vfven commited on
Commit
f2c3b03
·
verified ·
1 Parent(s): f38a870

Upload 6 files

Browse files
Files changed (3) hide show
  1. main.py +373 -531
  2. requirements.txt +1 -0
  3. templates/index.html +5 -5
main.py CHANGED
@@ -1,605 +1,447 @@
1
- import os
2
- import asyncio
3
- import httpx
4
- import json
5
- import re
6
- import uuid
7
- import base64
8
  from io import BytesIO
9
  from fastapi import FastAPI, Request
10
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
11
  from fastapi.middleware.cors import CORSMiddleware
12
  from datetime import datetime
13
  from pathlib import Path
14
-
15
- # python-docx imports
16
  from docx import Document as DocxDocument
17
  from docx.shared import Inches, Pt, RGBColor
18
  from docx.enum.text import WD_ALIGN_PARAGRAPH
19
  from docx.oxml.ns import qn
20
  from docx.oxml import OxmlElement
 
 
21
 
22
  app = FastAPI()
23
  app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
24
 
25
- GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY", "")
26
- OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
27
- GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
28
- PEXELS_API_KEY = os.getenv("PEXELS_API_KEY", "") # free at pexels.com/api
29
- HF_API_KEY = os.getenv("HF_TOKEN", "") # HuggingFace token for image gen
30
 
31
  DOCS_DIR = Path("docs")
32
  DOCS_DIR.mkdir(exist_ok=True)
33
 
34
  PROVIDERS = {
35
- "gemini": {
36
- "name": "Google Gemini", "type": "gemini", "key": GOOGLE_API_KEY,
37
- },
38
- "openrouter": {
39
- "name": "OpenRouter", "type": "openai_compat", "key": OPENROUTER_API_KEY,
40
- "base_url": "https://openrouter.ai/api/v1/chat/completions",
41
- "headers": {
42
- "HTTP-Referer": "https://huggingface.co/spaces/vfven/mission-control-ui",
43
- "X-Title": "Mission Control AI",
44
- },
45
- },
46
- "groq": {
47
- "name": "Groq", "type": "openai_compat", "key": GROQ_API_KEY,
48
- "base_url": "https://api.groq.com/openai/v1/chat/completions",
49
- "headers": {},
50
- },
51
  }
52
 
53
- # ── DEFAULT AGENTS (can be extended via UI) ───────────────────────────────
54
  DEFAULT_AGENTS = [
55
- {
56
- "key": "manager", "name": "Manager", "provider": "gemini",
57
- "role": "Gerente de proyecto experto en coordinar equipos y planificar estrategias. Cuando el usuario pide un documento o informe, DEBES indicar en tu respuesta qué agentes necesitas activar usando el formato JSON: {\"delegate\": [\"writer\", \"analyst\"]} al final de tu respuesta.",
58
- "models": ["gemini-2.5-flash-preview-04-17", "gemini-2.0-flash", "gemini-1.5-flash"],
59
- },
60
- {
61
- "key": "developer", "name": "Developer", "provider": "openrouter",
62
- "role": "Programador senior especialista en crear aplicaciones y soluciones técnicas.",
63
- "models": ["qwen/qwen3-4b:free", "meta-llama/llama-3.3-70b-instruct:free", "mistralai/mistral-small-3.1-24b-instruct:free", "google/gemma-3-12b-it:free"],
64
- },
65
- {
66
- "key": "analyst", "name": "Analyst", "provider": "openrouter",
67
- "role": "Analista de negocios experto en evaluar viabilidad, riesgos y oportunidades. También revisa y critica documentos formales.",
68
- "models": ["meta-llama/llama-3.3-70b-instruct:free", "mistralai/mistral-small-3.1-24b-instruct:free", "google/gemma-3-27b-it:free", "qwen/qwen3-4b:free"],
69
- },
70
- {
71
- "key": "writer", "name": "Writer", "provider": "openrouter",
72
- "role": "Especialista en redacción de documentos formales, informes ejecutivos y reportes técnicos. Escribe en formato estructurado con secciones claras usando ### para títulos y ** para subtítulos.",
73
- "models": ["meta-llama/llama-3.3-70b-instruct:free", "mistralai/mistral-small-3.1-24b-instruct:free", "qwen/qwen3-4b:free", "google/gemma-3-12b-it:free"],
74
- },
75
- {
76
- "key": "image_agent", "name": "ImageAgent", "provider": "gemini",
77
- "role": "Agente especializado en buscar y proveer imágenes relevantes. Cuando se te pida imágenes sobre un tema, responde con una lista JSON de términos de búsqueda en inglés: {\"image_queries\": [\"term1\", \"term2\", \"term3\"]}",
78
- "models": ["gemini-2.0-flash", "gemini-1.5-flash"],
79
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  ]
81
 
82
- SUBSTITUTE_MODELS = {
83
- "groq": ["llama-3.3-70b-versatile", "llama3-70b-8192", "gemma2-9b-it"],
84
- }
85
-
86
- # Runtime agent registry (can be extended)
87
  agent_registry = {a["key"]: dict(a) for a in DEFAULT_AGENTS}
88
  mission_history = []
89
 
90
-
91
- # ── LLM CALLERS ───────────────────────────────────────────────────────────
92
- async def call_gemini(model: str, system: str, user: str, key: str) -> str:
93
  url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}"
94
- payload = {
95
- "contents": [{"role": "user", "parts": [{"text": f"{system}\n\n{user}"}]}],
96
- "generationConfig": {"maxOutputTokens": 2048, "temperature": 0.7},
97
- }
98
- async with httpx.AsyncClient(timeout=90) as client:
99
- r = await client.post(url, json=payload)
100
  r.raise_for_status()
101
  return r.json()["candidates"][0]["content"]["parts"][0]["text"]
102
 
103
-
104
- async def call_openai_compat(base_url: str, model: str, system: str, user: str,
105
- key: str, extra_headers: dict) -> str:
106
- headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json", **extra_headers}
107
- payload = {
108
- "model": model,
109
- "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
110
- "max_tokens": 2048, "temperature": 0.7,
111
- }
112
- async with httpx.AsyncClient(timeout=90) as client:
113
- r = await client.post(base_url, json=payload, headers=headers)
114
  r.raise_for_status()
115
  return r.json()["choices"][0]["message"]["content"]
116
 
117
-
118
- async def call_agent_llm(agent: dict, task: str) -> str:
119
- prov_key = agent["provider"]
120
- provider = PROVIDERS[prov_key]
121
- system = f"Eres {agent['name']}. {agent['role']} Responde en español. Sé conciso y profesional."
122
- last_err = None
123
-
124
- for model in agent["models"]:
125
  try:
126
- if provider["type"] == "gemini":
127
- return await call_gemini(model, system, task, provider["key"])
128
- else:
129
- return await call_openai_compat(
130
- provider["base_url"], model, system, task,
131
- provider["key"], provider.get("headers", {}))
132
- except Exception as e:
133
- last_err = str(e)
134
-
135
- # Groq fallback
136
- if GROQ_API_KEY:
137
- groq = PROVIDERS["groq"]
138
- for m in SUBSTITUTE_MODELS["groq"]:
139
- try:
140
- return await call_openai_compat(
141
- groq["base_url"], m, system, task, groq["key"], {})
142
- except Exception as e:
143
- last_err = str(e)
144
-
145
- raise Exception(f"All providers failed: {last_err}")
146
-
147
-
148
- # ── IMAGE FETCHING ─────────────────────────────────────────────────────────
149
- async def fetch_pexels_image(query: str) -> bytes | None:
150
- if not PEXELS_API_KEY:
151
- return None
152
  try:
153
- async with httpx.AsyncClient(timeout=20) as client:
154
- r = await client.get(
155
- "https://api.pexels.com/v1/search",
156
- params={"query": query, "per_page": 1, "orientation": "landscape"},
157
- headers={"Authorization": PEXELS_API_KEY}
158
- )
159
- data = r.json()
160
- if data.get("photos"):
161
- img_url = data["photos"][0]["src"]["medium"]
162
- img_r = await client.get(img_url)
163
- return img_r.content
164
- except Exception:
165
- pass
166
  return None
167
 
168
-
169
- async def generate_hf_image(prompt: str) -> bytes | None:
170
- if not HF_API_KEY:
171
- return None
172
  try:
173
- async with httpx.AsyncClient(timeout=60) as client:
174
- r = await client.post(
175
- "https://api-inference.huggingface.co/models/stabilityai/stable-diffusion-xl-base-1.0",
176
- headers={"Authorization": f"Bearer {HF_API_KEY}"},
177
- json={"inputs": prompt, "parameters": {"width": 512, "height": 384}},
178
- )
179
- if r.status_code == 200 and r.headers.get("content-type", "").startswith("image"):
180
  return r.content
181
- except Exception:
182
- pass
183
  return None
184
 
 
 
185
 
186
- async def get_image_for_query(query: str) -> bytes | None:
187
- img = await fetch_pexels_image(query)
188
- if img:
189
- return img
190
- return await generate_hf_image(query)
191
-
 
 
 
 
 
192
 
193
- # ── DOCX BUILDER ──────────────────────────────────────────────────────────
194
- def build_docx(title: str, sections: dict, images: list[bytes], analyst_review: str) -> bytes:
195
- doc = DocxDocument()
196
 
197
- # Page margins
198
- for section in doc.sections:
199
- section.top_margin = Inches(1)
200
- section.bottom_margin = Inches(1)
201
- section.left_margin = Inches(1.2)
202
- section.right_margin = Inches(1.2)
203
 
204
- # Title
205
- title_para = doc.add_heading(title, 0)
206
- title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
207
- run = title_para.runs[0]
208
- run.font.color.rgb = RGBColor(0x1a, 0x56, 0xdb)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
- # Date + subtitle
211
- sub = doc.add_paragraph()
212
- sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
213
- sub.add_run(f"Mission Control AI — {datetime.now().strftime('%B %d, %Y')}").italic = True
 
 
 
214
  doc.add_paragraph()
215
-
216
- # Horizontal rule
217
- p = doc.add_paragraph()
218
- pPr = p._p.get_or_add_pPr()
219
- pBdr = OxmlElement("w:pBdr")
220
- bottom = OxmlElement("w:bottom")
221
- bottom.set(qn("w:val"), "single")
222
- bottom.set(qn("w:sz"), "6")
223
- bottom.set(qn("w:color"), "1a56db")
224
- pBdr.append(bottom)
225
- pPr.append(pBdr)
226
-
227
- img_index = 0
228
-
229
- # Writer content sections
230
- writer_text = sections.get("writer", "")
231
- current_lines = []
232
-
233
- def flush_lines():
234
- nonlocal current_lines
235
- if current_lines:
236
- para_text = " ".join(current_lines).strip()
237
- if para_text:
238
- p = doc.add_paragraph(para_text)
239
- p.paragraph_format.space_after = Pt(6)
240
- current_lines = []
241
-
242
  for line in writer_text.split("\n"):
243
- stripped = line.strip()
244
- if not stripped:
245
- flush_lines()
246
- continue
247
- if stripped.startswith("### "):
248
- flush_lines()
249
- h = doc.add_heading(stripped[4:], level=2)
250
- # Insert image after each major section heading if available
251
- if img_index < len(images) and images[img_index]:
252
- try:
253
- img_stream = BytesIO(images[img_index])
254
- doc.add_picture(img_stream, width=Inches(5))
255
- last_para = doc.paragraphs[-1]
256
- last_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
257
- cap = doc.add_paragraph(f"Figure {img_index + 1}")
258
- cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
259
- cap.runs[0].italic = True
260
- cap.runs[0].font.size = Pt(9)
261
- img_index += 1
262
- except Exception:
263
- pass
264
- elif stripped.startswith("## "):
265
- flush_lines()
266
- doc.add_heading(stripped[3:], level=1)
267
- elif stripped.startswith("**") and stripped.endswith("**"):
268
- flush_lines()
269
- p = doc.add_paragraph()
270
- run = p.add_run(stripped.strip("**"))
271
- run.bold = True
272
- elif stripped.startswith("- ") or stripped.startswith("* "):
273
- flush_lines()
274
- doc.add_paragraph(stripped[2:], style="List Bullet")
275
- else:
276
- current_lines.append(stripped)
277
-
278
- flush_lines()
279
-
280
- # Remaining images
281
- while img_index < len(images):
282
- if images[img_index]:
283
- try:
284
- img_stream = BytesIO(images[img_index])
285
- doc.add_picture(img_stream, width=Inches(5))
286
- last_para = doc.paragraphs[-1]
287
- last_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
288
- img_index += 1
289
- except Exception:
290
- img_index += 1
291
- else:
292
- img_index += 1
293
-
294
- # Analyst review section
295
- if analyst_review:
296
- doc.add_page_break()
297
- doc.add_heading("Análisis y Revisión", level=1)
298
- for line in analyst_review.split("\n"):
299
- if line.strip():
300
- doc.add_paragraph(line.strip())
301
-
302
- # Footer
303
- doc.add_paragraph()
304
- footer_p = doc.add_paragraph()
305
- footer_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
306
- footer_run = footer_p.add_run("— Generado por Mission Control AI —")
307
- footer_run.italic = True
308
- footer_run.font.size = Pt(9)
309
- footer_run.font.color.rgb = RGBColor(0x6b, 0x72, 0x80)
310
-
311
- buf = BytesIO()
312
- doc.save(buf)
313
- buf.seek(0)
314
  return buf.read()
315
 
316
-
317
- # ── PARSE MANAGER DELEGATION ───────────────────────────────────────────────
318
- def parse_delegation(manager_text: str) -> list[str]:
319
- match = re.search(r'\{[^}]*"delegate"\s*:\s*\[([^\]]*)\][^}]*\}', manager_text)
320
- if match:
321
- raw = match.group(1)
322
- keys = re.findall(r'"(\w+)"', raw)
323
- return keys
324
- # Heuristic fallback: detect keywords in manager response
325
- delegates = []
326
- lower = manager_text.lower()
327
- if any(w in lower for w in ["informe", "documento", "redact", "escrib", "report", "word"]):
328
- delegates.extend(["writer"])
329
- if any(w in lower for w in ["imagen", "image", "foto", "visual", "ilustr"]):
330
- delegates.extend(["image_agent"])
331
- if any(w in lower for w in ["analiz", "revisar", "evalua", "critic"]):
332
- delegates.extend(["analyst"])
333
- return list(dict.fromkeys(delegates)) # deduplicate
334
-
335
-
336
- def parse_image_queries(image_agent_text: str) -> list[str]:
337
- match = re.search(r'"image_queries"\s*:\s*\[([^\]]*)\]', image_agent_text)
338
- if match:
339
- return re.findall(r'"([^"]+)"', match.group(1))
340
- return []
341
-
342
-
343
- def clean_text(text: str) -> str:
344
- # Remove JSON blocks from display text
345
- text = re.sub(r'\{[^}]*"delegate"[^}]*\}', '', text)
346
- text = re.sub(r'\{[^}]*"image_queries"[^}]*\}', '', text)
347
- return text.strip()
348
-
349
-
350
- # ── ROUTES ─────────────────────────────────────────────────────────────────
351
- @app.get("/", response_class=HTMLResponse)
352
- async def root():
353
- return HTMLResponse(Path("templates/index.html").read_text())
354
-
355
 
356
  @app.get("/api/agents")
357
- async def get_agents():
358
- return {"agents": [
359
- {"key": a["key"], "name": a["name"], "role": a["role"]}
360
- for a in agent_registry.values()
361
- ]}
362
-
363
 
364
  @app.post("/api/agents/add")
365
- async def add_agent(request: Request):
366
- body = await request.json()
367
- key = re.sub(r'\W+', '_', body.get("key", "").lower().strip())
368
- if not key:
369
- return JSONResponse({"error": "key required"}, status_code=400)
370
- agent_registry[key] = {
371
- "key": key,
372
- "name": body.get("name", key.capitalize()),
373
- "role": body.get("role", "Agente de propósito general."),
374
- "provider": body.get("provider", "openrouter"),
375
- "models": body.get("models", ["meta-llama/llama-3.3-70b-instruct:free"]),
376
- }
377
- return {"success": True, "agent": agent_registry[key]}
378
-
379
 
380
  @app.delete("/api/agents/{key}")
381
- async def delete_agent(key: str):
382
- if key in ("manager", "developer", "analyst"):
383
- return JSONResponse({"error": "Cannot delete core agents"}, status_code=400)
384
- if key in agent_registry:
385
- del agent_registry[key]
386
- return {"success": True}
387
-
388
 
389
  @app.get("/api/history")
390
- async def get_history():
391
- return {"history": mission_history[-20:]}
392
-
393
 
394
  @app.get("/api/docs/{filename}")
395
- async def download_doc(filename: str):
396
- path = DOCS_DIR / filename
397
- if not path.exists() or not filename.endswith(".docx"):
398
- return JSONResponse({"error": "File not found"}, status_code=404)
399
- return FileResponse(
400
- path,
401
- media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
402
- filename=filename,
403
- )
404
-
405
 
406
  @app.post("/api/mission")
407
- async def run_mission(request: Request):
408
- body = await request.json()
409
- task = body.get("task", "").strip()
410
- if not task:
411
- return JSONResponse({"error": "No task provided"}, status_code=400)
412
-
413
- started_at = datetime.now().isoformat()
414
- results = {}
415
- doc_file = None
416
- events = [] # orchestration event log
417
-
418
- def log(msg: str):
419
- events.append({"time": datetime.now().strftime("%H:%M:%S"), "msg": msg})
420
-
421
- # ── STEP 1: Manager plans ──────────────────────────────────────────────
422
- log("Manager analyzing task...")
423
- manager_agent = agent_registry["manager"]
424
  try:
425
- manager_raw = await call_agent_llm(manager_agent, task)
426
- delegates = parse_delegation(manager_raw)
427
- manager_msg = clean_text(manager_raw)
428
- results["manager"] = {
429
- "status": "active", "message": manager_msg,
430
- "model": manager_agent["models"][0],
431
- "delegates": delegates,
432
- }
433
- log(f"Manager delegated to: {delegates or 'none'}")
434
  except Exception as e:
435
- results["manager"] = {"status": "resting", "message": str(e), "model": ""}
436
- delegates = []
437
- log(f"Manager failed: {e}")
438
-
439
- # ── STEP 2: Run delegated agents ───────────────────────────────────────
440
- image_bytes = []
441
- writer_text = ""
442
- analyst_text = ""
443
-
444
- async def run_delegated(key: str):
445
- nonlocal writer_text, analyst_text
446
- if key not in agent_registry:
447
- log(f"Agent '{key}' not found in registry")
448
- return
449
-
450
- agent = agent_registry[key]
451
- log(f"{agent['name']} starting...")
452
-
453
- if key == "image_agent":
454
- try:
455
- img_prompt = f"Proporciona consultas de búsqueda de imágenes en inglés para ilustrar: {task}"
456
- raw = await call_agent_llm(agent, img_prompt)
457
- queries = parse_image_queries(raw)
458
- if not queries:
459
- queries = [task[:40]]
460
- log(f"ImageAgent searching: {queries}")
461
- imgs = await asyncio.gather(*[get_image_for_query(q) for q in queries[:3]])
462
  for img in imgs:
463
- if img:
464
- image_bytes.append(img)
465
- results["image_agent"] = {
466
- "status": "active",
467
- "message": f"Encontradas {len(image_bytes)} imágenes para: {', '.join(queries[:3])}",
468
- "model": agent["models"][0],
469
- }
470
- log(f"ImageAgent: {len(image_bytes)} images found")
471
- except Exception as e:
472
- results["image_agent"] = {"status": "resting", "message": str(e), "model": ""}
473
- log(f"ImageAgent failed: {e}")
474
-
475
- elif key == "writer":
476
- try:
477
- manager_context = results.get("manager", {}).get("message", "")
478
- writer_prompt = (
479
- f"Eres un redactor experto. Escribe el contenido REAL y COMPLETO del siguiente informe.\n\n"
480
- f"TEMA DEL INFORME: {task}\n\n"
481
- f"INSTRUCCIONES DEL MANAGER:\n{manager_context[:500]}\n\n"
482
- "INSTRUCCIONES IMPORTANTES:\n"
483
- "- Escribe contenido REAL, detallado y específico sobre el tema. NO uses placeholders ni corchetes.\n"
484
- "- El informe debe tener mínimo 600 palabras de contenido real.\n"
485
- "- Usa datos, hechos y ejemplos concretos relacionados con el tema.\n"
486
- "- Estructura con estos encabezados EXACTOS (sin corchetes, solo el contenido real):\n\n"
487
- "## Resumen Ejecutivo\n"
488
- "(Escribe aquí 2-3 párrafos resumiendo los puntos clave del informe)\n\n"
489
- "### Introducción\n"
490
- "(Escribe aquí contexto e importancia del tema)\n\n"
491
- "### Desarrollo\n"
492
- "(Escribe aquí el análisis detallado con datos y hechos reales)\n\n"
493
- "### Hallazgos Principales\n"
494
- "(Escribe aquí los descubrimientos o puntos más importantes)\n\n"
495
- "### Conclusiones\n"
496
- "(Escribe aquí las conclusiones derivadas del análisis)\n\n"
497
- "### Recomendaciones\n"
498
- "(Escribe aquí recomendaciones concretas y accionables)\n\n"
499
- "IMPORTANTE: Escribe contenido real y sustancioso. Nada de '[contenido]' ni placeholders."
500
- )
501
- writer_text = await call_agent_llm(agent, writer_prompt)
502
- results["writer"] = {
503
- "status": "active",
504
- "message": writer_text[:200] + "..." if len(writer_text) > 200 else writer_text,
505
- "model": agent["models"][0],
506
- }
507
- log("Writer completed document")
508
- except Exception as e:
509
- results["writer"] = {"status": "resting", "message": str(e), "model": ""}
510
- log(f"Writer failed: {e}")
511
-
512
- elif key == "analyst":
513
- try:
514
- content_to_review = writer_text or manager_msg
515
- analyst_prompt = (
516
- f"Revisa y analiza el siguiente contenido sobre: {task}\n\n"
517
- f"Contenido:\n{content_to_review[:1000]}\n\n"
518
- "Proporciona: 1) Evaluación de calidad, 2) Puntos fuertes, "
519
- "3) Áreas de mejora, 4) Conclusión final."
520
- )
521
- analyst_text = await call_agent_llm(agent, analyst_prompt)
522
- results["analyst"] = {
523
- "status": "active",
524
- "message": analyst_text[:200] + "..." if len(analyst_text) > 200 else analyst_text,
525
- "model": agent["models"][0],
526
- }
527
- log("Analyst review completed")
528
- except Exception as e:
529
- results["analyst"] = {"status": "resting", "message": str(e), "model": ""}
530
- log(f"Analyst failed: {e}")
531
-
532
- else:
533
- # Generic agent
534
- try:
535
- raw = await call_agent_llm(agent, task)
536
- results[key] = {"status": "active", "message": raw, "model": agent["models"][0]}
537
- log(f"{agent['name']} completed")
538
- except Exception as e:
539
- results[key] = {"status": "resting", "message": str(e), "model": ""}
540
-
541
- # Run image_agent and writer in parallel, then analyst
542
- parallel_first = [k for k in delegates if k in ("writer", "image_agent")]
543
- sequential_after = [k for k in delegates if k == "analyst"]
544
- other = [k for k in delegates if k not in ("writer", "image_agent", "analyst")]
545
-
546
- if parallel_first or other:
547
- await asyncio.gather(*[run_delegated(k) for k in parallel_first + other])
548
-
549
- if sequential_after:
550
- for k in sequential_after:
551
- await run_delegated(k)
552
-
553
- # ── STEP 3: Build .docx if writer was involved ─────────────────────────
554
  if writer_text:
555
- log("Assembling Word document...")
556
  try:
557
- doc_bytes = build_docx(task, {"writer": writer_text}, image_bytes, analyst_text)
558
- safe_name = re.sub(r'[^\w\-]', '_', task[:40])
559
- doc_filename = f"{safe_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.docx"
560
- (DOCS_DIR / doc_filename).write_bytes(doc_bytes)
561
- doc_file = doc_filename
562
- results["manager"]["doc_file"] = doc_filename
563
- log(f"Document ready: {doc_filename}")
564
- except Exception as e:
565
- log(f"Document build failed: {e}")
566
-
567
- # Mark idle agents
568
- for key in agent_registry:
569
- if key not in results:
570
- results[key] = {"status": "idle", "message": "", "model": ""}
571
-
572
- final = results.get("manager", {}).get("message", "")[:300]
573
-
574
- entry = {
575
- "id": len(mission_history) + 1,
576
- "task": task,
577
- "started_at": started_at,
578
- "ended_at": datetime.now().isoformat(),
579
- "results": results,
580
- "final": final,
581
- "doc_file": doc_file,
582
- "events": events,
583
- }
584
- mission_history.append(entry)
585
 
586
- return JSONResponse({
587
- "success": True, "task": task,
588
- "results": results, "final": final,
589
- "doc_file": doc_file, "events": events,
590
- "mission_id": entry["id"],
591
- })
 
592
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
593
 
594
  @app.get("/api/health")
595
  async def health():
596
- return {
597
- "status": "ok",
598
- "providers": {
599
- "gemini": "ok" if GOOGLE_API_KEY else "missing",
600
- "openrouter": "ok" if OPENROUTER_API_KEY else "missing",
601
- "groq": "ok" if GROQ_API_KEY else "missing",
602
- "pexels": "ok" if PEXELS_API_KEY else "missing (optional)",
603
- "hf_images": "ok" if HF_API_KEY else "missing (optional)",
604
- }
605
- }
 
1
+ import os, asyncio, httpx, re, json as _json
 
 
 
 
 
 
2
  from io import BytesIO
3
  from fastapi import FastAPI, Request
4
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
5
  from fastapi.middleware.cors import CORSMiddleware
6
  from datetime import datetime
7
  from pathlib import Path
 
 
8
  from docx import Document as DocxDocument
9
  from docx.shared import Inches, Pt, RGBColor
10
  from docx.enum.text import WD_ALIGN_PARAGRAPH
11
  from docx.oxml.ns import qn
12
  from docx.oxml import OxmlElement
13
+ import openpyxl
14
+ from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
15
 
16
  app = FastAPI()
17
  app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
18
 
19
+ GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY","")
20
+ OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY","")
21
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY","")
22
+ PEXELS_API_KEY = os.getenv("PEXELS_API_KEY","")
23
+ HF_API_KEY = os.getenv("HF_TOKEN","")
24
 
25
  DOCS_DIR = Path("docs")
26
  DOCS_DIR.mkdir(exist_ok=True)
27
 
28
  PROVIDERS = {
29
+ "gemini": {"name":"Google Gemini","type":"gemini","key":GOOGLE_API_KEY},
30
+ "openrouter": {"name":"OpenRouter","type":"openai_compat","key":OPENROUTER_API_KEY,
31
+ "base_url":"https://openrouter.ai/api/v1/chat/completions",
32
+ "headers":{"HTTP-Referer":"https://huggingface.co/spaces/vfven/mission-control-ui","X-Title":"Mission Control AI"}},
33
+ "groq": {"name":"Groq","type":"openai_compat","key":GROQ_API_KEY,
34
+ "base_url":"https://api.groq.com/openai/v1/chat/completions","headers":{}},
 
 
 
 
 
 
 
 
 
 
35
  }
36
 
 
37
  DEFAULT_AGENTS = [
38
+ {"key":"manager","name":"Manager","provider":"gemini",
39
+ "role":(
40
+ "Eres el gerente de proyecto. Analiza la solicitud y decide qué agentes trabajarán. "
41
+ "NUNCA hagas el trabajo tú mismo. Saluda en 1 línea y delega siempre con JSON al final: "
42
+ '{"delegate":["key1","key2"]}\n'
43
+ "REGLAS:\n"
44
+ "- imagen/foto/gato/perro/dibujo image_agent\n"
45
+ "- informe/reporte/word/documento writer + analyst\n"
46
+ "- excel/planilla/hoja/spreadsheet/registrar → backend_dev\n"
47
+ "- python/script/groovy/jenkins/api/devops → backend_dev\n"
48
+ "- html/css/web/interfaz/frontend → frontend_dev\n"
49
+ "- app completa full-stack backend_dev + frontend_dev\n"
50
+ "- analisis/viabilidad/evaluar analyst"
51
+ ),
52
+ "models":["gemini-2.5-flash-preview-04-17","gemini-2.0-flash","gemini-1.5-flash"]},
53
+ {"key":"backend_dev","name":"Backend","provider":"openrouter",
54
+ "role":(
55
+ "Eres programador backend senior. REGLAS ABSOLUTAS:\n"
56
+ "1. Entrega SOLO el código pedido, sin explicaciones innecesarias.\n"
57
+ "2. Excel/planilla → responde con EXCEL_TEMPLATE:{\"title\":\"...\",\"sheet_name\":\"...\","
58
+ "\"headers\":[...],\"sample_rows\":[[...],[...]]}\n"
59
+ "3. Python entrega código Python puro y funcional.\n"
60
+ "4. Groovy/Jenkins entrega el script completo.\n"
61
+ "5. Si hay frontend_dev en el equipo, TÚ haces servidor/backend, él hace HTML.\n"
62
+ "6. Si la tarea no requiere backend → responde: {\"skip\":\"no backend needed\"}"
63
+ ),
64
+ "models":["qwen/qwen3-4b:free","meta-llama/llama-3.3-70b-instruct:free",
65
+ "mistralai/mistral-small-3.1-24b-instruct:free","google/gemma-3-12b-it:free"]},
66
+ {"key":"frontend_dev","name":"Frontend","provider":"openrouter",
67
+ "role":(
68
+ "Eres desarrollador frontend senior. REGLAS ABSOLUTAS:\n"
69
+ "1. Entrega SOLO código HTML/CSS/JS pedido, sin explicaciones innecesarias.\n"
70
+ "2. Si hay backend_dev, TÚ haces HTML/interfaz, él hace servidor/lógica.\n"
71
+ "3. Si la tarea NO requiere frontend → responde: {\"skip\":\"no frontend needed\"}\n"
72
+ "4. Entrega siempre HTML completo y funcional con los estilos incluidos."
73
+ ),
74
+ "models":["meta-llama/llama-3.3-70b-instruct:free","mistralai/mistral-small-3.1-24b-instruct:free",
75
+ "qwen/qwen3-4b:free","google/gemma-3-12b-it:free"]},
76
+ {"key":"analyst","name":"Analyst","provider":"openrouter",
77
+ "role":(
78
+ "Eres analista de negocios. REGLAS:\n"
79
+ "1. Solo haz lo que el manager delegó: revisar documentos, evaluar viabilidad, analizar riesgos.\n"
80
+ "2. NUNCA describas imágenes ni hagas trabajo de otros agentes.\n"
81
+ "3. Si la tarea no requiere análisis → responde: {\"skip\":\"no analysis needed\"}"
82
+ ),
83
+ "models":["meta-llama/llama-3.3-70b-instruct:free","mistralai/mistral-small-3.1-24b-instruct:free",
84
+ "google/gemma-3-27b-it:free","qwen/qwen3-4b:free"]},
85
+ {"key":"writer","name":"Writer","provider":"openrouter",
86
+ "role":(
87
+ "Eres redactor experto. Escribe SOLO contenido real y extenso (500+ palabras). "
88
+ "Sin placeholders. Usa ## para secciones y ### para subsecciones. "
89
+ "Secciones: ## Resumen Ejecutivo, ### Introducción, ### Desarrollo, "
90
+ "### Hallazgos, ### Conclusiones, ### Recomendaciones"
91
+ ),
92
+ "models":["meta-llama/llama-3.3-70b-instruct:free","mistralai/mistral-small-3.1-24b-instruct:free",
93
+ "qwen/qwen3-4b:free","google/gemma-3-12b-it:free"]},
94
+ {"key":"image_agent","name":"ImageAgent","provider":"gemini",
95
+ "role":(
96
+ "Cuando se te pida imágenes, responde SOLO con: "
97
+ "{\"image_queries\":[\"english term 1\",\"english term 2\",\"english term 3\"]} "
98
+ "Los términos deben ser específicos en inglés para encontrar buenas imágenes."
99
+ ),
100
+ "models":["gemini-2.0-flash","gemini-1.5-flash"]},
101
  ]
102
 
 
 
 
 
 
103
  agent_registry = {a["key"]: dict(a) for a in DEFAULT_AGENTS}
104
  mission_history = []
105
 
106
+ async def call_gemini(model,system,user,key):
 
 
107
  url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}"
108
+ async with httpx.AsyncClient(timeout=90) as c:
109
+ r = await c.post(url,json={"contents":[{"role":"user","parts":[{"text":f"{system}\n\n{user}"}]}],"generationConfig":{"maxOutputTokens":2048,"temperature":0.4}})
 
 
 
 
110
  r.raise_for_status()
111
  return r.json()["candidates"][0]["content"]["parts"][0]["text"]
112
 
113
+ async def call_compat(base_url,model,system,user,key,headers):
114
+ h = {"Authorization":f"Bearer {key}","Content-Type":"application/json",**headers}
115
+ async with httpx.AsyncClient(timeout=90) as c:
116
+ r = await c.post(base_url,json={"model":model,"messages":[{"role":"system","content":system},{"role":"user","content":user}],"max_tokens":2048,"temperature":0.4},headers=h)
 
 
 
 
 
 
 
117
  r.raise_for_status()
118
  return r.json()["choices"][0]["message"]["content"]
119
 
120
+ async def call_llm(agent,task):
121
+ p = PROVIDERS[agent["provider"]]
122
+ err = None
123
+ for m in agent["models"]:
 
 
 
 
124
  try:
125
+ if p["type"]=="gemini": return await call_gemini(m,agent["role"],task,p["key"])
126
+ else: return await call_compat(p["base_url"],m,agent["role"],task,p["key"],p.get("headers",{}))
127
+ except Exception as e: err=str(e)
128
+ if GROQ_API_KEY and agent["provider"]!="groq":
129
+ for m in ["llama-3.3-70b-versatile","llama3-70b-8192","gemma2-9b-it"]:
130
+ try: return await call_compat(PROVIDERS["groq"]["base_url"],m,agent["role"],task,GROQ_API_KEY,{})
131
+ except Exception as e: err=str(e)
132
+ raise Exception(f"All providers failed: {err}")
133
+
134
+ async def fetch_pexels(q):
135
+ if not PEXELS_API_KEY: return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  try:
137
+ async with httpx.AsyncClient(timeout=20) as c:
138
+ r = await c.get("https://api.pexels.com/v1/search",params={"query":q,"per_page":1,"orientation":"landscape"},headers={"Authorization":PEXELS_API_KEY})
139
+ d = r.json()
140
+ if d.get("photos"):
141
+ ir = await c.get(d["photos"][0]["src"]["medium"])
142
+ return ir.content
143
+ except: pass
 
 
 
 
 
 
144
  return None
145
 
146
+ async def gen_hf_image(p):
147
+ if not HF_API_KEY: return None
 
 
148
  try:
149
+ async with httpx.AsyncClient(timeout=60) as c:
150
+ r = await c.post("https://api-inference.huggingface.co/models/stabilityai/stable-diffusion-xl-base-1.0",
151
+ headers={"Authorization":f"Bearer {HF_API_KEY}"},json={"inputs":p,"parameters":{"width":512,"height":384}})
152
+ if r.status_code==200 and "image" in r.headers.get("content-type",""):
 
 
 
153
  return r.content
154
+ except: pass
 
155
  return None
156
 
157
+ async def get_image(q):
158
+ return await fetch_pexels(q) or await gen_hf_image(q)
159
 
160
+ def classify(task):
161
+ lo = task.lower()
162
+ return {
163
+ "img": any(w in lo for w in ["imagen","image","foto","picture","gato","cat","dog","perro","dibuja","genera una imagen","crea una imagen","ilustra"]),
164
+ "excel": any(w in lo for w in ["excel","xlsx","planilla","hoja de calculo","hoja de cálculo","spreadsheet","registro de","registrar alumnos","tabla de alumnos","plantilla de"]),
165
+ "word": any(w in lo for w in ["informe","reporte","report","documento word","docx","redacta un informe","escribe un informe"]),
166
+ "anal": any(w in lo for w in ["analiza","evalua","evalúa","viabilidad","riesgo","revisar"]),
167
+ "back": any(w in lo for w in ["python","script","groovy","jenkins","api","backend","fastapi","flask","devops","pipeline",".py"]),
168
+ "front": any(w in lo for w in ["html","css","frontend","web page","página web","pagina web","sitio web","interfaz web"]),
169
+ "both": any(w in lo for w in ["hola mundo","full stack","fullstack","app completa"]) and any(w in lo for w in ["python","backend",".py"]) and any(w in lo for w in ["html","web","ver en","interfaz"]),
170
+ }
171
 
172
+ def parse_delegates(text):
173
+ m = re.search(r'\{"delegate"\s*:\s*\[([^\]]*)\]\}',text)
174
+ return re.findall(r'"(\w+)"',m.group(1)) if m else []
175
 
176
+ def clean(text):
177
+ text = re.sub(r'\{"delegate"[^}]*\}','',text)
178
+ text = re.sub(r'\{"image_queries"[^}]*\}','',text)
179
+ return text.strip()
 
 
180
 
181
+ def is_skip(text): return '"skip"' in text.lower()
182
+
183
+ def build_excel(task,backend_text):
184
+ m = re.search(r'EXCEL_TEMPLATE:\s*(\{.*?\})\s*$',backend_text,re.DOTALL|re.MULTILINE)
185
+ if not m:
186
+ m = re.search(r'EXCEL_TEMPLATE:\s*(\{.*)',backend_text,re.DOTALL)
187
+ if not m: return None
188
+ try: structure = _json.loads(m.group(1))
189
+ except: return None
190
+ wb = openpyxl.Workbook(); ws = wb.active
191
+ ws.title = structure.get("sheet_name","Datos")[:31]
192
+ headers = structure.get("headers",[])
193
+ rows = structure.get("sample_rows",[])
194
+ title = structure.get("title",task[:50])
195
+ thin = Side(style="thin",color="D1D5DB")
196
+ bdr = Border(left=thin,right=thin,top=thin,bottom=thin)
197
+ row_off = 1
198
+ if title and headers:
199
+ ws.merge_cells(f"A1:{chr(64+len(headers))}1")
200
+ c = ws["A1"]; c.value=title
201
+ c.font=Font(bold=True,size=13,color="FFFFFF")
202
+ c.fill=PatternFill("solid",fgColor="1a56db")
203
+ c.alignment=Alignment(horizontal="center",vertical="center")
204
+ ws.row_dimensions[1].height=28; row_off=2
205
+ for col,h in enumerate(headers,1):
206
+ c=ws.cell(row=row_off,column=col,value=h)
207
+ c.font=Font(bold=True,color="FFFFFF",size=10)
208
+ c.fill=PatternFill("solid",fgColor="2563eb")
209
+ c.alignment=Alignment(horizontal="center",vertical="center")
210
+ c.border=bdr
211
+ ws.column_dimensions[c.column_letter].width=max(len(str(h))+6,14)
212
+ ws.row_dimensions[row_off].height=20
213
+ alt=PatternFill("solid",fgColor="EFF6FF")
214
+ for ri,row in enumerate(rows,row_off+1):
215
+ for col,val in enumerate(row,1):
216
+ c=ws.cell(row=ri,column=col,value=val)
217
+ c.border=bdr; c.alignment=Alignment(vertical="center")
218
+ if ri%2==0: c.fill=alt
219
+ ws.row_dimensions[ri].height=18
220
+ ws.freeze_panes=f"A{row_off+1}"
221
+ buf=BytesIO(); wb.save(buf); buf.seek(0)
222
+ return buf.read()
223
 
224
+ def build_docx(title,writer_text,images,analyst_text):
225
+ doc=DocxDocument()
226
+ for s in doc.sections: s.top_margin=s.bottom_margin=Inches(1); s.left_margin=s.right_margin=Inches(1.2)
227
+ tp=doc.add_heading(title,0); tp.alignment=WD_ALIGN_PARAGRAPH.CENTER
228
+ if tp.runs: tp.runs[0].font.color.rgb=RGBColor(0x1a,0x56,0xdb)
229
+ sub=doc.add_paragraph(); sub.alignment=WD_ALIGN_PARAGRAPH.CENTER
230
+ sub.add_run(f"Mission Control AI — {datetime.now().strftime('%B %d, %Y')}").italic=True
231
  doc.add_paragraph()
232
+ p=doc.add_paragraph(); pPr=p._p.get_or_add_pPr(); pBdr=OxmlElement("w:pBdr")
233
+ bot=OxmlElement("w:bottom"); bot.set(qn("w:val"),"single"); bot.set(qn("w:sz"),"6"); bot.set(qn("w:color"),"1a56db")
234
+ pBdr.append(bot); pPr.append(pBdr)
235
+ ii=0; pending=[]
236
+ def flush():
237
+ nonlocal pending
238
+ t=" ".join(pending).strip()
239
+ if t: p2=doc.add_paragraph(t); p2.paragraph_format.space_after=Pt(6)
240
+ pending.clear()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  for line in writer_text.split("\n"):
242
+ s=line.strip()
243
+ if not s: flush(); continue
244
+ if s.startswith("## "):
245
+ flush(); doc.add_heading(s[3:],level=1)
246
+ if ii<len(images) and images[ii]:
247
+ try: doc.add_picture(BytesIO(images[ii]),width=Inches(5)); doc.paragraphs[-1].alignment=WD_ALIGN_PARAGRAPH.CENTER; ii+=1
248
+ except: pass
249
+ elif s.startswith("### "): flush(); doc.add_heading(s[4:],level=2)
250
+ elif s.startswith("- ") or s.startswith("* "): flush(); doc.add_paragraph(s[2:],style="List Bullet")
251
+ else: pending.append(s)
252
+ flush()
253
+ while ii<len(images):
254
+ if images[ii]:
255
+ try: doc.add_picture(BytesIO(images[ii]),width=Inches(5)); doc.paragraphs[-1].alignment=WD_ALIGN_PARAGRAPH.CENTER
256
+ except: pass
257
+ ii+=1
258
+ if analyst_text and not is_skip(analyst_text):
259
+ doc.add_page_break(); doc.add_heading("Análisis y Revisión",level=1)
260
+ for line in analyst_text.split("\n"):
261
+ if line.strip(): doc.add_paragraph(line.strip())
262
+ fp=doc.add_paragraph(); fp.alignment=WD_ALIGN_PARAGRAPH.CENTER
263
+ fr=fp.add_run(" Generado por Mission Control AI —"); fr.italic=True; fr.font.size=Pt(9); fr.font.color.rgb=RGBColor(0x6b,0x72,0x80)
264
+ buf=BytesIO(); doc.save(buf); buf.seek(0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  return buf.read()
266
 
267
+ @app.get("/",response_class=HTMLResponse)
268
+ async def root(): return HTMLResponse(Path("templates/index.html").read_text())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
 
270
  @app.get("/api/agents")
271
+ async def get_agents(): return {"agents":[{"key":a["key"],"name":a["name"],"role":a["role"]} for a in agent_registry.values()]}
 
 
 
 
 
272
 
273
  @app.post("/api/agents/add")
274
+ async def add_agent(request:Request):
275
+ body=await request.json(); key=re.sub(r'\W+','_',body.get("key","").lower().strip())
276
+ if not key: return JSONResponse({"error":"key required"},status_code=400)
277
+ agent_registry[key]={"key":key,"name":body.get("name",key.capitalize()),"role":body.get("role","General purpose agent."),"provider":body.get("provider","openrouter"),"models":body.get("models",["meta-llama/llama-3.3-70b-instruct:free"])}
278
+ return {"success":True,"agent":agent_registry[key]}
 
 
 
 
 
 
 
 
 
279
 
280
  @app.delete("/api/agents/{key}")
281
+ async def del_agent(key:str):
282
+ if key in {"manager","backend_dev","frontend_dev","analyst"}: return JSONResponse({"error":"Cannot delete core agents"},status_code=400)
283
+ agent_registry.pop(key,None); return {"success":True}
 
 
 
 
284
 
285
  @app.get("/api/history")
286
+ async def get_history(): return {"history":mission_history[-20:]}
 
 
287
 
288
  @app.get("/api/docs/{filename}")
289
+ async def dl_doc(filename:str):
290
+ path=DOCS_DIR/filename
291
+ if not path.exists(): return JSONResponse({"error":"not found"},status_code=404)
292
+ ext=Path(filename).suffix.lower()
293
+ mt={".docx":"application/vnd.openxmlformats-officedocument.wordprocessingml.document",".xlsx":"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",".html":"text/html",".py":"text/plain",".groovy":"text/plain",".jpg":"image/jpeg",".png":"image/png"}.get(ext,"application/octet-stream")
294
+ return FileResponse(path,media_type=mt,filename=filename)
 
 
 
 
295
 
296
  @app.post("/api/mission")
297
+ async def run_mission(request:Request):
298
+ body=await request.json(); task=body.get("task","").strip()
299
+ if not task: return JSONResponse({"error":"No task"},status_code=400)
300
+ started=datetime.now().isoformat(); results={}; events=[]; doc_file=None
301
+ tc=classify(task)
302
+ def log(m): events.append({"time":datetime.now().strftime("%H:%M:%S"),"msg":m})
303
+
304
+ # Manager
305
+ log("Manager planning...")
 
 
 
 
 
 
 
 
306
  try:
307
+ mgr_raw=await call_llm(agent_registry["manager"],task)
308
+ mgr_del=parse_delegates(mgr_raw)
309
+ results["manager"]={"status":"active","message":clean(mgr_raw),"model":agent_registry["manager"]["models"][0],"delegates":mgr_del}
310
+ log(f"Manager delegated: {mgr_del}")
 
 
 
 
 
311
  except Exception as e:
312
+ results["manager"]={"status":"resting","message":str(e),"model":""}; mgr_del=[]
313
+ log(f"Manager error: {e}")
314
+
315
+ # Merge manager + classifier
316
+ delegates=list(mgr_del)
317
+ safety_map=[("img","image_agent"),("excel","backend_dev"),("word","writer"),("anal","analyst"),("back","backend_dev"),("front","frontend_dev")]
318
+ for flag,key in safety_map:
319
+ if tc[flag] and key not in delegates: delegates.append(key)
320
+ if tc["both"]:
321
+ for k in ["backend_dev","frontend_dev"]:
322
+ if k not in delegates: delegates.append(k)
323
+ delegates=[k for k in delegates if k in agent_registry]
324
+ log(f"Final plan: {delegates}")
325
+
326
+ image_bytes=[]; writer_text=""; analyst_text=""; backend_text=""; frontend_text=""
327
+
328
+ async def run_one(key):
329
+ nonlocal writer_text,analyst_text,backend_text,frontend_text
330
+ agent=agent_registry[key]
331
+ log(f"{agent['name']} started...")
332
+ try:
333
+ if key=="image_agent":
334
+ raw=await call_llm(agent,f"Find images for this request: {task}")
335
+ m=re.search(r'"image_queries"\s*:\s*\[([^\]]*)\]',raw)
336
+ queries=re.findall(r'"([^"]+)"',m.group(1)) if m else [task[:50]]
337
+ log(f"ImageAgent queries: {queries[:3]}")
338
+ imgs=await asyncio.gather(*[get_image(q) for q in queries[:3]])
339
  for img in imgs:
340
+ if img: image_bytes.append(img)
341
+ results["image_agent"]={"status":"active","message":f"{len(image_bytes)} image(s) found: {', '.join(queries[:3])}","model":agent["models"][0]}
342
+ if image_bytes:
343
+ safe=re.sub(r'[^\w]','_',task[:30])
344
+ for i,ib in enumerate(image_bytes):
345
+ fp=DOCS_DIR/f"{safe}_img{i+1}.jpg"; fp.write_bytes(ib)
346
+ results["image_agent"]["doc_file"]=f"{safe}_img1.jpg"
347
+ log(f"ImageAgent: {len(image_bytes)} saved")
348
+
349
+ elif key=="writer":
350
+ prompt=f"Escribe informe formal completo sobre: {task}\nContexto: {results.get('manager',{}).get('message','')[:300]}\nContenido real 500+ palabras. Secciones: ## Resumen Ejecutivo, ### Introducción, ### Desarrollo, ### Hallazgos, ### Conclusiones, ### Recomendaciones"
351
+ writer_text=await call_llm(agent,prompt)
352
+ results["writer"]={"status":"active","message":writer_text[:200]+"...","model":agent["models"][0]}
353
+ log("Writer done")
354
+
355
+ elif key=="analyst":
356
+ content=writer_text or results.get("manager",{}).get("message",task)
357
+ raw=await call_llm(agent,f"Tarea: {task}\nContenido:\n{content[:1500]}\nEvalúa calidad, puntos fuertes, áreas de mejora, conclusión.")
358
+ if is_skip(raw): results["analyst"]={"status":"idle","message":"","model":""}
359
+ else: results["analyst"]={"status":"active","message":raw[:200]+"...","model":agent["models"][0]}; analyst_text=raw
360
+ log("Analyst done")
361
+
362
+ elif key=="backend_dev":
363
+ has_fe="frontend_dev" in delegates
364
+ collab=" Tu compañero Frontend hará el HTML/interfaz. TÚ solo haces servidor/lógica/backend." if has_fe else ""
365
+ if tc["excel"]:
366
+ prompt=(f"Crea plantilla Excel para: {task}\n"
367
+ "Responde SOLO con:\nEXCEL_TEMPLATE:{\"title\":\"...\",\"sheet_name\":\"...\",\"headers\":[\"Col1\",\"Col2\",...],\"sample_rows\":[[\"val1\",\"val2\",...],[\"val1\",\"val2\",...],[\"val1\",\"val2\",...]]}\n"
368
+ "Incluye 3+ filas de ejemplo con datos REALES y relevantes.")
369
+ else:
370
+ prompt=f"Tarea: {task}\n{collab}\nEntrega SOLO el código solicitado. Sin explicaciones innecesarias."
371
+ raw=await call_llm(agent,prompt)
372
+ if is_skip(raw): results["backend_dev"]={"status":"idle","message":"","model":""}
373
+ else: results["backend_dev"]={"status":"active","message":raw[:300]+("..." if len(raw)>300 else ""),"model":agent["models"][0]}; backend_text=raw
374
+ log("Backend done")
375
+
376
+ elif key=="frontend_dev":
377
+ be_ctx=backend_text[:500] if backend_text else ""
378
+ prompt=(f"Tarea: {task}\n"+(f"Backend hizo: {be_ctx}\n" if be_ctx else "")
379
+ +"Entrega SOLO el código HTML/CSS/JS. Si no se necesita frontend → {\"skip\":\"no frontend needed\"}")
380
+ raw=await call_llm(agent,prompt)
381
+ if is_skip(raw): results["frontend_dev"]={"status":"idle","message":"","model":""}
382
+ else: results["frontend_dev"]={"status":"active","message":raw[:300]+("..." if len(raw)>300 else ""),"model":agent["models"][0]}; frontend_text=raw
383
+ log("Frontend done")
384
+ else:
385
+ raw=await call_llm(agent,task)
386
+ results[key]={"status":"active","message":raw,"model":agent["models"][0]}
387
+ log(f"{agent['name']} done")
388
+ except Exception as e:
389
+ results[key]={"status":"resting","message":str(e),"model":""}; log(f"{key} error: {e}")
390
+
391
+ # Execution order: backend first, then image+writer in parallel, then frontend+analyst
392
+ be=[k for k in delegates if k=="backend_dev"]
393
+ par=[k for k in delegates if k in ("image_agent","writer")]
394
+ seq=[k for k in delegates if k in ("frontend_dev","analyst")]
395
+ oth=[k for k in delegates if k not in be+par+seq]
396
+ if be: await asyncio.gather(*[run_one(k) for k in be])
397
+ if par+oth: await asyncio.gather(*[run_one(k) for k in par+oth])
398
+ for k in seq: await run_one(k)
399
+
400
+ # Build files
401
+ safe=re.sub(r'[^\w\-]','_',task[:40]); ts=datetime.now().strftime('%Y%m%d_%H%M%S')
402
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  if writer_text:
 
404
  try:
405
+ db=build_docx(task,writer_text,image_bytes,analyst_text)
406
+ fn=f"{safe}_{ts}.docx"; (DOCS_DIR/fn).write_bytes(db)
407
+ doc_file=fn; results["manager"]["doc_file"]=fn; log(f"Docx: {fn}")
408
+ except Exception as e: log(f"Docx error: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
 
410
+ if backend_text and tc["excel"]:
411
+ try:
412
+ xb=build_excel(task,backend_text)
413
+ if xb:
414
+ fn=f"{safe}_{ts}.xlsx"; (DOCS_DIR/fn).write_bytes(xb)
415
+ doc_file=fn; results["backend_dev"]["doc_file"]=fn; log(f"Excel: {fn}")
416
+ except Exception as e: log(f"Excel error: {e}")
417
 
418
+ if backend_text and not tc["excel"] and not is_skip(backend_text):
419
+ try:
420
+ code=re.sub(r'```\w*\n?','',backend_text); code=re.sub(r'```','',code).strip()
421
+ if len(code)>30:
422
+ ext=".groovy" if ("groovy" in backend_text.lower() or "jenkins" in task.lower()) else ".py"
423
+ fn=f"{safe}_{ts}{ext}"; (DOCS_DIR/fn).write_text(code,encoding="utf-8")
424
+ results["backend_dev"]["doc_file"]=fn; log(f"Code: {fn}")
425
+ except Exception as e: log(f"Code file error: {e}")
426
+
427
+ if frontend_text and not is_skip(frontend_text):
428
+ try:
429
+ html=re.sub(r'```\w*\n?','',frontend_text); html=re.sub(r'```','',html).strip()
430
+ if len(html)>30:
431
+ fn=f"{safe}_frontend_{ts}.html"; (DOCS_DIR/fn).write_text(html,encoding="utf-8")
432
+ results["frontend_dev"]["doc_file"]=fn
433
+ if not doc_file: doc_file=fn
434
+ log(f"HTML: {fn}")
435
+ except Exception as e: log(f"HTML error: {e}")
436
+
437
+ for k in agent_registry:
438
+ if k not in results: results[k]={"status":"idle","message":"","model":""}
439
+
440
+ final=results.get("manager",{}).get("message","")[:300]
441
+ entry={"id":len(mission_history)+1,"task":task,"started_at":started,"ended_at":datetime.now().isoformat(),"results":results,"final":final,"doc_file":doc_file,"events":events}
442
+ mission_history.append(entry)
443
+ return JSONResponse({"success":True,"task":task,"results":results,"final":final,"doc_file":doc_file,"events":events,"mission_id":entry["id"]})
444
 
445
  @app.get("/api/health")
446
  async def health():
447
+ return {"status":"ok","providers":{"gemini":"ok" if GOOGLE_API_KEY else "missing","openrouter":"ok" if OPENROUTER_API_KEY else "missing","groq":"ok" if GROQ_API_KEY else "missing","pexels":"ok" if PEXELS_API_KEY else "optional","hf_images":"ok" if HF_API_KEY else "optional"}}
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -4,3 +4,4 @@ httpx==0.27.2
4
  python-multipart==0.0.9
5
  python-docx==1.1.2
6
  Pillow==10.4.0
 
 
4
  python-multipart==0.0.9
5
  python-docx==1.1.2
6
  Pillow==10.4.0
7
+ openpyxl==3.1.5
templates/index.html CHANGED
@@ -97,9 +97,9 @@ body::after{content:'';position:fixed;inset:0;background:repeating-linear-gradie
97
  .dtop{height:7px;background:var(--woodl);border-top:2px solid #c09828}
98
  .kbd{height:6px;background:#c0b090;border:1px solid #a09070;display:flex;gap:1px;padding:1px 2px;align-items:center}
99
  .kk{flex:1;height:3px;background:#a09070}
100
- .coffee{position:absolute;bottom:28px;right:8px;z-index:3}
101
- .person-slot{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);z-index:3}
102
- .chair-slot{position:absolute;bottom:0;right:3px;z-index:2}
103
  .ooo{position:absolute;inset:0;background:rgba(20,14,0,.75);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;z-index:10}
104
  .ooot{font-family:'VT323',monospace;font-size:17px;color:var(--A);letter-spacing:2px}
105
  .ooos{font-size:10px;color:rgba(255,204,68,.5)}
@@ -310,10 +310,10 @@ function runWalkAnimation(task,delegates,onDone){
310
  const rPos=rooms.map((r,i)=>({x:30+i*(rW+14),y:rY,w:rW,h:rH,key:r.key,name:r.name,color:agentColors[r.key]||'#4a9eff'}));
311
  const delSet=new Set(delegates);
312
  const manColor='#00e5a0';
313
- let mx=12,my=H/2+8,frame=0,stepI=0,phase='walking',talkF=0;
314
  const TALK=85,SPD=2.8;
315
  const msgs={writer:`"Write full report:\n${task.substring(0,26)}..."`,analyst:`"Review the\ndocument."`,developer:`"Build solution:\n${task.substring(0,24)}..."`,image_agent:`"Find images for:\n${task.substring(0,24)}..."`};
316
- const steps=[{key:'__start',x:W*.07,y:H/2,msg:'Planning...'},...rPos.filter(r=>delSet.has(r.key)).map(r=>({key:r.key,x:r.x+r.w/2-8,y:r.y+r.h-26,msg:msgs[r.key]||`"Delegating task..."`})),{key:'__return',x:W*.07,y:H/2,msg:'Briefing complete!'}];
317
  const visited=new Set();
318
  let curMsg='';
319
 
 
97
  .dtop{height:7px;background:var(--woodl);border-top:2px solid #c09828}
98
  .kbd{height:6px;background:#c0b090;border:1px solid #a09070;display:flex;gap:1px;padding:1px 2px;align-items:center}
99
  .kk{flex:1;height:3px;background:#a09070}
100
+ .coffee{position:absolute;bottom:30px;right:12px;z-index:3}
101
+ .person-slot{position:absolute;bottom:14px;left:52px;z-index:3}
102
+ .chair-slot{position:absolute;bottom:0;left:44px;z-index:2}
103
  .ooo{position:absolute;inset:0;background:rgba(20,14,0,.75);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;z-index:10}
104
  .ooot{font-family:'VT323',monospace;font-size:17px;color:var(--A);letter-spacing:2px}
105
  .ooos{font-size:10px;color:rgba(255,204,68,.5)}
 
310
  const rPos=rooms.map((r,i)=>({x:30+i*(rW+14),y:rY,w:rW,h:rH,key:r.key,name:r.name,color:agentColors[r.key]||'#4a9eff'}));
311
  const delSet=new Set(delegates);
312
  const manColor='#00e5a0';
313
+ let mx=W/2-8,my=4,frame=0,stepI=0,phase='walking',talkF=0;
314
  const TALK=85,SPD=2.8;
315
  const msgs={writer:`"Write full report:\n${task.substring(0,26)}..."`,analyst:`"Review the\ndocument."`,developer:`"Build solution:\n${task.substring(0,24)}..."`,image_agent:`"Find images for:\n${task.substring(0,24)}..."`};
316
+ const steps=[{key:'__start',x:W/2-8,y:H*.15,msg:'Planning...'},...rPos.filter(r=>delSet.has(r.key)).map(r=>({key:r.key,x:r.x+r.w/2-8,y:r.y+r.h-28,msg:msgs[r.key]||`"Delegating task..."`})),{key:'__return',x:W/2-8,y:H*.15,msg:'Briefing complete!'}];
317
  const visited=new Set();
318
  let curMsg='';
319