Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SfRSvDd335NPYEbBrDFGE
199 lines
10 KiB
Python
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}
|