hbox-docs/backend/standup.py

199 lines
10 KiB
Python

"""Standup diario (27-oct-2026): el bot de scrum del sitio.
Cada mañana el agente diario (Claude, tarea programada) lee el tablero y prepara para cada persona con pendientes
abiertos su «parte del día»: instrucciones concretas (qué hacer hoy, en qué orden, con las ligas a los documentos) y
las tres preguntas de siempre sobre SUS pendientes: ¿qué terminaste?, ¿qué te falta?, ¿qué te bloquea?
Cada quien contesta en /tableros/standup/ (el sitio le avisa con una franja arriba hasta que conteste). Al día
siguiente el agente lee las respuestas, arma un lote para el tablero (Leo lo aplica) y un resumen para Leo y Adriana.
Si el agente no corrió, la página igual pregunta por los pendientes abiertos de la persona (sin instrucciones).
Lo engancha app.py al final con registrar(app, globals()).
Datos: DATOS_BASE/standup/AAAA-MM-DD/<usuario>.json {fecha, usuario, nombre, preparado_por, preparado_en,
instrucciones:[{texto, tarea_id?, liga?}], preguntas:[{tarea_id, titulo, pregunta}], nota_bot,
respuesta:{enviada_en, hecho, falta, bloqueos, comentario, por_tarea:[{tarea_id, estado, nota}]}, historial}
Rutas (todas con sesión):
GET /api/standup/hoy[?fecha=] el mío de hoy (o el que el bot dejó en los últimos 3 días sin contestar)
POST /api/standup/respuesta {fecha, hecho, falta, bloqueos, comentario, por_tarea:[{tarea_id, estado, nota}]}
GET /api/standup?fecha= equipo: quién contestó, bloqueos (STANDUP_LECTORES + administradores)
POST /api/standup/preparar {fecha, personas:[{usuario, instrucciones, preguntas, nota_bot}]} (claude + admins)
GET /api/standup/estado {pendiente: bool, fecha} — para la franja de aviso (barato)
Estados por pendiente en la respuesta: hecho · sigo · bloqueado · no-empece.
"""
import datetime
import os
from flask import jsonify, request
ESTADOS_RESP = ("hecho", "sigo", "bloqueado", "no-empece")
DIAS_ATRAS = 3
def registrar(app, g):
carpeta = os.environ.get("STANDUP_DIR", os.path.join(g["DATOS_BASE"], "standup"))
lectores = {u.strip().lower() for u in os.environ.get("STANDUP_LECTORES", "leo,adriana,claude,gibranlp").split(",")
if u.strip()}
preparan = {u.strip().lower() for u in os.environ.get("STANDUP_PREPARAN", "claude").split(",") if u.strip()}
texto, ahora, hist = g["_texto"], g["ahora"], g["_hist"]
def yo():
return g["current_user"]()
def hoy():
return datetime.date.today().isoformat()
def fecha_valida(s):
s = texto(s, 10)
return s if g["FECHA_RE"].match(s or "") else None
def ruta(fecha, usuario):
return os.path.join(carpeta, fecha, g["_norm_id"](usuario) + ".json")
def mis_pendientes(u):
miembros = g["_miembros"]()
out = []
for t in g["_listar_json"](g["TAREAS_DIR"]):
if t.get("estado") == "hecho":
continue
ids = g["_ids_responsable"](t.get("responsable"), miembros)[0]
if g["_norm_id"](u) in ids:
out.append({"id": t.get("id"), "titulo": t.get("titulo"), "estado": t.get("estado"),
"vence": t.get("vence"), "pista": t.get("pista"), "bloqueado_por": t.get("bloqueado_por")})
orden = {"bloqueado": 0, "en-curso": 1, "por-hacer": 2}
out.sort(key=lambda t: (orden.get(t["estado"], 3), t.get("vence") or "9999"))
return out
def armar_generico(u, fecha):
pend = mis_pendientes(u)
m = g["_miembros"]().get(g["_norm_id"](u)) or {}
return {"fecha": fecha, "usuario": g["_norm_id"](u), "nombre": m.get("nombre") or u, "preparado_por": "",
"preparado_en": None, "instrucciones": [], "nota_bot": "",
"preguntas": [{"tarea_id": t["id"], "titulo": t["titulo"], "pregunta": ""} for t in pend[:12]],
"respuesta": None, "_generico": True}
def buscar_mio(u, fecha=None):
"""El de la fecha pedida; si no, el de hoy; si no hay de hoy, el último sin contestar de los últimos días."""
if fecha:
return g["_leer_json"](ruta(fecha, u)), fecha
d = g["_leer_json"](ruta(hoy(), u))
if d:
return d, hoy()
for i in range(1, DIAS_ATRAS + 1):
f = (datetime.date.today() - datetime.timedelta(days=i)).isoformat()
d = g["_leer_json"](ruta(f, u))
if d and not d.get("respuesta"):
return d, f
return None, hoy()
def con_pendientes(d, u):
"""Agrega el estado de hoy de cada pendiente preguntado (pudo moverse desde que el bot preguntó)."""
actuales = {t["id"]: t for t in mis_pendientes(u)}
for p in d.get("preguntas") or []:
t = actuales.get(p.get("tarea_id"))
p["estado_tablero"] = t["estado"] if t else "hecho o ya no es tuyo"
p["vence"] = t.get("vence") if t else None
d["pendientes_abiertos"] = list(actuales.values())
return d
@app.get("/api/standup/hoy")
def standup_hoy():
u = yo()
if not u:
return g["_sin_usuario"]()
d, fecha = buscar_mio(u, fecha_valida(request.args.get("fecha")))
d = d or armar_generico(u, fecha)
return jsonify(ok=True, standup=con_pendientes(d, u), estados=list(ESTADOS_RESP), hoy=hoy())
@app.get("/api/standup/estado")
def standup_estado():
u = yo()
if not u:
return g["_sin_usuario"]()
d, fecha = buscar_mio(u)
return jsonify(ok=True, preparado=bool(d), pendiente=bool(d and not d.get("respuesta")), fecha=fecha)
@app.post("/api/standup/respuesta")
def standup_respuesta():
u = yo()
if not u:
return g["_sin_usuario"]()
d = g["_json_body"]()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
fecha = fecha_valida(d.get("fecha")) or hoy()
reg = g["_leer_json"](ruta(fecha, u)) or armar_generico(u, fecha)
reg.pop("_generico", None)
por_tarea = []
for x in d.get("por_tarea") or []:
if isinstance(x, dict) and x.get("tarea_id") and x.get("estado") in ESTADOS_RESP:
por_tarea.append({"tarea_id": texto(x["tarea_id"], 60), "estado": x["estado"], "nota": texto(x.get("nota"), 600)})
resp = {"enviada_en": ahora(), "hecho": texto(d.get("hecho"), 3000), "falta": texto(d.get("falta"), 3000),
"bloqueos": texto(d.get("bloqueos"), 3000), "comentario": texto(d.get("comentario"), 3000),
"por_tarea": por_tarea}
if not (resp["hecho"] or resp["falta"] or resp["bloqueos"] or por_tarea):
return jsonify(ok=False, error="Contesta al menos una pregunta o marca cómo va un pendiente."), 400
cambio = "contestó" if not reg.get("respuesta") else "corrigió su respuesta"
reg["respuesta"] = resp
hist(reg, u, cambio)
g["_escribir_json"](ruta(fecha, u), reg)
return jsonify(ok=True, standup=reg)
@app.post("/api/standup/preparar")
def standup_preparar():
u = yo()
if not u or not (u.lower() in preparan or g["is_admin"]()):
return jsonify(ok=False, error="Solo el bot (claude) o un administrador prepara el standup."), 403
d = g["_json_body"]()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
fecha = fecha_valida(d.get("fecha")) or hoy()
miembros = g["_miembros"]()
hechos, omitidos = [], []
for p in d.get("personas") or []:
uid = g["_norm_id"](p.get("usuario"))
if uid not in miembros:
omitidos.append({"usuario": uid, "por": "no es miembro del equipo"})
continue
previo = g["_leer_json"](ruta(fecha, uid))
if previo and previo.get("respuesta"):
omitidos.append({"usuario": uid, "por": "ya contestó: no se reescribe"})
continue
instr = []
for x in p.get("instrucciones") or []:
if isinstance(x, str):
x = {"texto": x}
if isinstance(x, dict) and texto(x.get("texto")):
liga = texto(x.get("liga"), 300)
if liga.lower().startswith(("javascript:", "data:")):
liga = ""
instr.append({"texto": texto(x.get("texto"), 600), "tarea_id": texto(x.get("tarea_id"), 60), "liga": liga})
preg = []
for x in p.get("preguntas") or []:
if isinstance(x, dict) and (x.get("tarea_id") or x.get("pregunta")):
preg.append({"tarea_id": texto(x.get("tarea_id"), 60), "titulo": texto(x.get("titulo"), 200),
"pregunta": texto(x.get("pregunta"), 400)})
reg = {"fecha": fecha, "usuario": uid, "nombre": miembros[uid].get("nombre") or uid, "preparado_por": u,
"preparado_en": ahora(), "instrucciones": instr[:12], "preguntas": preg[:15],
"nota_bot": texto(p.get("nota_bot"), 1200), "respuesta": None, "historial": (previo or {}).get("historial", [])}
hist(reg, u, "preparó el standup" if not previo else "rehízo el standup")
g["_escribir_json"](ruta(fecha, uid), reg)
hechos.append(uid)
return jsonify(ok=True, fecha=fecha, preparados=hechos, omitidos=omitidos)
@app.get("/api/standup")
def standup_equipo():
u = yo()
if not u:
return g["_sin_usuario"]()
if not (u.lower() in lectores or g["is_admin"]()):
return jsonify(ok=False, error="La vista del equipo es para Leo, Adriana y el bot."), 403
fecha = fecha_valida(request.args.get("fecha")) or hoy()
base = os.path.join(carpeta, fecha)
regs = g["_listar_json"](base) if os.path.isdir(base) else []
fechas = sorted([f for f in os.listdir(carpeta) if g["FECHA_RE"].match(f)], reverse=True)[:30] if os.path.isdir(carpeta) else []
return jsonify(ok=True, fecha=fecha, fechas=fechas, standups=regs,
contestaron=sum(1 for r in regs if r.get("respuesta")), total=len(regs))
g["_standup"] = {"mis_pendientes": mis_pendientes, "carpeta": carpeta}