From b06f15534f75e72940b658b47004a270ada4963f Mon Sep 17 00:00:00 2001 From: Victor Fraile Garcia Date: Mon, 20 Jul 2026 18:08:32 +0200 Subject: [PATCH] feat(ucepsa): add daily governance agenda shadow --- ..._daily_governance_agenda_shadow_v0.3.12.md | 142 ++++ ...sa-shopfloor-context-health.dashboard.json | 313 +++++++- ...ontext_daily_governance_dashboard_v0312.sh | 259 +++++++ .../manage_context_daily_governance_v0312.sh | 47 ++ ...alidate_context_daily_governance_v0312.sql | 181 +++++ ...t_daily_governance_agenda_shadow_v0312.sql | 633 ++++++++++++++++ .../tools/context_daily_governance.py | 673 ++++++++++++++++++ 7 files changed, 2246 insertions(+), 2 deletions(-) create mode 100644 ucepsa/edge-oee-demo/docs/runbooks/111_context_daily_governance_agenda_shadow_v0.3.12.md create mode 100755 ucepsa/edge-oee-demo/ops/deploy_context_daily_governance_dashboard_v0312.sh create mode 100755 ucepsa/edge-oee-demo/ops/manage_context_daily_governance_v0312.sh create mode 100644 ucepsa/edge-oee-demo/ops/validate_context_daily_governance_v0312.sql create mode 100644 ucepsa/edge-oee-demo/sql/versions/114_ucepsa_context_daily_governance_agenda_shadow_v0312.sql create mode 100755 ucepsa/edge-oee-demo/tools/context_daily_governance.py diff --git a/ucepsa/edge-oee-demo/docs/runbooks/111_context_daily_governance_agenda_shadow_v0.3.12.md b/ucepsa/edge-oee-demo/docs/runbooks/111_context_daily_governance_agenda_shadow_v0.3.12.md new file mode 100644 index 0000000..2292051 --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/runbooks/111_context_daily_governance_agenda_shadow_v0.3.12.md @@ -0,0 +1,142 @@ +# UCEPSA — Agenda diaria de gobernanza SHADOW v0.3.12 + +## Objetivo + +Convertir la arquitectura de gobernanza en una revisión diaria de 15 minutos: + +```text +datos automáticos +→ decisiones humanas consolidadas +→ agenda priorizada +→ snapshot auditable +→ revisión y seguimiento +``` + +Una fila de agenda equivale a una pregunta humana. + +## Priorización + +La severidad técnica se conserva. + +La agenda añade un estado de atención independiente: + +```text +0-8 h RECENT +8-24 h ATTENTION +>24 h OVERDUE +``` + +Estos tramos organizan la reunión. No convierten una advertencia en crítica. + +## Objetos + +```text +context_governance_daily_policies +context_governance_agenda_snapshots +context_governance_agenda_snapshot_items + +li_context_governance_decision_evidence_pack_v1 +li_context_governance_daily_agenda_v1 +li_context_governance_daily_summary_v1 +li_context_governance_agenda_snapshot_history_v1 +``` + +## Despliegue + +```bash +FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/114_ucepsa_context_daily_governance_agenda_shadow_v0312.sql + +docker exec -i mv_ucepsa_postgres_hot sh -lc \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + < "$FILE" +``` + +## Runtime + +```bash +RUNTIME=/srv/mesavault/edge-oee-ucepsa +TARGET=/srv/mesavault/40-clients/ucepsa/edge-oee-demo + +install -m 0644 \ + "$TARGET/tools/context_daily_governance.py" \ + "$RUNTIME/tools/" +``` + +## Consola + +```bash +MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_context_daily_governance_v0312.sh + +"$MANAGER" agenda --limit 50 + +"$MANAGER" show \ + --decision-group-key 'DECISION:...' +``` + +## Guardar la agenda de una reunión + +```bash +"$MANAGER" snapshot \ + --generated-by "Víctor Fraile" \ + --notes "Revisión diaria con Dirección y Producción." +``` + +La salida devuelve `snapshot_id`. + +## Exportar Markdown + +Vista actual: + +```bash +OUT=/srv/mesavault/exports/governance-review-$(date +%F).md +mkdir -p "$(dirname "$OUT")" + +"$MANAGER" export-markdown \ + --generated-by "MESAVAULT" \ + > "$OUT" +``` + +Snapshot inmutable: + +```bash +"$MANAGER" export-markdown \ + --snapshot-id NUMERO \ + > "$OUT" +``` + +## Validación + +```bash +VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_context_daily_governance_v0312.sql + +docker exec -i mv_ucepsa_postgres_hot sh -lc \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + < "$VALIDATION" +``` + +Valores obligatorios: + +```text +agenda_rows = decision_backlog_rows +duplicated_agenda_decisions = 0 +agenda_rank_mismatches = 0 +invalid_attention_status_rows = 0 +snapshot_item_count_mismatches = 0 + +official_snapshot_rows = 0 +official_snapshot_item_rows = 0 +official_summary_rows = 0 +official_operator_queue_rows = 0 +``` + +## Dashboard + +Se mantiene el mismo UID y se añaden: + +```text +Agenda diaria de gobernanza +Paquete de evidencia por decisión +Indicadores de gobernanza diaria +``` + +Paneles totales esperados: 28. diff --git a/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json b/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json index b372fc6..50420c0 100644 --- a/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json +++ b/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json @@ -15,7 +15,7 @@ } ] }, - "description": "Supervisión SHADOW de sesiones, incidencias, arbitraje, casos raíz y decisiones humanas consolidadas. La carga de revisión se mide por preguntas operativas, conservando la auditoría de casos y evidencias.", + "description": "Supervisión SHADOW de sesiones, arbitraje, casos raíz, decisiones humanas y agenda diaria de gobernanza. La revisión se organiza por preguntas operativas y puede guardarse como snapshot auditable.", "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 1, @@ -2981,6 +2981,311 @@ ], "title": "Historial de decisiones humanas · últimos 7 días", "type": "table" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Severidad" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "type": "value", + "options": { + "WARNING": { + "text": "WARNING", + "color": "orange", + "index": 0 + }, + "CRITICAL": { + "text": "CRITICAL", + "color": "red", + "index": 1 + } + } + } + ] + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Atención" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "type": "value", + "options": { + "RECENT": { + "text": "Reciente", + "color": "green", + "index": 0 + }, + "ATTENTION": { + "text": "Atención", + "color": "orange", + "index": 1 + }, + "OVERDUE": { + "text": "Vencida", + "color": "red", + "index": 2 + } + } + } + ] + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Decisión" + }, + "properties": [ + { + "id": "custom.width", + "value": 400 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Pregunta" + }, + "properties": [ + { + "id": "custom.width", + "value": 520 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Acción" + }, + "properties": [ + { + "id": "custom.width", + "value": 520 + } + ] + } + ] + }, + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 188 + }, + "id": 26, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n agenda_rank AS \"Prioridad\",\n max_severity AS \"Severidad\",\n attention_status AS \"Atención\",\n machine_id AS \"Máquina\",\n decision_title AS \"Decisión\",\n CASE review_owner\n WHEN 'PRODUCTION' THEN 'Producción'\n WHEN 'TECHNICAL' THEN 'Técnico'\n ELSE 'Conjunto'\n END AS \"Responsable\",\n pending_age_hours AS \"Antigüedad h\",\n review_due_at AS \"Límite\",\n pending_root_case_count AS \"Casos pendientes\",\n linked_episode_count AS \"Evidencias\",\n array_to_string(\n production_orders,\n ', '\n ) AS \"Órdenes\",\n array_to_string(\n odoo_workorder_ids,\n ', '\n ) AS \"Workorders\",\n question_text AS \"Pregunta\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_daily_agenda_v1\nORDER BY agenda_rank\nLIMIT 50", + "refId": "A" + } + ], + "title": "Agenda diaria de gobernanza", + "type": "table" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 200 + }, + "id": 27, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n decision_group_key AS \"Decisión\",\n machine_id AS \"Máquina\",\n linked_root_case_count AS \"Casos raíz\",\n linked_episode_count AS \"Episodios\",\n array_to_string(\n root_case_ids,\n ', '\n ) AS \"IDs casos\",\n array_to_string(\n episode_ids,\n ', '\n ) AS \"IDs episodios\",\n array_to_string(\n incident_codes,\n ', '\n ) AS \"Códigos\",\n array_to_string(\n operator_names,\n ', '\n ) AS \"Operarios\",\n array_to_string(\n known_resolution_classifications,\n ', '\n ) AS \"Resolución conocida\",\n evidence_first_seen_at AS \"Primera evidencia\",\n evidence_last_seen_at AS \"Última evidencia\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_decision_evidence_pack_v1\nWHERE review_status =\n 'PENDING'\nORDER BY\n CASE max_severity\n WHEN 'CRITICAL' THEN 1\n ELSE 2\n END,\n first_seen_at\nLIMIT 50", + "refId": "A" + } + ], + "title": "Paquete de evidencia por decisión", + "type": "table" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 210 + }, + "id": 28, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n agenda_local_date AS \"Fecha\",\n pending_decision_count AS \"Decisiones pendientes\",\n pending_critical_decision_count AS \"Críticas\",\n attention_decision_count AS \"Atención\",\n overdue_decision_count AS \"Vencidas\",\n production_pending_count AS \"Producción\",\n technical_pending_count AS \"Técnico\",\n joint_pending_count AS \"Conjunto\",\n pending_root_case_count AS \"Casos raíz\",\n linked_episode_count AS \"Evidencias\",\n decisions_first_seen_today AS \"Nuevas hoy\",\n decisions_reviewed_today AS \"Resueltas hoy\",\n average_review_resolution_hours AS \"Resolución media h\",\n evidence_per_pending_decision AS \"Evidencias por decisión\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_daily_summary_v1", + "refId": "A" + } + ], + "title": "Indicadores de gobernanza diaria", + "type": "table" } ], "refresh": "15s", @@ -3005,7 +3310,11 @@ "v0310", "decision-groups", "temporal-consolidation", - "v0311" + "v0311", + "daily-governance", + "agenda", + "evidence-pack", + "v0312" ], "templating": { "list": [] diff --git a/ucepsa/edge-oee-demo/ops/deploy_context_daily_governance_dashboard_v0312.sh b/ucepsa/edge-oee-demo/ops/deploy_context_daily_governance_dashboard_v0312.sh new file mode 100755 index 0000000..8bb53f9 --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/deploy_context_daily_governance_dashboard_v0312.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +set -euo pipefail + +ACTION="${1:-deploy}" +GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}" +REPO_ROOT="${DAILY_GOVERNANCE_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}" +DASHBOARD_FILE="${DAILY_GOVERNANCE_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}" +FOLDER_UID="${DAILY_GOVERNANCE_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}" +DASHBOARD_UID="ucepsa-shopfloor-context-health" +DATASOURCE_UID="bfnbcasbm6hhca" + +resolve_url() { + if [[ -n "${GRAFANA_URL:-}" ]]; then + printf '%s' "$GRAFANA_URL" + return + fi + + local port_line host_port container_ip + port_line="$( + docker port "$GRAFANA_CONTAINER" 3000/tcp 2>/dev/null \ + | head -n 1 || true + )" + + if [[ -n "$port_line" ]]; then + host_port="${port_line##*:}" + printf 'http://127.0.0.1:%s' "$host_port" + return + fi + + container_ip="$( + docker inspect "$GRAFANA_CONTAINER" \ + --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' + )" + + if [[ -z "$container_ip" ]]; then + echo "ERROR: no se pudo resolver Grafana." >&2 + exit 1 + fi + + printf 'http://%s:3000' "$container_ip" +} + +if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then + echo "ERROR: falta GRAFANA_API_TOKEN." >&2 + exit 1 +fi + +export GRAFANA_URL_RESOLVED +GRAFANA_URL_RESOLVED="$(resolve_url)" + +python3 - \ + "$ACTION" \ + "$DASHBOARD_FILE" \ + "$FOLDER_UID" \ + "$DASHBOARD_UID" \ + "$DATASOURCE_UID" <<'PY' +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + +action, dashboard_file, folder_uid, dashboard_uid, datasource_uid = sys.argv[1:] +base_url = os.environ["GRAFANA_URL_RESOLVED"].rstrip("/") +token = os.environ["GRAFANA_API_TOKEN"] + + +def request(method, path, payload=None): + body = None + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {token}", + } + + if payload is not None: + body = json.dumps( + payload, + ensure_ascii=False, + ).encode("utf-8") + headers["Content-Type"] = "application/json" + + req = urllib.request.Request( + base_url + path, + data=body, + headers=headers, + method=method, + ) + + try: + with urllib.request.urlopen( + req, + timeout=20, + ) as response: + raw = response.read() + return ( + json.loads(raw.decode("utf-8")) + if raw + else None + ) + except urllib.error.HTTPError as exc: + detail = exc.read().decode( + "utf-8", + errors="replace", + ) + raise RuntimeError( + f"Grafana API {method} {path}: " + f"HTTP {exc.code}: {detail}" + ) from exc + + +health = request("GET", "/api/health") +if not health or health.get("database") != "ok": + raise RuntimeError( + f"Grafana no está saludable: {health!r}" + ) + +if action == "deploy": + with open( + dashboard_file, + encoding="utf-8", + ) as handle: + dashboard = json.load(handle) + + result = request( + "POST", + "/api/dashboards/db", + { + "dashboard": dashboard, + "folderUid": folder_uid, + "overwrite": True, + "message": ( + "MESAVAULT daily governance agenda " + "SHADOW v0.3.12" + ), + }, + ) + print(json.dumps( + { + "status": "deployed", + "dashboard_uid": dashboard_uid, + "folder_uid": folder_uid, + "response": result, + }, + ensure_ascii=False, + indent=2, + )) + +elif action == "validate": + result = request( + "GET", + "/api/dashboards/uid/" + + urllib.parse.quote(dashboard_uid), + ) + dashboard = result["dashboard"] + panels = { + panel.get("id"): panel + for panel in dashboard.get("panels", []) + } + + datasource_uids = sorted({ + panel.get("datasource", {}).get("uid") + for panel in dashboard.get("panels", []) + if isinstance( + panel.get("datasource"), + dict, + ) + and panel.get( + "datasource", + {}, + ).get("uid") + }) + + errors = [] + + if len(dashboard.get("panels", [])) != 28: + errors.append( + "El dashboard no tiene 28 paneles" + ) + + if dashboard.get("refresh") != "15s": + errors.append( + "El refresco no está en 15s" + ) + + if datasource_uids != [datasource_uid]: + errors.append( + f"Datasource inesperado: {datasource_uids!r}" + ) + + expected = { + 26: "li_context_governance_daily_agenda_v1", + 27: "li_context_governance_decision_evidence_pack_v1", + 28: "li_context_governance_daily_summary_v1", + } + + for panel_id, fragment in expected.items(): + sql = ( + panels.get(panel_id, {}) + .get("targets", [{}])[0] + .get("rawSql", "") + ) + if fragment not in sql: + errors.append( + f"Panel {panel_id} no consulta {fragment}" + ) + + output = { + "status": + "ok" if not errors else "error", + "dashboard_uid": + dashboard.get("uid"), + "folder_uid": + result.get( + "meta", + {}, + ).get("folderUid"), + "panel_count": + len( + dashboard.get( + "panels", + [], + ) + ), + "refresh": + dashboard.get("refresh"), + "datasource_uids": + datasource_uids, + "version": + dashboard.get("version"), + "url": + result.get( + "meta", + {}, + ).get("url"), + "daily_governance_panels": { + "agenda": 26 in panels, + "evidence_pack": 27 in panels, + "summary": 28 in panels, + }, + "errors": + errors, + } + + print(json.dumps( + output, + ensure_ascii=False, + indent=2, + )) + + if errors: + raise SystemExit(1) + +else: + raise SystemExit( + "Uso: deploy_context_daily_governance_dashboard_v0312.sh " + "{deploy|validate}" + ) +PY diff --git a/ucepsa/edge-oee-demo/ops/manage_context_daily_governance_v0312.sh b/ucepsa/edge-oee-demo/ops/manage_context_daily_governance_v0312.sh new file mode 100755 index 0000000..17ad5d2 --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/manage_context_daily_governance_v0312.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +ACTION="${1:-agenda}" +ROOT="${DAILY_GOVERNANCE_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}" +NETWORK="${DAILY_GOVERNANCE_NETWORK:-mv_ucepsa_net}" +POSTGRES_CONTAINER="${DAILY_GOVERNANCE_POSTGRES_CONTAINER:-mv_ucepsa_postgres_hot}" +TENANT_OVERRIDE="${DAILY_GOVERNANCE_TENANT_OVERRIDE:-ucepsa}" +SITE_OVERRIDE="${DAILY_GOVERNANCE_SITE_OVERRIDE:-ucepsa_onpremise}" +PGHOST_OVERRIDE="${DAILY_GOVERNANCE_PGHOST_OVERRIDE:-mv_ucepsa_postgres_hot}" + +cd "$ROOT" + +IMAGE="$( + docker inspect \ + mv_ucepsa_odoo_order_state_pg_sink \ + --format '{{.Config.Image}}' +)" + +PGDATABASE_OVERRIDE="${DAILY_GOVERNANCE_PGDATABASE_OVERRIDE:-$( + docker inspect "$POSTGRES_CONTAINER" \ + --format '{{range .Config.Env}}{{println .}}{{end}}' \ + | sed -n 's/^POSTGRES_DB=//p' \ + | head -n 1 +)}" + +if [[ -z "$PGDATABASE_OVERRIDE" ]]; then + echo "ERROR: no se pudo determinar POSTGRES_DB." >&2 + exit 1 +fi + +shift || true + +docker run --rm -i \ + --network "$NETWORK" \ + --env-file .env \ + -e "TENANT_OVERRIDE=$TENANT_OVERRIDE" \ + -e "SITE_OVERRIDE=$SITE_OVERRIDE" \ + -e "PGHOST=$PGHOST_OVERRIDE" \ + -e "PGDATABASE=$PGDATABASE_OVERRIDE" \ + -v "$ROOT:/app:ro" \ + -w /app \ + --entrypoint python \ + "$IMAGE" \ + tools/context_daily_governance.py \ + "$ACTION" \ + "$@" diff --git a/ucepsa/edge-oee-demo/ops/validate_context_daily_governance_v0312.sql b/ucepsa/edge-oee-demo/ops/validate_context_daily_governance_v0312.sql new file mode 100644 index 0000000..0ed9bbd --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/validate_context_daily_governance_v0312.sql @@ -0,0 +1,181 @@ +\pset pager off + +\echo '=== 1. Objetos v0.3.12 ===' + +SELECT + to_regclass( + 'mv_loss_intelligence.context_governance_daily_policies' + ) AS policy_table, + to_regclass( + 'mv_loss_intelligence.context_governance_agenda_snapshots' + ) AS snapshots_table, + to_regclass( + 'mv_loss_intelligence.context_governance_agenda_snapshot_items' + ) AS snapshot_items_table, + to_regclass( + 'mv_reports_ucepsa_prod.li_context_governance_decision_evidence_pack_v1' + ) AS evidence_pack_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_context_governance_daily_agenda_v1' + ) AS agenda_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_context_governance_daily_summary_v1' + ) AS summary_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_context_governance_agenda_snapshot_history_v1' + ) AS snapshot_history_view; + +\echo '=== 2. Política diaria ===' + +SELECT + timezone_name, + attention_after_hours, + overdue_after_hours, + agenda_limit, + active +FROM + mv_loss_intelligence + .context_governance_daily_policies +WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND policy_code = + 'CONTEXT_DAILY_GOVERNANCE_V1'; + +\echo '=== 3. Una fila de agenda por decisión pendiente ===' + +SELECT + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_context_governance_daily_agenda_v1 + ) AS agenda_rows, + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_backlog_v1 + ) AS decision_backlog_rows; + +\echo '=== 4. Decisiones duplicadas en agenda ===' + +SELECT COUNT(*) AS duplicated_agenda_decisions +FROM ( + SELECT + decision_group_key, + COUNT(*) AS n + FROM + mv_reports_ucepsa_prod + .li_context_governance_daily_agenda_v1 + GROUP BY decision_group_key + HAVING COUNT(*) > 1 +) duplicated; + +\echo '=== 5. Ranking continuo ===' + +WITH ranked AS ( + SELECT + agenda_rank, + ROW_NUMBER() OVER ( + ORDER BY agenda_rank + ) AS expected_rank + FROM + mv_reports_ucepsa_prod + .li_context_governance_daily_agenda_v1 +) +SELECT COUNT(*) AS agenda_rank_mismatches +FROM ranked +WHERE agenda_rank <> + expected_rank; + +\echo '=== 6. Evidencia de las tres decisiones actuales ===' + +SELECT + decision_group_key, + machine_id, + linked_root_case_count, + linked_episode_count, + root_case_ids, + episode_ids, + production_orders, + odoo_workorder_ids, + operator_names, + review_owner, + review_status +FROM + mv_reports_ucepsa_prod + .li_context_governance_decision_evidence_pack_v1 +WHERE decision_group_key IN ( + 'DECISION:UNCONFIRMED_ACTIVITY:CORT-00:20260720:1', + 'DECISION:ODOO_ORDER_START_CONFLICT:CORT-00:20260720:5', + 'DECISION:UNCONFIRMED_ACTIVITY:CORT-02:20260720:7' +) +ORDER BY decision_group_key; + +\echo '=== 7. Resumen diario ===' + +SELECT * +FROM + mv_reports_ucepsa_prod + .li_context_governance_daily_summary_v1; + +\echo '=== 8. Tramos de atención válidos ===' + +SELECT COUNT(*) AS invalid_attention_status_rows +FROM + mv_reports_ucepsa_prod + .li_context_governance_daily_agenda_v1 +WHERE attention_status NOT IN ( + 'RECENT', + 'ATTENTION', + 'OVERDUE' +) + OR pending_age_hours < 0 + OR review_due_at < + first_seen_at; + +\echo '=== 9. Snapshots coherentes ===' + +SELECT COUNT(*) AS snapshot_item_count_mismatches +FROM + mv_loss_intelligence + .context_governance_agenda_snapshots s +WHERE s.item_count <> + ( + SELECT COUNT(*) + FROM + mv_loss_intelligence + .context_governance_agenda_snapshot_items i + WHERE i.snapshot_id = + s.snapshot_id + ); + +\echo '=== 10. Nada oficial ===' + +SELECT + ( + SELECT COUNT(*) + FROM + mv_loss_intelligence + .context_governance_agenda_snapshots + WHERE official_eligible + ) AS official_snapshot_rows, + ( + SELECT COUNT(*) + FROM + mv_loss_intelligence + .context_governance_agenda_snapshot_items + WHERE official_eligible + ) AS official_snapshot_item_rows, + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_context_governance_daily_summary_v1 + WHERE official_eligible + ) AS official_summary_rows, + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod.li_operator_stop_queue_v2 + ) AS official_operator_queue_rows; diff --git a/ucepsa/edge-oee-demo/sql/versions/114_ucepsa_context_daily_governance_agenda_shadow_v0312.sql b/ucepsa/edge-oee-demo/sql/versions/114_ucepsa_context_daily_governance_agenda_shadow_v0312.sql new file mode 100644 index 0000000..16d6b17 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/versions/114_ucepsa_context_daily_governance_agenda_shadow_v0312.sql @@ -0,0 +1,633 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS +mv_loss_intelligence.context_governance_daily_policies ( + tenant text NOT NULL, + site text NOT NULL, + policy_code text NOT NULL, + timezone_name text NOT NULL DEFAULT 'Europe/Madrid', + attention_after_hours numeric(10, 2) NOT NULL DEFAULT 8 + CHECK (attention_after_hours >= 0), + overdue_after_hours numeric(10, 2) NOT NULL DEFAULT 24 + CHECK ( + overdue_after_hours > + attention_after_hours + ), + agenda_limit integer NOT NULL DEFAULT 50 + CHECK (agenda_limit > 0), + active boolean NOT NULL DEFAULT true, + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ( + tenant, + site, + policy_code + ) +); + +COMMENT ON TABLE +mv_loss_intelligence.context_governance_daily_policies IS +'Política SHADOW para priorizar la agenda diaria de gobernanza. No modifica severidad, contexto, Odoo ni elegibilidad oficial.'; + +INSERT INTO +mv_loss_intelligence.context_governance_daily_policies ( + tenant, + site, + policy_code, + timezone_name, + attention_after_hours, + overdue_after_hours, + agenda_limit, + active, + notes +) +VALUES ( + 'ucepsa', + 'ucepsa_onpremise', + 'CONTEXT_DAILY_GOVERNANCE_V1', + 'Europe/Madrid', + 8, + 24, + 50, + true, + '0-8 h reciente; 8-24 h requiere atención; más de 24 h vencido. Estos tramos organizan la revisión y no sustituyen la severidad técnica.' +) +ON CONFLICT ( + tenant, + site, + policy_code +) +DO UPDATE SET + timezone_name = + EXCLUDED.timezone_name, + attention_after_hours = + EXCLUDED.attention_after_hours, + overdue_after_hours = + EXCLUDED.overdue_after_hours, + agenda_limit = + EXCLUDED.agenda_limit, + active = true, + notes = + EXCLUDED.notes, + updated_at = now(); + +CREATE TABLE IF NOT EXISTS +mv_loss_intelligence.context_governance_agenda_snapshots ( + snapshot_id bigserial PRIMARY KEY, + tenant text NOT NULL, + site text NOT NULL, + agenda_local_date date NOT NULL, + generated_at timestamptz NOT NULL DEFAULT now(), + generated_by text NOT NULL, + item_count integer NOT NULL + CHECK (item_count >= 0), + source_state_hash text NOT NULL, + source_view text NOT NULL DEFAULT + 'mv_reports_ucepsa_prod.li_context_governance_daily_agenda_v1', + notes text, + official_eligible boolean NOT NULL DEFAULT false + CHECK (official_eligible = false), + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS +idx_context_governance_snapshots_date +ON +mv_loss_intelligence.context_governance_agenda_snapshots ( + tenant, + site, + agenda_local_date DESC, + generated_at DESC +); + +CREATE TABLE IF NOT EXISTS +mv_loss_intelligence.context_governance_agenda_snapshot_items ( + snapshot_id bigint NOT NULL + REFERENCES + mv_loss_intelligence + .context_governance_agenda_snapshots ( + snapshot_id + ) + ON DELETE CASCADE, + item_rank integer NOT NULL + CHECK (item_rank > 0), + decision_group_key text NOT NULL, + decision_status text NOT NULL, + max_severity text NOT NULL, + machine_id text, + decision_family text NOT NULL, + decision_title text NOT NULL, + question_text text NOT NULL, + recommended_action text NOT NULL, + review_owner text NOT NULL, + first_seen_at timestamptz NOT NULL, + last_seen_at timestamptz NOT NULL, + ended_at timestamptz, + pending_age_hours numeric(14, 2) NOT NULL, + attention_status text NOT NULL + CHECK ( + attention_status IN ( + 'RECENT', + 'ATTENTION', + 'OVERDUE' + ) + ), + review_due_at timestamptz NOT NULL, + pending_root_case_count integer NOT NULL, + reviewed_root_case_count integer NOT NULL, + linked_root_case_count integer NOT NULL, + linked_episode_count integer NOT NULL, + root_case_ids bigint[] NOT NULL DEFAULT ARRAY[]::bigint[], + episode_ids bigint[] NOT NULL DEFAULT ARRAY[]::bigint[], + production_orders text[] NOT NULL DEFAULT ARRAY[]::text[], + odoo_workorder_ids bigint[] NOT NULL DEFAULT ARRAY[]::bigint[], + known_resolution_classifications text[] NOT NULL + DEFAULT ARRAY[]::text[], + evidence_json jsonb NOT NULL DEFAULT '{}'::jsonb, + official_eligible boolean NOT NULL DEFAULT false + CHECK (official_eligible = false), + PRIMARY KEY ( + snapshot_id, + item_rank + ), + CONSTRAINT ux_context_governance_snapshot_decision + UNIQUE ( + snapshot_id, + decision_group_key + ) +); + +COMMENT ON TABLE +mv_loss_intelligence.context_governance_agenda_snapshots IS +'Cabecera inmutable de una agenda diaria SHADOW guardada para auditoría de la reunión.'; + +COMMENT ON TABLE +mv_loss_intelligence.context_governance_agenda_snapshot_items IS +'Contenido inmutable de cada agenda guardada. Conserva la pregunta y su evidencia tal como se presentaron ese día.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_governance_decision_evidence_pack_v1 +AS +WITH evidence AS ( + SELECT + d.decision_group_key, + COUNT( + DISTINCT e.episode_id + ) AS linked_episode_count, + ARRAY_AGG( + DISTINCT e.episode_id + ORDER BY e.episode_id + ) AS episode_ids, + COALESCE( + ARRAY_REMOVE( + ARRAY_AGG( + DISTINCT e.incident_code + ORDER BY e.incident_code + ), + NULL + ), + ARRAY[]::text[] + ) AS incident_codes, + COALESCE( + ARRAY_REMOVE( + ARRAY_AGG( + DISTINCT e.incident_scope + ORDER BY e.incident_scope + ), + NULL + ), + ARRAY[]::text[] + ) AS incident_scopes, + COALESCE( + ARRAY_REMOVE( + ARRAY_AGG( + DISTINCT e.operator_name + ORDER BY e.operator_name + ), + NULL + ), + ARRAY[]::text[] + ) AS operator_names, + MIN(e.first_seen_at) + AS evidence_first_seen_at, + MAX(e.last_seen_at) + AS evidence_last_seen_at, + JSONB_AGG( + JSONB_BUILD_OBJECT( + 'case_id', + d.case_id, + 'episode_id', + e.episode_id, + 'evidence_role', + e.evidence_role, + 'incident_scope', + e.incident_scope, + 'incident_code', + e.incident_code, + 'incident_title', + e.incident_title, + 'production_order', + e.production_order, + 'odoo_workorder_id', + e.odoo_workorder_id, + 'operator_name', + e.operator_name, + 'episode_status', + e.episode_status, + 'first_seen_at', + e.first_seen_at, + 'last_seen_at', + e.last_seen_at, + 'ended_at', + e.ended_at, + 'episode_duration_min', + e.episode_duration_min, + 'episode_severity', + e.episode_severity, + 'episode_review_status', + e.episode_review_status, + 'latest_detail', + e.latest_detail, + 'recommended_action', + e.latest_recommended_action + ) + ORDER BY + d.case_id, + e.episode_id + ) AS episode_evidence + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_cases_v1 d + JOIN + mv_reports_ucepsa_prod + .li_context_review_case_evidence_v1 e + ON e.case_id = + d.case_id + GROUP BY + d.decision_group_key +) +SELECT + h.*, + COALESCE( + e.linked_episode_count, + 0 + ) AS linked_episode_count, + COALESCE( + e.episode_ids, + ARRAY[]::bigint[] + ) AS episode_ids, + COALESCE( + e.incident_codes, + ARRAY[]::text[] + ) AS incident_codes, + COALESCE( + e.incident_scopes, + ARRAY[]::text[] + ) AS incident_scopes, + COALESCE( + e.operator_names, + ARRAY[]::text[] + ) AS operator_names, + e.evidence_first_seen_at, + e.evidence_last_seen_at, + COALESCE( + e.episode_evidence, + '[]'::jsonb + ) AS episode_evidence +FROM + mv_reports_ucepsa_prod + .li_context_decision_group_history_v1 h +LEFT JOIN evidence e + ON e.decision_group_key = + h.decision_group_key; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_governance_decision_evidence_pack_v1 IS +'Paquete legible de evidencia para cada decisión humana: casos raíz, episodios, órdenes, operarios, detalles y acciones.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_governance_daily_agenda_v1 +AS +WITH policy AS ( + SELECT * + FROM + mv_loss_intelligence + .context_governance_daily_policies + WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND policy_code = + 'CONTEXT_DAILY_GOVERNANCE_V1' + AND active + ORDER BY updated_at DESC + LIMIT 1 +), +base AS ( + SELECT + e.*, + p.timezone_name, + p.attention_after_hours, + p.overdue_after_hours, + p.agenda_limit, + ROUND( + GREATEST( + EXTRACT( + EPOCH FROM ( + now() + - e.first_seen_at + ) + ) / 3600.0, + 0 + )::numeric, + 2 + ) AS pending_age_hours + FROM + mv_reports_ucepsa_prod + .li_context_governance_decision_evidence_pack_v1 e + CROSS JOIN policy p + WHERE e.review_required + AND e.review_status = + 'PENDING' +), +classified AS ( + SELECT + b.*, + CASE + WHEN b.pending_age_hours >= + b.overdue_after_hours + THEN 'OVERDUE' + WHEN b.pending_age_hours >= + b.attention_after_hours + THEN 'ATTENTION' + ELSE 'RECENT' + END AS attention_status, + b.first_seen_at + + ( + b.overdue_after_hours + * interval '1 hour' + ) AS review_due_at + FROM base b +) +SELECT + ROW_NUMBER() OVER ( + ORDER BY + CASE max_severity + WHEN 'CRITICAL' THEN 1 + ELSE 2 + END, + CASE attention_status + WHEN 'OVERDUE' THEN 1 + WHEN 'ATTENTION' THEN 2 + ELSE 3 + END, + CASE decision_status + WHEN 'OPEN' THEN 1 + ELSE 2 + END, + first_seen_at + ) AS agenda_rank, + ( + now() + AT TIME ZONE timezone_name + )::date AS agenda_local_date, + c.* +FROM classified c +ORDER BY agenda_rank; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_governance_daily_agenda_v1 IS +'Agenda diaria priorizada de gobernanza. Una fila equivale a una pregunta humana pendiente.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_governance_daily_summary_v1 +AS +WITH policy AS ( + SELECT * + FROM + mv_loss_intelligence + .context_governance_daily_policies + WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND policy_code = + 'CONTEXT_DAILY_GOVERNANCE_V1' + AND active + ORDER BY updated_at DESC + LIMIT 1 +), +today AS ( + SELECT + p.*, + ( + now() + AT TIME ZONE p.timezone_name + )::date AS local_today + FROM policy p +), +agenda_summary AS ( + SELECT + COUNT(*) AS pending_decision_count, + COUNT(*) FILTER ( + WHERE max_severity = + 'CRITICAL' + ) AS pending_critical_decision_count, + COUNT(*) FILTER ( + WHERE attention_status = + 'ATTENTION' + ) AS attention_decision_count, + COUNT(*) FILTER ( + WHERE attention_status = + 'OVERDUE' + ) AS overdue_decision_count, + COUNT(*) FILTER ( + WHERE review_owner = + 'PRODUCTION' + ) AS production_pending_count, + COUNT(*) FILTER ( + WHERE review_owner = + 'TECHNICAL' + ) AS technical_pending_count, + COUNT(*) FILTER ( + WHERE review_owner = + 'JOINT' + ) AS joint_pending_count, + COALESCE( + SUM(pending_root_case_count), + 0 + ) AS pending_root_case_count, + COALESCE( + SUM(linked_episode_count), + 0 + ) AS linked_episode_count + FROM + mv_reports_ucepsa_prod + .li_context_governance_daily_agenda_v1 +), +history_summary AS ( + SELECT + COUNT(*) FILTER ( + WHERE ( + h.first_seen_at + AT TIME ZONE t.timezone_name + )::date = + t.local_today + ) AS decisions_first_seen_today, + COUNT(*) FILTER ( + WHERE h.reviewed_at IS NOT NULL + AND ( + h.reviewed_at + AT TIME ZONE t.timezone_name + )::date = + t.local_today + ) AS decisions_reviewed_today, + ROUND( + ( + AVG( + EXTRACT( + EPOCH FROM ( + h.reviewed_at + - h.first_seen_at + ) + ) / 3600.0 + ) FILTER ( + WHERE h.reviewed_at IS NOT NULL + AND h.reviewed_at >= + h.first_seen_at + ) + )::numeric, + 2 + ) AS average_review_resolution_hours + FROM + mv_reports_ucepsa_prod + .li_context_governance_decision_evidence_pack_v1 h + CROSS JOIN today t +) +SELECT + now() AS observed_at, + t.local_today AS agenda_local_date, + a.pending_decision_count, + a.pending_critical_decision_count, + a.attention_decision_count, + a.overdue_decision_count, + a.production_pending_count, + a.technical_pending_count, + a.joint_pending_count, + a.pending_root_case_count, + a.linked_episode_count, + h.decisions_first_seen_today, + h.decisions_reviewed_today, + h.average_review_resolution_hours, + ROUND( + CASE + WHEN a.pending_decision_count = 0 + THEN 0 + ELSE + a.linked_episode_count::numeric + / a.pending_decision_count + END, + 2 + ) AS evidence_per_pending_decision, + false AS official_eligible +FROM today t +CROSS JOIN agenda_summary a +CROSS JOIN history_summary h; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_governance_daily_summary_v1 IS +'Indicadores de gobernanza: preguntas pendientes, vencidas, responsables, reducción de evidencias y tiempo de resolución.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_governance_agenda_snapshot_history_v1 +AS +SELECT + s.snapshot_id, + s.tenant, + s.site, + s.agenda_local_date, + s.generated_at, + s.generated_by, + s.item_count, + s.source_state_hash, + s.source_view, + s.notes, + i.item_rank, + i.decision_group_key, + i.decision_status, + i.max_severity, + i.machine_id, + i.decision_family, + i.decision_title, + i.question_text, + i.recommended_action, + i.review_owner, + i.first_seen_at, + i.last_seen_at, + i.ended_at, + i.pending_age_hours, + i.attention_status, + i.review_due_at, + i.pending_root_case_count, + i.reviewed_root_case_count, + i.linked_root_case_count, + i.linked_episode_count, + i.root_case_ids, + i.episode_ids, + i.production_orders, + i.odoo_workorder_ids, + i.known_resolution_classifications, + i.evidence_json, + s.official_eligible +FROM + mv_loss_intelligence + .context_governance_agenda_snapshots s +LEFT JOIN + mv_loss_intelligence + .context_governance_agenda_snapshot_items i + ON i.snapshot_id = + s.snapshot_id; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_governance_agenda_snapshot_history_v1 IS +'Histórico inmutable de agendas guardadas y evidencia presentada en cada reunión.'; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = + 'grafana_ucepsa_ro' + ) THEN + GRANT SELECT + ON + mv_loss_intelligence + .context_governance_daily_policies + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_governance_decision_evidence_pack_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_governance_daily_agenda_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_governance_daily_summary_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_governance_agenda_snapshot_history_v1 + TO grafana_ucepsa_ro; + END IF; +END +$$; + +COMMIT; diff --git a/ucepsa/edge-oee-demo/tools/context_daily_governance.py b/ucepsa/edge-oee-demo/tools/context_daily_governance.py new file mode 100755 index 0000000..1fccff0 --- /dev/null +++ b/ucepsa/edge-oee-demo/tools/context_daily_governance.py @@ -0,0 +1,673 @@ +#!/usr/bin/env python3 +"""Agenda diaria y evidencia de gobernanza UCEPSA v0.3.12.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +import psycopg2 +from psycopg2.extras import Json, RealDictCursor + + +VERSION = "0.3.12" + + +def env_first(*names: str, default: str | None = None) -> str | None: + for name in names: + value = os.getenv(name) + if value not in (None, ""): + return value + return default + + +def connect(): + database = env_first("PGDATABASE", "POSTGRES_DB") + user = env_first("PGUSER", "POSTGRES_USER") + password = env_first("PGPASSWORD", "POSTGRES_PASSWORD") + + missing = [ + name + for name, value in ( + ("PGDATABASE", database), + ("PGUSER", user), + ("PGPASSWORD", password), + ) + if not value + ] + if missing: + raise RuntimeError( + "Faltan variables: " + ", ".join(missing) + ) + + return psycopg2.connect( + host=env_first( + "PGHOST", + default="mv_ucepsa_postgres_hot", + ), + port=int( + env_first("PGPORT", default="5432") + or "5432" + ), + dbname=database, + user=user, + password=password, + connect_timeout=10, + application_name=( + "context_daily_governance_v0312" + ), + ) + + +def json_safe(value: Any) -> Any: + if isinstance(value, dict): + return { + str(key): json_safe(item) + for key, item in value.items() + } + if isinstance(value, (list, tuple, set)): + return [ + json_safe(item) + for item in value + ] + if isinstance(value, (datetime, date)): + return value.isoformat() + if isinstance(value, Decimal): + return str(value) + return value + + +def print_json(value: Any) -> None: + print( + json.dumps( + json_safe(value), + ensure_ascii=False, + indent=2, + ) + ) + + +def fetch_agenda(cursor, limit: int) -> list[dict[str, Any]]: + cursor.execute( + """ + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_governance_daily_agenda_v1 + ORDER BY agenda_rank + LIMIT %s + """, + (limit,), + ) + return [ + dict(row) + for row in cursor.fetchall() + ] + + +def fetch_summary(cursor) -> dict[str, Any]: + cursor.execute( + """ + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_governance_daily_summary_v1 + """ + ) + row = cursor.fetchone() + return dict(row) if row else {} + + +def fetch_decision( + cursor, + decision_group_key: str, +) -> dict[str, Any]: + cursor.execute( + """ + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_governance_decision_evidence_pack_v1 + WHERE decision_group_key = %s + """, + (decision_group_key,), + ) + row = cursor.fetchone() + if not row: + raise RuntimeError( + "No existe la decisión " + f"{decision_group_key!r}" + ) + return dict(row) + + +def agenda_command(limit: int) -> None: + with connect() as connection: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + print_json({ + "version": VERSION, + "summary": fetch_summary(cursor), + "agenda": fetch_agenda(cursor, limit), + }) + + +def show_command(decision_group_key: str) -> None: + with connect() as connection: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + print_json( + fetch_decision( + cursor, + decision_group_key, + ) + ) + + +def snapshot_payload( + agenda: list[dict[str, Any]], +) -> str: + payload = json.dumps( + json_safe(agenda), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def snapshot_command( + generated_by: str, + notes: str | None, + limit: int, +) -> None: + with connect() as connection: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + agenda = fetch_agenda(cursor, limit) + summary = fetch_summary(cursor) + local_date = ( + agenda[0]["agenda_local_date"] + if agenda + else summary.get( + "agenda_local_date" + ) + ) + + if local_date is None: + raise RuntimeError( + "No se pudo resolver la fecha local." + ) + + state_hash = snapshot_payload(agenda) + + cursor.execute( + """ + INSERT INTO + mv_loss_intelligence + .context_governance_agenda_snapshots ( + tenant, + site, + agenda_local_date, + generated_by, + item_count, + source_state_hash, + notes, + official_eligible + ) + VALUES ( + 'ucepsa', + 'ucepsa_onpremise', + %s, + %s, + %s, + %s, + %s, + false + ) + RETURNING * + """, + ( + local_date, + generated_by, + len(agenda), + state_hash, + notes, + ), + ) + snapshot = dict(cursor.fetchone()) + + for row in agenda: + cursor.execute( + """ + INSERT INTO + mv_loss_intelligence + .context_governance_agenda_snapshot_items ( + snapshot_id, + item_rank, + decision_group_key, + decision_status, + max_severity, + machine_id, + decision_family, + decision_title, + question_text, + recommended_action, + review_owner, + first_seen_at, + last_seen_at, + ended_at, + pending_age_hours, + attention_status, + review_due_at, + pending_root_case_count, + reviewed_root_case_count, + linked_root_case_count, + linked_episode_count, + root_case_ids, + episode_ids, + production_orders, + odoo_workorder_ids, + known_resolution_classifications, + evidence_json, + official_eligible + ) + VALUES ( + %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, + %s, %s, false + ) + """, + ( + snapshot["snapshot_id"], + row["agenda_rank"], + row["decision_group_key"], + row["decision_status"], + row["max_severity"], + row["machine_id"], + row["decision_family"], + row["decision_title"], + row["question_text"], + row["recommended_action"], + row["review_owner"], + row["first_seen_at"], + row["last_seen_at"], + row["ended_at"], + row["pending_age_hours"], + row["attention_status"], + row["review_due_at"], + row["pending_root_case_count"], + row["reviewed_root_case_count"], + row["linked_root_case_count"], + row["linked_episode_count"], + row["root_case_ids"], + row["episode_ids"], + row["production_orders"], + row["odoo_workorder_ids"], + row[ + "known_resolution_classifications" + ], + Json({ + "incident_codes": + row["incident_codes"], + "incident_scopes": + row["incident_scopes"], + "operator_names": + row["operator_names"], + "episode_evidence": + json_safe( + row["episode_evidence"] + ), + }), + ), + ) + + print_json({ + "version": VERSION, + "status": "snapshot_created", + "snapshot": snapshot, + }) + + +def fetch_snapshot( + cursor, + snapshot_id: int, +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + cursor.execute( + """ + SELECT * + FROM + mv_loss_intelligence + .context_governance_agenda_snapshots + WHERE snapshot_id = %s + """, + (snapshot_id,), + ) + header = cursor.fetchone() + if not header: + raise RuntimeError( + f"No existe el snapshot {snapshot_id}" + ) + + cursor.execute( + """ + SELECT * + FROM + mv_loss_intelligence + .context_governance_agenda_snapshot_items + WHERE snapshot_id = %s + ORDER BY item_rank + """, + (snapshot_id,), + ) + items = [ + dict(row) + for row in cursor.fetchall() + ] + return dict(header), items + + +def array_text(values: list[Any] | None) -> str: + if not values: + return "—" + return ", ".join(str(value) for value in values) + + +def dt_text(value: Any) -> str: + if value is None: + return "—" + if isinstance(value, datetime): + return value.strftime("%d/%m/%Y %H:%M:%S %z") + return str(value) + + +def markdown_document( + title_date: Any, + generated_at: Any, + generated_by: str, + summary: dict[str, Any], + items: list[dict[str, Any]], + snapshot_id: int | None = None, + notes: str | None = None, +) -> str: + lines = [ + f"# Revisión diaria de gobernanza — {title_date}", + "", + f"- Generada: {dt_text(generated_at)}", + f"- Generada por: {generated_by}", + f"- Snapshot: {snapshot_id if snapshot_id is not None else 'vista actual'}", + "- Modo: SHADOW", + "- Ledger oficial: desactivado", + ] + + if notes: + lines.extend([ + f"- Notas: {notes}", + ]) + + lines.extend([ + "", + "## Resumen", + "", + f"- Decisiones pendientes: {summary.get('pending_decision_count', len(items))}", + f"- Críticas: {summary.get('pending_critical_decision_count', '—')}", + f"- Requieren atención: {summary.get('attention_decision_count', '—')}", + f"- Vencidas: {summary.get('overdue_decision_count', '—')}", + f"- Casos raíz pendientes: {summary.get('pending_root_case_count', '—')}", + f"- Evidencias enlazadas: {summary.get('linked_episode_count', '—')}", + "", + "## Agenda", + "", + ]) + + if not items: + lines.append( + "No hay decisiones humanas pendientes." + ) + return "\n".join(lines) + "\n" + + for item in items: + rank = ( + item.get("agenda_rank") + or item.get("item_rank") + ) + lines.extend([ + f"### {rank}. {item['max_severity']} · {item.get('machine_id') or 'GLOBAL'}", + "", + f"**{item['decision_title']}**", + "", + f"- Estado: {item['decision_status']}", + f"- Atención: {item['attention_status']}", + f"- Responsable: {item['review_owner']}", + f"- Inicio: {dt_text(item['first_seen_at'])}", + f"- Última evidencia: {dt_text(item['last_seen_at'])}", + f"- Antigüedad pendiente: {item['pending_age_hours']} h", + f"- Límite de revisión: {dt_text(item['review_due_at'])}", + f"- Casos raíz: {array_text(item.get('root_case_ids'))}", + f"- Episodios: {array_text(item.get('episode_ids'))}", + f"- Órdenes: {array_text(item.get('production_orders'))}", + f"- Workorders: {array_text(item.get('odoo_workorder_ids'))}", + f"- Resolución conocida: {array_text(item.get('known_resolution_classifications'))}", + "", + "**Pregunta**", + "", + item["question_text"], + "", + "**Acción recomendada**", + "", + item["recommended_action"], + "", + ]) + + evidence = item.get("episode_evidence") + if evidence is None: + evidence_json = item.get( + "evidence_json", + {}, + ) + evidence = evidence_json.get( + "episode_evidence", + [], + ) + + if evidence: + lines.extend([ + "**Evidencia técnica resumida**", + "", + ]) + for row in evidence: + lines.append( + "- " + f"Episodio {row.get('episode_id')}: " + f"{row.get('incident_title')}; " + f"{row.get('latest_detail') or 'sin detalle adicional'}" + ) + lines.append("") + + lines.extend([ + f"`{item['decision_group_key']}`", + "", + ]) + + return "\n".join(lines) + "\n" + + +def export_markdown_command( + snapshot_id: int | None, + generated_by: str, +) -> None: + with connect() as connection: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + if snapshot_id is not None: + header, items = fetch_snapshot( + cursor, + snapshot_id, + ) + summary = { + "pending_decision_count": + header["item_count"], + "pending_critical_decision_count": + sum( + 1 + for row in items + if row["max_severity"] == + "CRITICAL" + ), + "attention_decision_count": + sum( + 1 + for row in items + if row["attention_status"] == + "ATTENTION" + ), + "overdue_decision_count": + sum( + 1 + for row in items + if row["attention_status"] == + "OVERDUE" + ), + "pending_root_case_count": + sum( + row[ + "pending_root_case_count" + ] + for row in items + ), + "linked_episode_count": + sum( + row[ + "linked_episode_count" + ] + for row in items + ), + } + document = markdown_document( + title_date= + header["agenda_local_date"], + generated_at= + header["generated_at"], + generated_by= + header["generated_by"], + summary=summary, + items=items, + snapshot_id=snapshot_id, + notes=header["notes"], + ) + else: + items = fetch_agenda(cursor, 1000) + summary = fetch_summary(cursor) + document = markdown_document( + title_date= + summary.get( + "agenda_local_date" + ), + generated_at=summary.get( + "observed_at", + datetime.now(), + ), + generated_by=generated_by, + summary=summary, + items=items, + ) + + sys.stdout.write(document) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Agenda diaria, evidencia y snapshots " + "de gobernanza UCEPSA." + ) + ) + sub = parser.add_subparsers( + dest="command", + required=True, + ) + + agenda = sub.add_parser("agenda") + agenda.add_argument( + "--limit", + type=int, + default=50, + ) + + show = sub.add_parser("show") + show.add_argument( + "--decision-group-key", + required=True, + ) + + snapshot = sub.add_parser("snapshot") + snapshot.add_argument( + "--generated-by", + required=True, + ) + snapshot.add_argument("--notes") + snapshot.add_argument( + "--limit", + type=int, + default=50, + ) + + export = sub.add_parser("export-markdown") + export.add_argument( + "--snapshot-id", + type=int, + ) + export.add_argument( + "--generated-by", + default="MESAVAULT", + ) + + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + if args.command == "agenda": + agenda_command(args.limit) + return 0 + + if args.command == "show": + show_command( + args.decision_group_key + ) + return 0 + + if args.command == "snapshot": + snapshot_command( + generated_by=args.generated_by, + notes=args.notes, + limit=args.limit, + ) + return 0 + + if args.command == "export-markdown": + export_markdown_command( + snapshot_id=args.snapshot_id, + generated_by=args.generated_by, + ) + return 0 + + raise RuntimeError("Comando no soportado") + + +if __name__ == "__main__": + raise SystemExit(main())