diff --git a/ucepsa/edge-oee-demo/docs/runbooks/108_context_root_case_queue_shadow_v0.3.10.md b/ucepsa/edge-oee-demo/docs/runbooks/108_context_root_case_queue_shadow_v0.3.10.md new file mode 100644 index 0000000..760258b --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/runbooks/108_context_root_case_queue_shadow_v0.3.10.md @@ -0,0 +1,186 @@ +# UCEPSA — Cola de casos raíz SHADOW v0.3.10 + +## Objetivo + +Reducir la intervención humana a una decisión por problema real. + +Ejemplo actual: + +```text +episodio 7 incidencia de máquina +episodio 8 evidencia workorder 649 +episodio 9 evidencia workorder 698 +``` + +La cola humana debe presentar: + +```text +1 caso raíz +CORT-00 +Varias órdenes Shop Floor abiertas +Evidencias: 7, 8 y 9 +``` + +No se elimina ninguna evidencia. + +## Arquitectura + +```text +episodios técnicos +→ constructor de casos raíz +→ una pregunta humana +→ revisión auditada +→ cascada de revisión a episodios asociados +``` + +## Agrupación inicial + +`MULTIPLE_OPEN_SESSIONS` se agrupa cuando: + +```text +misma máquina +misma familia +intervalos solapados o separados <= 120 s +``` + +Los demás episodios generan inicialmente un caso individual. + +## Revisión + +Clasificaciones de caso: + +```text +ODOO_DOUBLE_START_ONE_VALID +ODOO_DOUBLE_START_BOTH_INVALID +ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED +AUTHORIZED_LEGACY_PRODUCTION +TEST_OR_SETUP +RESIDUAL_MATERIAL +ODOO_START_OMITTED +DATA_ISSUE +NOT_RELEVANT +OTHER +``` + +Una revisión de caso: + +- actualiza el caso raíz; +- crea una revisión auditada para cada episodio pendiente; +- elimina la duplicación del backlog; +- no modifica Odoo; +- no crea pérdidas oficiales. + +## Objetos + +```text +context_review_case_policies +context_review_cases +context_review_case_evidence +context_review_case_reviews +context_review_case_builder_state + +li_context_review_case_history_v1 +li_context_review_case_backlog_v1 +li_context_review_case_evidence_v1 +li_context_review_case_builder_health_v1 + +li_canonical_context_readiness_metrics_v2 +li_canonical_context_readiness_gate_v2 +li_context_rollout_blockers_v2 +li_context_rollout_readiness_summary_v2 +``` + +## Despliegue + +### Migración + +```bash +FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/112_ucepsa_context_root_case_queue_shadow_v0310.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_review_case_builder.py" \ + "$RUNTIME/tools/" + +install -m 0644 \ + "$TARGET/tools/context_review_case_admin.py" \ + "$RUNTIME/tools/" +``` + +### Constructor + +```bash +MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_context_review_case_builder_v0310.sh + +"$MANAGER" dry-run +"$MANAGER" once +"$MANAGER" start +sleep 35 +"$MANAGER" status +``` + +## Validación + +```bash +VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_context_root_case_queue_v0310.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 +evidence_link_duplicates = 0 +review_episodes_without_case = 0 +root_case_count_7_8_9 = 1 +evidence_count_7_8_9 = 3 +ready_v2_gate_mismatches = 0 +official_case_rows = 0 +official_gate_rows = 0 +official_operator_queue_rows = 0 +``` + +## Consola + +```bash +"$MANAGER" admin list +"$MANAGER" admin show --case-id +``` + +Cuando Juan Pablo confirme la orden real del conflicto: + +```bash +"$MANAGER" admin review \ + --case-id \ + --classification ODOO_DOUBLE_START_ONE_VALID \ + --selected-order-ref "WH/MO/00706" \ + --selected-workorder-id 698 \ + --reviewed-by "Juan Pablo" \ + --notes "Confirma que WH/MO/00706 era la orden real y WH/MO/00660 quedó abierta por error." +``` + +No ejecutar hasta recibir confirmación. + +## Dashboard + +Se mantienen los dos dashboards existentes. El dashboard de salud añade: + +```text +Casos raíz pendientes +Evidencias asociadas +Historial de casos raíz +``` + +El panel readiness utiliza v2 y cuenta decisiones humanas, no evidencias. 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 33fd514..71aa26a 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 canónico y readiness de rollout. Los criterios muestran qué falta antes de un piloto controlado; nunca habilitan el Ledger oficial.", + "description": "Supervisión SHADOW de sesiones, incidencias, arbitraje, readiness y casos raíz. Producción revisa una decisión por problema real mientras las evidencias técnicas se conservan para auditoría.", "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 1, @@ -1384,7 +1384,7 @@ "refId": "A" } ], - "title": "Incidencias pendientes de revisión", + "title": "Evidencias técnicas pendientes · auditoría", "type": "table" }, { @@ -2045,11 +2045,11 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT\n machine_id AS \"Máquina\",\n observed_activity_hours AS \"Horas observadas\",\n confirmed_context_pct AS \"% confirmado\",\n unconfirmed_context_pct AS \"% sin confirmar\",\n pending_review_count AS \"Revisiones pendientes\",\n oldest_pending_review_hours AS \"Antigüedad revisión h\",\n recent_conflict_minutes AS \"Conflicto min\",\n critical_session_dq_count AS \"DQ crítica\",\n readiness_status AS \"Readiness\",\n next_action AS \"Siguiente acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_canonical_context_readiness_gate_v1\nORDER BY machine_id", + "rawSql": "SELECT\n machine_id AS \"Máquina\",\n observed_activity_hours AS \"Horas observadas\",\n confirmed_context_pct AS \"% confirmado\",\n unconfirmed_context_pct AS \"% sin confirmar\",\n pending_root_case_count AS \"Casos raíz pendientes\",\n pending_review_count AS \"Evidencias pendientes\",\n oldest_pending_root_case_hours\n AS \"Antigüedad caso h\",\n recent_conflict_minutes AS \"Conflicto min\",\n critical_session_dq_count AS \"DQ crítica\",\n readiness_status AS \"Readiness\",\n next_action AS \"Siguiente acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_canonical_context_readiness_gate_v2\nORDER BY machine_id", "refId": "A" } ], - "title": "Readiness de contexto por máquina · ventana de 7 días", + "title": "Readiness por máquina · casos raíz", "type": "table" }, { @@ -2166,7 +2166,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT\n machine_id AS \"Máquina\",\n blocker_severity AS \"Severidad\",\n blocker_code AS \"Criterio\",\n current_value AS \"Valor actual\",\n target_value AS \"Objetivo\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_rollout_blockers_v1\nORDER BY\n CASE blocker_severity\n WHEN 'CRITICAL' THEN 1\n WHEN 'WARNING' THEN 2\n ELSE 3\n END,\n machine_id,\n blocker_code", + "rawSql": "SELECT\n machine_id AS \"Máquina\",\n blocker_severity AS \"Severidad\",\n blocker_code AS \"Criterio\",\n current_value AS \"Valor actual\",\n target_value AS \"Objetivo\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_rollout_blockers_v2\nORDER BY\n CASE blocker_severity\n WHEN 'CRITICAL' THEN 1\n WHEN 'WARNING' THEN 2\n ELSE 3\n END,\n machine_id,\n blocker_code", "refId": "A" } ], @@ -2231,12 +2231,384 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT\n overall_readiness_status AS \"Estado global\",\n machine_count AS \"Máquinas\",\n ready_machine_count AS \"Ready SHADOW\",\n technical_blocked_machine_count AS \"Bloqueo técnico\",\n insufficient_observation_machine_count AS \"Falta observación\",\n review_backlog_machine_count AS \"Revisión pendiente\",\n coverage_not_ready_machine_count AS \"Cobertura insuficiente\",\n blocker_count AS \"Bloqueos\",\n critical_blocker_count AS \"Bloqueos críticos\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_rollout_readiness_summary_v1", + "rawSql": "SELECT\n overall_readiness_status AS \"Estado global\",\n machine_count AS \"Máquinas\",\n ready_machine_count AS \"Ready SHADOW\",\n technical_blocked_machine_count\n AS \"Bloqueo técnico\",\n insufficient_observation_machine_count\n AS \"Falta observación\",\n review_backlog_machine_count\n AS \"Revisión pendiente\",\n coverage_not_ready_machine_count\n AS \"Cobertura insuficiente\",\n pending_root_case_count\n AS \"Casos raíz pendientes\",\n blocker_count AS \"Bloqueos\",\n critical_blocker_count AS \"Bloqueos críticos\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_rollout_readiness_summary_v2", "refId": "A" } ], "title": "Resumen de readiness del contexto canónico", "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" + } + }, + { + "id": "custom.width", + "value": 110 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Problema raíz" + }, + "properties": [ + { + "id": "custom.width", + "value": 380 + } + ] + }, + { + "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": 11, + "w": 24, + "x": 0, + "y": 126 + }, + "id": 20, + "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 backlog_rank AS \"Prioridad\",\n case_id AS \"Caso\",\n CASE case_status\n WHEN 'OPEN' THEN 'Abierto'\n ELSE 'Cerrado'\n END AS \"Estado\",\n max_severity AS \"Severidad\",\n machine_id AS \"Máquina\",\n case_title AS \"Problema raíz\",\n array_to_string(\n production_orders,\n ', '\n ) AS \"Órdenes\",\n array_to_string(\n odoo_workorder_ids,\n ', '\n ) AS \"Workorders\",\n linked_evidence_count AS \"Evidencias\",\n CASE review_owner\n WHEN 'PRODUCTION' THEN 'Producción'\n WHEN 'TECHNICAL' THEN 'Técnico'\n ELSE 'Conjunto'\n END AS \"Responsable\",\n first_seen_at AS \"Inicio\",\n case_duration_min AS \"Duración min\",\n question_text AS \"Pregunta\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_review_case_backlog_v1\nORDER BY backlog_rank\nLIMIT 50", + "refId": "A" + } + ], + "title": "Casos raíz pendientes de revisió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": [ + { + "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" + } + }, + { + "id": "custom.width", + "value": 110 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Evidencia" + }, + "properties": [ + { + "id": "custom.width", + "value": 420 + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 137 + }, + "id": 21, + "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 case_id AS \"Caso\",\n evidence_role AS \"Rol\",\n episode_id AS \"Episodio\",\n incident_scope AS \"Ámbito\",\n incident_title AS \"Evidencia\",\n COALESCE(\n production_order,\n '—'\n ) AS \"Orden\",\n COALESCE(\n odoo_workorder_id::text,\n '—'\n ) AS \"Workorder\",\n episode_status AS \"Estado\",\n episode_duration_min AS \"Duración min\",\n episode_severity AS \"Severidad\",\n episode_review_status AS \"Revisión\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_review_case_evidence_v1\nWHERE case_review_status =\n 'PENDING'\nORDER BY\n case_id,\n episode_id\nLIMIT 200", + "refId": "A" + } + ], + "title": "Evidencias asociadas a los casos raíz", + "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" + } + }, + { + "id": "custom.width", + "value": 110 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Problema raíz" + }, + "properties": [ + { + "id": "custom.width", + "value": 420 + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 147 + }, + "id": 22, + "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 case_id AS \"Caso\",\n CASE case_status\n WHEN 'OPEN' THEN 'Abierto'\n ELSE 'Cerrado'\n END AS \"Estado\",\n max_severity AS \"Severidad\",\n machine_id AS \"Máquina\",\n case_title AS \"Problema raíz\",\n first_seen_at AS \"Inicio\",\n ended_at AS \"Fin\",\n case_duration_min AS \"Duración min\",\n linked_evidence_count AS \"Evidencias\",\n review_status AS \"Revisión\",\n COALESCE(\n resolution_classification,\n '—'\n ) AS \"Resolución\",\n COALESCE(\n selected_order_ref,\n '—'\n ) AS \"Orden válida\",\n COALESCE(\n selected_workorder_id::text,\n '—'\n ) AS \"WO válido\",\n COALESCE(\n reviewed_by,\n '—'\n ) AS \"Revisado por\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_review_case_history_v1\nWHERE first_seen_at >=\n now() - interval '7 days'\nORDER BY first_seen_at DESC\nLIMIT 100", + "refId": "A" + } + ], + "title": "Historial de casos raíz · últimos 7 días", + "type": "table" } ], "refresh": "15s", @@ -2255,7 +2627,10 @@ "v038", "readiness", "rollout-gate", - "v039" + "v039", + "root-cases", + "human-governance", + "v0310" ], "templating": { "list": [] diff --git a/ucepsa/edge-oee-demo/ops/deploy_context_root_case_dashboard_v0310.sh b/ucepsa/edge-oee-demo/ops/deploy_context_root_case_dashboard_v0310.sh new file mode 100755 index 0000000..210fb0d --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/deploy_context_root_case_dashboard_v0310.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash +set -euo pipefail + +ACTION="${1:-deploy}" +GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}" +REPO_ROOT="${ROOT_CASE_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}" +DASHBOARD_FILE="${ROOT_CASE_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}" +FOLDER_UID="${ROOT_CASE_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 + cat >&2 <<'MSG' +ERROR: falta GRAFANA_API_TOKEN. + + read -rsp "Token temporal de Grafana: " GRAFANA_API_TOKEN + echo + export GRAFANA_API_TOKEN +MSG + 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 root case queue " + "SHADOW v0.3.10" + ), + }, + ) + + 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", [])) != 22: + errors.append( + "El dashboard no tiene 22 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 = { + 20: "li_context_review_case_backlog_v1", + 21: "li_context_review_case_evidence_v1", + 22: "li_context_review_case_history_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} " + f"no consulta {fragment}" + ) + + readiness_sql = ( + panels.get(17, {}) + .get("targets", [{}])[0] + .get("rawSql", "") + ) + if ( + "li_canonical_context_readiness_gate_v2" + not in readiness_sql + ): + errors.append( + "El panel readiness no usa gate v2" + ) + + 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"), + "root_case_panels": { + "backlog": 20 in panels, + "evidence": 21 in panels, + "history": 22 in panels, + "readiness_v2": + "li_canonical_context_readiness_gate_v2" + in readiness_sql, + }, + "errors": + errors, + } + + print(json.dumps( + output, + ensure_ascii=False, + indent=2, + )) + + if errors: + raise SystemExit(1) + +else: + raise SystemExit( + "Uso: " + "deploy_context_root_case_dashboard_v0310.sh " + "{deploy|validate}" + ) +PY diff --git a/ucepsa/edge-oee-demo/ops/manage_context_review_case_builder_v0310.sh b/ucepsa/edge-oee-demo/ops/manage_context_review_case_builder_v0310.sh new file mode 100755 index 0000000..1d7398e --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/manage_context_review_case_builder_v0310.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -euo pipefail + +ACTION="${1:-status}" +ROOT="${CONTEXT_CASE_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}" +CONTAINER="${CONTEXT_CASE_CONTAINER:-mv_ucepsa_context_review_case_builder}" +NETWORK="${CONTEXT_CASE_NETWORK:-mv_ucepsa_net}" +INTERVAL="${CONTEXT_CASE_INTERVAL_S:-15}" +POSTGRES_CONTAINER="${CONTEXT_CASE_POSTGRES_CONTAINER:-mv_ucepsa_postgres_hot}" +TENANT_OVERRIDE="${CONTEXT_CASE_TENANT_OVERRIDE:-ucepsa}" +SITE_OVERRIDE="${CONTEXT_CASE_SITE_OVERRIDE:-ucepsa_onpremise}" +PGHOST_OVERRIDE="${CONTEXT_CASE_PGHOST_OVERRIDE:-mv_ucepsa_postgres_hot}" + +cd "$ROOT" + +IMAGE="$( + docker inspect \ + mv_ucepsa_odoo_order_state_pg_sink \ + --format '{{.Config.Image}}' +)" + +PGDATABASE_OVERRIDE="${CONTEXT_CASE_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 + +common_args=( + --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 +) + +start_builder() { + docker rm -f "$CONTAINER" \ + >/dev/null 2>&1 || true + + docker run -d \ + --name "$CONTAINER" \ + --restart unless-stopped \ + "${common_args[@]}" \ + "$IMAGE" \ + tools/context_review_case_builder.py \ + --watch \ + --interval "$INTERVAL" + + docker ps \ + --format 'table {{.Names}}\t{{.Status}}' \ + | grep -E "$CONTAINER|NAMES" +} + +run_builder_once() { + docker run --rm -i \ + "${common_args[@]}" \ + "$IMAGE" \ + tools/context_review_case_builder.py \ + --once +} + +run_builder_dry() { + docker run --rm -i \ + "${common_args[@]}" \ + "$IMAGE" \ + tools/context_review_case_builder.py \ + --once \ + --dry-run +} + +run_admin() { + shift + docker run --rm -i \ + "${common_args[@]}" \ + "$IMAGE" \ + tools/context_review_case_admin.py \ + "$@" +} + +case "$ACTION" in + start) + start_builder + ;; + stop) + docker stop "$CONTAINER" + ;; + restart) + docker rm -f "$CONTAINER" \ + >/dev/null 2>&1 || true + start_builder + ;; + status) + docker ps -a \ + --format 'table {{.Names}}\t{{.Status}}' \ + | grep -E "$CONTAINER|NAMES" + ;; + logs) + docker logs --tail 200 -f "$CONTAINER" + ;; + once) + run_builder_once + ;; + dry-run) + run_builder_dry + ;; + admin) + run_admin "$@" + ;; + *) + echo \ + "Uso: $0 {start|stop|restart|status|logs|once|dry-run|admin ...}" \ + >&2 + exit 2 + ;; +esac diff --git a/ucepsa/edge-oee-demo/ops/validate_context_root_case_queue_v0310.sql b/ucepsa/edge-oee-demo/ops/validate_context_root_case_queue_v0310.sql new file mode 100644 index 0000000..faffb26 --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/validate_context_root_case_queue_v0310.sql @@ -0,0 +1,200 @@ +\pset pager off + +\echo '=== 1. Objetos v0.3.10 ===' + +SELECT + to_regclass( + 'mv_loss_intelligence.context_review_case_policies' + ) AS policy_table, + to_regclass( + 'mv_loss_intelligence.context_review_cases' + ) AS cases_table, + to_regclass( + 'mv_loss_intelligence.context_review_case_evidence' + ) AS evidence_table, + to_regclass( + 'mv_loss_intelligence.context_review_case_reviews' + ) AS reviews_table, + to_regclass( + 'mv_loss_intelligence.context_review_case_builder_state' + ) AS state_table, + to_regclass( + 'mv_reports_ucepsa_prod.li_context_review_case_history_v1' + ) AS history_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_context_review_case_backlog_v1' + ) AS backlog_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_context_review_case_evidence_v1' + ) AS evidence_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_context_review_case_builder_health_v1' + ) AS health_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_canonical_context_readiness_gate_v2' + ) AS readiness_v2_view; + +\echo '=== 2. Salud del constructor ===' + +SELECT * +FROM + mv_reports_ucepsa_prod + .li_context_review_case_builder_health_v1; + +\echo '=== 3. Evidencias no pertenecen a varios casos ===' + +SELECT COUNT(*) AS evidence_link_duplicates +FROM ( + SELECT + episode_id, + COUNT(*) AS n + FROM + mv_loss_intelligence.context_review_case_evidence + GROUP BY episode_id + HAVING COUNT(*) > 1 +) duplicated; + +\echo '=== 4. Episodios recientes con revisión no quedan sin caso ===' + +SELECT COUNT(*) AS review_episodes_without_case +FROM + mv_reports_ucepsa_prod + .li_shopfloor_context_incident_history_v1 h +WHERE h.review_required + AND ( + h.episode_status = 'OPEN' + OR h.first_seen_at >= + now() - interval '30 days' + ) + AND NOT EXISTS ( + SELECT 1 + FROM + mv_loss_intelligence.context_review_case_evidence e + WHERE e.episode_id = + h.episode_id + ); + +\echo '=== 5. Conflicto 7/8/9 agrupado en un caso raíz ===' + +SELECT + COUNT( + DISTINCT case_id + ) AS root_case_count_7_8_9, + COUNT(*) AS evidence_count_7_8_9 +FROM + mv_loss_intelligence.context_review_case_evidence +WHERE episode_id IN ( + 7, + 8, + 9 +); + +\echo '=== 6. Detalle del caso raíz 7/8/9 ===' + +SELECT DISTINCT + c.case_id, + c.case_family, + c.machine_id, + c.case_title, + c.question_text, + c.case_status, + c.max_severity, + c.linked_evidence_count, + c.episode_ids, + c.production_orders, + c.odoo_workorder_ids, + c.review_owner, + c.review_status, + c.official_eligible +FROM + mv_reports_ucepsa_prod + .li_context_review_case_history_v1 c +JOIN + mv_loss_intelligence.context_review_case_evidence e + ON e.case_id = + c.case_id +WHERE e.episode_id IN ( + 7, + 8, + 9 +); + +\echo '=== 7. Backlog humano por caso raíz ===' + +SELECT + backlog_rank, + case_id, + case_status, + max_severity, + machine_id, + case_title, + linked_evidence_count, + production_orders, + odoo_workorder_ids, + review_owner, + review_status, + question_text +FROM + mv_reports_ucepsa_prod + .li_context_review_case_backlog_v1 +ORDER BY backlog_rank +LIMIT 50; + +\echo '=== 8. Readiness v2 ===' + +SELECT + machine_id, + pending_review_count + AS pending_episode_review_count, + pending_root_case_count, + pending_critical_root_case_count, + oldest_pending_root_case_hours, + builder_health_status, + technical_gate_pass, + review_gate_pass, + readiness_status, + next_action, + ready_for_official_ledger, + official_ledger_eligible +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v2 +ORDER BY machine_id; + +\echo '=== 9. READY v2 coherente ===' + +SELECT COUNT(*) AS ready_v2_gate_mismatches +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v2 +WHERE readiness_status = + 'READY_FOR_CONTROLLED_SHADOW' + AND NOT ( + technical_gate_pass + AND observation_gate_pass + AND review_gate_pass + AND coverage_gate_pass + ); + +\echo '=== 10. Nada oficial ===' + +SELECT + ( + SELECT COUNT(*) + FROM + mv_loss_intelligence.context_review_cases + WHERE official_eligible + ) AS official_case_rows, + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v2 + WHERE ready_for_official_ledger + OR official_ledger_eligible + ) AS official_gate_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/112_ucepsa_context_root_case_queue_shadow_v0310.sql b/ucepsa/edge-oee-demo/sql/versions/112_ucepsa_context_root_case_queue_shadow_v0310.sql new file mode 100644 index 0000000..31e25a7 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/versions/112_ucepsa_context_root_case_queue_shadow_v0310.sql @@ -0,0 +1,1262 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS +mv_loss_intelligence.context_review_case_policies ( + tenant text NOT NULL, + site text NOT NULL, + policy_code text NOT NULL, + builder_interval_s integer NOT NULL DEFAULT 15 + CHECK (builder_interval_s > 0), + grouping_window_s integer NOT NULL DEFAULT 120 + CHECK (grouping_window_s >= 0), + builder_delayed_after_s integer NOT NULL DEFAULT 45 + CHECK (builder_delayed_after_s > 0), + builder_stale_after_s integer NOT NULL DEFAULT 120 + CHECK ( + builder_stale_after_s > + builder_delayed_after_s + ), + history_lookback_days integer NOT NULL DEFAULT 30 + CHECK (history_lookback_days > 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_review_case_policies IS +'Política SHADOW para agrupar episodios técnicos relacionados en un único caso raíz de revisión humana.'; + +INSERT INTO +mv_loss_intelligence.context_review_case_policies ( + tenant, + site, + policy_code, + builder_interval_s, + grouping_window_s, + builder_delayed_after_s, + builder_stale_after_s, + history_lookback_days, + active, + notes +) +VALUES ( + 'ucepsa', + 'ucepsa_onpremise', + 'CONTEXT_ROOT_CASE_QUEUE_V1', + 15, + 120, + 45, + 120, + 30, + true, + 'Agrupa evidencias MULTIPLE_OPEN_SESSIONS de la misma máquina cuando sus ventanas se solapan o quedan separadas por menos de 120 s. El resto de episodios genera un caso individual.' +) +ON CONFLICT ( + tenant, + site, + policy_code +) +DO UPDATE SET + builder_interval_s = + EXCLUDED.builder_interval_s, + grouping_window_s = + EXCLUDED.grouping_window_s, + builder_delayed_after_s = + EXCLUDED.builder_delayed_after_s, + builder_stale_after_s = + EXCLUDED.builder_stale_after_s, + history_lookback_days = + EXCLUDED.history_lookback_days, + active = true, + notes = + EXCLUDED.notes, + updated_at = now(); + +CREATE TABLE IF NOT EXISTS +mv_loss_intelligence.context_review_cases ( + case_id bigserial PRIMARY KEY, + tenant text NOT NULL, + site text NOT NULL, + case_key text NOT NULL, + case_family text NOT NULL, + root_incident_code text NOT NULL, + machine_id text, + case_title text NOT NULL, + question_text text NOT NULL, + recommended_action text NOT NULL, + case_status text NOT NULL DEFAULT 'OPEN' + CHECK ( + case_status IN ( + 'OPEN', + 'CLOSED' + ) + ), + max_severity text NOT NULL + CHECK ( + max_severity IN ( + 'WARNING', + 'CRITICAL' + ) + ), + first_seen_at timestamptz NOT NULL, + last_seen_at timestamptz NOT NULL, + ended_at timestamptz, + evidence_count integer NOT NULL DEFAULT 0 + CHECK (evidence_count >= 0), + review_owner text NOT NULL + CHECK ( + review_owner IN ( + 'PRODUCTION', + 'TECHNICAL', + 'JOINT' + ) + ), + review_required boolean NOT NULL DEFAULT true, + review_status text NOT NULL DEFAULT 'PENDING' + CHECK ( + review_status IN ( + 'PENDING', + 'REVIEWED', + 'DISMISSED', + 'NOT_REQUIRED' + ) + ), + resolution_classification text, + selected_order_ref text, + selected_workorder_id bigint, + reviewed_by text, + reviewed_at timestamptz, + review_notes text, + official_eligible boolean NOT NULL DEFAULT false + CHECK (official_eligible = false), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ux_context_review_case_key + UNIQUE ( + tenant, + site, + case_key + ), + CONSTRAINT ck_context_review_case_interval + CHECK ( + last_seen_at >= first_seen_at + AND ( + ended_at IS NULL + OR ended_at >= first_seen_at + ) + ), + CONSTRAINT ck_context_review_case_resolution + CHECK ( + resolution_classification IS NULL + OR resolution_classification IN ( + 'ODOO_DOUBLE_START_ONE_VALID', + 'ODOO_DOUBLE_START_BOTH_INVALID', + 'ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED', + 'AUTHORIZED_LEGACY_PRODUCTION', + 'TEST_OR_SETUP', + 'RESIDUAL_MATERIAL', + 'ODOO_START_OMITTED', + 'DATA_ISSUE', + 'NOT_RELEVANT', + 'OTHER' + ) + ) +); + +CREATE INDEX IF NOT EXISTS +idx_context_review_cases_backlog +ON +mv_loss_intelligence.context_review_cases ( + tenant, + site, + review_required, + review_status, + max_severity, + first_seen_at +); + +CREATE INDEX IF NOT EXISTS +idx_context_review_cases_machine +ON +mv_loss_intelligence.context_review_cases ( + tenant, + site, + machine_id, + first_seen_at DESC +); + +CREATE TABLE IF NOT EXISTS +mv_loss_intelligence.context_review_case_evidence ( + case_id bigint NOT NULL + REFERENCES + mv_loss_intelligence.context_review_cases ( + case_id + ) + ON DELETE CASCADE, + episode_id bigint NOT NULL + REFERENCES + mv_loss_intelligence + .shopfloor_context_incident_episodes ( + episode_id + ) + ON DELETE CASCADE, + evidence_role text NOT NULL + CHECK ( + evidence_role IN ( + 'MACHINE_ROOT', + 'SESSION_EVIDENCE', + 'PRIMARY_EPISODE', + 'RELATED_EVIDENCE' + ) + ), + linked_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ( + case_id, + episode_id + ), + CONSTRAINT ux_context_review_case_episode + UNIQUE (episode_id) +); + +CREATE INDEX IF NOT EXISTS +idx_context_review_case_evidence_case +ON +mv_loss_intelligence.context_review_case_evidence ( + case_id, + episode_id +); + +CREATE TABLE IF NOT EXISTS +mv_loss_intelligence.context_review_case_reviews ( + review_id bigserial PRIMARY KEY, + case_id bigint NOT NULL + REFERENCES + mv_loss_intelligence.context_review_cases ( + case_id + ) + ON DELETE CASCADE, + previous_values jsonb NOT NULL, + new_review_status text NOT NULL + CHECK ( + new_review_status IN ( + 'REVIEWED', + 'DISMISSED' + ) + ), + resolution_classification text NOT NULL + CHECK ( + resolution_classification IN ( + 'ODOO_DOUBLE_START_ONE_VALID', + 'ODOO_DOUBLE_START_BOTH_INVALID', + 'ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED', + 'AUTHORIZED_LEGACY_PRODUCTION', + 'TEST_OR_SETUP', + 'RESIDUAL_MATERIAL', + 'ODOO_START_OMITTED', + 'DATA_ISSUE', + 'NOT_RELEVANT', + 'OTHER' + ) + ), + selected_order_ref text, + selected_workorder_id bigint, + reviewed_by text NOT NULL, + reviewed_at timestamptz NOT NULL DEFAULT now(), + notes text, + evidence_json jsonb NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS +idx_context_review_case_reviews_case +ON +mv_loss_intelligence.context_review_case_reviews ( + case_id, + reviewed_at DESC +); + +CREATE OR REPLACE FUNCTION +mv_loss_intelligence.apply_context_review_case_review() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + UPDATE + mv_loss_intelligence.context_review_cases + SET + review_status = + NEW.new_review_status, + resolution_classification = + NEW.resolution_classification, + selected_order_ref = + NEW.selected_order_ref, + selected_workorder_id = + NEW.selected_workorder_id, + reviewed_by = + NEW.reviewed_by, + reviewed_at = + NEW.reviewed_at, + review_notes = + NEW.notes, + updated_at = + now() + WHERE case_id = + NEW.case_id; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS +trg_apply_context_review_case_review +ON +mv_loss_intelligence.context_review_case_reviews; + +CREATE TRIGGER +trg_apply_context_review_case_review +AFTER INSERT +ON +mv_loss_intelligence.context_review_case_reviews +FOR EACH ROW +EXECUTE FUNCTION +mv_loss_intelligence.apply_context_review_case_review(); + +CREATE TABLE IF NOT EXISTS +mv_loss_intelligence.context_review_case_builder_state ( + tenant text NOT NULL, + site text NOT NULL, + builder_name text NOT NULL, + last_success_at timestamptz, + last_episode_count integer, + last_case_count integer, + last_link_count integer, + last_error text, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ( + tenant, + site, + builder_name + ) +); + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_review_case_history_v1 +AS +WITH evidence_rollup AS ( + SELECT + e.case_id, + COUNT(*) AS linked_evidence_count, + ARRAY_AGG( + e.episode_id + ORDER BY e.episode_id + ) AS episode_ids, + ARRAY_AGG( + e.evidence_role + ORDER BY e.episode_id + ) AS evidence_roles, + COALESCE( + ARRAY_REMOVE( + ARRAY_AGG( + DISTINCT h.production_order + ORDER BY h.production_order + ), + NULL + ), + ARRAY[]::text[] + ) AS production_orders, + COALESCE( + ARRAY_REMOVE( + ARRAY_AGG( + DISTINCT h.odoo_workorder_id + ORDER BY h.odoo_workorder_id + ), + NULL + ), + ARRAY[]::bigint[] + ) AS odoo_workorder_ids, + COUNT(*) FILTER ( + WHERE h.episode_status = 'OPEN' + ) AS open_evidence_count, + COUNT(*) FILTER ( + WHERE h.review_status = 'PENDING' + ) AS pending_episode_review_count + FROM + mv_loss_intelligence.context_review_case_evidence e + JOIN + mv_reports_ucepsa_prod + .li_shopfloor_context_incident_history_v1 h + ON h.episode_id = + e.episode_id + GROUP BY e.case_id +), +review_counts AS ( + SELECT + case_id, + COUNT(*) AS review_count + FROM + mv_loss_intelligence.context_review_case_reviews + GROUP BY case_id +) +SELECT + c.case_id, + ( + 'CONTEXT_REVIEW_CASE:' + || c.case_id::text + ) AS case_reference, + c.tenant, + c.site, + c.case_key, + c.case_family, + c.root_incident_code, + c.machine_id, + c.case_title, + c.question_text, + c.recommended_action, + c.case_status, + c.max_severity, + c.first_seen_at, + c.last_seen_at, + c.ended_at, + ROUND( + EXTRACT( + EPOCH FROM ( + COALESCE( + c.ended_at, + now() + ) + - c.first_seen_at + ) + )::numeric / 60.0, + 2 + ) AS case_duration_min, + COALESCE( + e.linked_evidence_count, + 0 + ) AS linked_evidence_count, + COALESCE( + e.episode_ids, + ARRAY[]::bigint[] + ) AS episode_ids, + COALESCE( + e.evidence_roles, + ARRAY[]::text[] + ) AS evidence_roles, + COALESCE( + e.production_orders, + ARRAY[]::text[] + ) AS production_orders, + COALESCE( + e.odoo_workorder_ids, + ARRAY[]::bigint[] + ) AS odoo_workorder_ids, + COALESCE( + e.open_evidence_count, + 0 + ) AS open_evidence_count, + COALESCE( + e.pending_episode_review_count, + 0 + ) AS pending_episode_review_count, + c.review_owner, + c.review_required, + c.review_status, + c.resolution_classification, + c.selected_order_ref, + c.selected_workorder_id, + c.reviewed_by, + c.reviewed_at, + c.review_notes, + COALESCE( + r.review_count, + 0 + ) AS review_count, + c.official_eligible, + c.created_at, + c.updated_at +FROM + mv_loss_intelligence.context_review_cases c +LEFT JOIN evidence_rollup e + ON e.case_id = + c.case_id +LEFT JOIN review_counts r + ON r.case_id = + c.case_id; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_review_case_history_v1 IS +'Historial SHADOW de casos raíz. Un caso puede contener varias evidencias técnicas y requiere una sola decisión humana.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_review_case_backlog_v1 +AS +SELECT + ROW_NUMBER() OVER ( + ORDER BY + CASE max_severity + WHEN 'CRITICAL' THEN 1 + ELSE 2 + END, + CASE case_status + WHEN 'OPEN' THEN 1 + ELSE 2 + END, + first_seen_at + ) AS backlog_rank, + h.* +FROM + mv_reports_ucepsa_prod + .li_context_review_case_history_v1 h +WHERE h.review_required + AND h.review_status = + 'PENDING'; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_review_case_backlog_v1 IS +'Cola humana de casos raíz pendientes. Sustituye el conteo operativo por episodio, sin eliminar la auditoría técnica original.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_review_case_evidence_v1 +AS +SELECT + c.case_id, + c.case_reference, + c.case_family, + c.case_title, + c.machine_id, + c.case_status, + c.max_severity + AS case_severity, + c.review_status + AS case_review_status, + e.evidence_role, + h.episode_id, + h.incident_scope, + h.incident_key, + h.incident_code, + h.incident_title, + h.context_key, + h.production_order, + h.odoo_workorder_id, + h.operator_name, + h.episode_status, + h.first_seen_at, + h.last_seen_at, + h.ended_at, + h.episode_duration_min, + h.max_severity + AS episode_severity, + h.review_status + AS episode_review_status, + h.review_classification + AS episode_review_classification, + h.latest_detail, + h.latest_recommended_action, + h.official_eligible +FROM + mv_reports_ucepsa_prod + .li_context_review_case_history_v1 c +JOIN + mv_loss_intelligence.context_review_case_evidence e + ON e.case_id = + c.case_id +JOIN + mv_reports_ucepsa_prod + .li_shopfloor_context_incident_history_v1 h + ON h.episode_id = + e.episode_id; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_review_case_evidence_v1 IS +'Evidencias técnicas asociadas a cada caso raíz. Mantiene máquina, sesiones y episodios originales para auditoría.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_review_case_builder_health_v1 +AS +WITH policy AS ( + SELECT * + FROM + mv_loss_intelligence.context_review_case_policies + WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND policy_code = + 'CONTEXT_ROOT_CASE_QUEUE_V1' + AND active + ORDER BY updated_at DESC + LIMIT 1 +), +state AS ( + SELECT * + FROM + mv_loss_intelligence.context_review_case_builder_state + WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND builder_name = + 'context_review_case_builder_v0310' +) +SELECT + p.tenant, + p.site, + 'context_review_case_builder_v0310'::text + AS builder_name, + p.policy_code, + p.builder_interval_s, + p.grouping_window_s, + p.builder_delayed_after_s, + p.builder_stale_after_s, + p.history_lookback_days, + s.last_success_at, + s.last_episode_count, + s.last_case_count, + s.last_link_count, + NULLIF( + btrim(s.last_error), + '' + ) AS last_error, + CASE + WHEN s.last_success_at IS NULL + THEN NULL::numeric + ELSE ROUND( + GREATEST( + EXTRACT( + EPOCH FROM ( + now() + - s.last_success_at + ) + ), + 0 + )::numeric, + 2 + ) + END AS seconds_since_success, + CASE + WHEN s.tenant IS NULL + THEN 'NOT_INITIALIZED' + WHEN NULLIF( + btrim(s.last_error), + '' + ) IS NOT NULL + THEN 'ERROR' + WHEN s.last_success_at IS NULL + THEN 'NOT_INITIALIZED' + WHEN now() - s.last_success_at > + make_interval( + secs => + p.builder_stale_after_s + ::double precision + ) + THEN 'STALE' + WHEN now() - s.last_success_at > + make_interval( + secs => + p.builder_delayed_after_s + ::double precision + ) + THEN 'DELAYED' + ELSE 'HEALTHY' + END AS builder_health_status, + CASE + WHEN s.tenant IS NULL + OR s.last_success_at IS NULL + OR NULLIF( + btrim(s.last_error), + '' + ) IS NOT NULL + OR now() - s.last_success_at > + make_interval( + secs => + p.builder_stale_after_s + ::double precision + ) + THEN 'CRITICAL' + WHEN now() - s.last_success_at > + make_interval( + secs => + p.builder_delayed_after_s + ::double precision + ) + THEN 'WARNING' + ELSE 'OK' + END AS severity, + false AS official_eligible, + now() AS observed_at +FROM policy p +LEFT JOIN state s + ON s.tenant = + p.tenant + AND s.site = + p.site; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_review_case_builder_health_v1 IS +'Salud del constructor persistente de casos raíz de revisión.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_canonical_context_readiness_metrics_v2 +AS +WITH root_cases AS ( + SELECT + machine_id, + COUNT(*) AS pending_root_case_count, + COUNT(*) FILTER ( + WHERE max_severity = + 'CRITICAL' + ) AS pending_critical_root_case_count, + MIN(first_seen_at) + AS oldest_pending_root_case_at + FROM + mv_reports_ucepsa_prod + .li_context_review_case_backlog_v1 + WHERE machine_id IS NOT NULL + GROUP BY machine_id +), +builder_health AS ( + SELECT + builder_health_status, + severity + AS builder_severity, + seconds_since_success + AS builder_seconds_since_success + FROM + mv_reports_ucepsa_prod + .li_context_review_case_builder_health_v1 +) +SELECT + m.*, + COALESCE( + r.pending_root_case_count, + 0 + ) AS pending_root_case_count, + COALESCE( + r.pending_critical_root_case_count, + 0 + ) AS pending_critical_root_case_count, + r.oldest_pending_root_case_at, + ROUND( + CASE + WHEN r.oldest_pending_root_case_at + IS NULL + THEN 0 + ELSE EXTRACT( + EPOCH FROM ( + now() + - r.oldest_pending_root_case_at + ) + )::numeric / 3600.0 + END, + 2 + ) AS oldest_pending_root_case_hours, + b.builder_health_status, + b.builder_severity, + b.builder_seconds_since_success +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_metrics_v1 m +CROSS JOIN builder_health b +LEFT JOIN root_cases r + ON r.machine_id = + m.machine_id; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_canonical_context_readiness_metrics_v2 IS +'Readiness v2: conserva métricas por episodio para auditoría y añade conteo humano por caso raíz.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_canonical_context_readiness_gate_v2 +AS +SELECT + m.*, + ( + ( + NOT m.require_sync_healthy + OR m.sync_health_status = + 'HEALTHY' + ) + AND ( + NOT m.require_incident_collector_healthy + OR m.collector_health_status = + 'HEALTHY' + ) + AND m.builder_health_status = + 'HEALTHY' + AND m.critical_session_dq_count <= + m.maximum_critical_session_dq_count + AND m.recent_conflict_minutes <= + m.maximum_conflict_minutes + AND m.live_canonical_health_status <> + 'CRITICAL' + ) AS technical_gate_pass, + ( + m.observed_activity_hours >= + m.minimum_observed_activity_hours + ) AS observation_gate_pass, + ( + m.pending_root_case_count <= + m.maximum_pending_review_count + AND m.oldest_pending_root_case_hours <= + m.maximum_oldest_pending_review_hours + ) AS review_gate_pass, + ( + m.confirmed_context_pct >= + m.minimum_confirmed_context_pct + AND m.unconfirmed_context_pct <= + m.maximum_unconfirmed_context_pct + ) AS coverage_gate_pass, + CASE + WHEN ( + ( + m.require_sync_healthy + AND m.sync_health_status <> + 'HEALTHY' + ) + OR ( + m.require_incident_collector_healthy + AND m.collector_health_status <> + 'HEALTHY' + ) + OR m.builder_health_status <> + 'HEALTHY' + OR m.critical_session_dq_count > + m.maximum_critical_session_dq_count + OR m.recent_conflict_minutes > + m.maximum_conflict_minutes + OR m.live_canonical_health_status = + 'CRITICAL' + ) + THEN 'TECHNICAL_BLOCKED' + WHEN m.observed_activity_hours < + m.minimum_observed_activity_hours + THEN 'INSUFFICIENT_OBSERVATION' + WHEN ( + m.pending_root_case_count > + m.maximum_pending_review_count + OR m.oldest_pending_root_case_hours > + m.maximum_oldest_pending_review_hours + ) + THEN 'REVIEW_BACKLOG' + WHEN ( + m.confirmed_context_pct < + m.minimum_confirmed_context_pct + OR m.unconfirmed_context_pct > + m.maximum_unconfirmed_context_pct + ) + THEN 'COVERAGE_NOT_READY' + ELSE 'READY_FOR_CONTROLLED_SHADOW' + END AS readiness_status, + CASE + WHEN ( + ( + m.require_sync_healthy + AND m.sync_health_status <> + 'HEALTHY' + ) + OR ( + m.require_incident_collector_healthy + AND m.collector_health_status <> + 'HEALTHY' + ) + ) + THEN + 'Restablecer la salud de sincronización y del colector de incidencias.' + WHEN m.builder_health_status <> + 'HEALTHY' + THEN + 'Restablecer el constructor de casos raíz.' + WHEN m.critical_session_dq_count > + m.maximum_critical_session_dq_count + THEN + 'Resolver los errores críticos de sesiones Shop Floor.' + WHEN m.recent_conflict_minutes > + m.maximum_conflict_minutes + OR m.live_canonical_health_status = + 'CRITICAL' + THEN + 'Resolver los conflictos de contexto antes de continuar.' + WHEN m.observed_activity_hours < + m.minimum_observed_activity_hours + THEN + 'Acumular más horas reales de observación.' + WHEN m.pending_root_case_count > + m.maximum_pending_review_count + THEN + 'Resolver los casos raíz pendientes con Producción o el responsable técnico.' + WHEN m.oldest_pending_root_case_hours > + m.maximum_oldest_pending_review_hours + THEN + 'Evitar que el caso raíz más antiguo supere el plazo configurado.' + WHEN m.confirmed_context_pct < + m.minimum_confirmed_context_pct + THEN + 'Aumentar el uso correcto de Shop Floor o autorizar contextos legacy/manuales reales.' + WHEN m.unconfirmed_context_pct > + m.maximum_unconfirmed_context_pct + THEN + 'Reducir actividad física sin contexto confirmado.' + ELSE + 'La máquina cumple los criterios SHADOW configurados.' + END AS next_action, + CASE + WHEN ( + ( + m.require_sync_healthy + AND m.sync_health_status <> + 'HEALTHY' + ) + OR ( + m.require_incident_collector_healthy + AND m.collector_health_status <> + 'HEALTHY' + ) + OR m.builder_health_status <> + 'HEALTHY' + OR m.critical_session_dq_count > + m.maximum_critical_session_dq_count + OR m.recent_conflict_minutes > + m.maximum_conflict_minutes + OR m.live_canonical_health_status = + 'CRITICAL' + ) + THEN 1 + WHEN m.observed_activity_hours < + m.minimum_observed_activity_hours + THEN 2 + WHEN ( + m.pending_root_case_count > + m.maximum_pending_review_count + OR m.oldest_pending_root_case_hours > + m.maximum_oldest_pending_review_hours + ) + THEN 3 + WHEN ( + m.confirmed_context_pct < + m.minimum_confirmed_context_pct + OR m.unconfirmed_context_pct > + m.maximum_unconfirmed_context_pct + ) + THEN 4 + ELSE 5 + END AS readiness_stage, + false AS ready_for_official_ledger +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_metrics_v2 m; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_canonical_context_readiness_gate_v2 IS +'Gate humano v2: el backlog se mide por casos raíz, no por evidencias técnicas. Toda elegibilidad oficial permanece false.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_rollout_blockers_v2 +AS +SELECT + g.machine_id, + 'SYNC_HEALTH'::text + AS blocker_code, + 'CRITICAL'::text + AS blocker_severity, + g.sync_health_status + AS current_value, + 'HEALTHY'::text + AS target_value, + 'Restablecer la sincronización Shop Floor.'::text + AS recommended_action, + false AS official_ledger_eligible +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v2 g +WHERE g.require_sync_healthy + AND g.sync_health_status <> + 'HEALTHY' + +UNION ALL + +SELECT + g.machine_id, + 'INCIDENT_COLLECTOR_HEALTH', + 'CRITICAL', + g.collector_health_status, + 'HEALTHY', + 'Restablecer el colector de episodios de incidencia.', + false +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v2 g +WHERE g.require_incident_collector_healthy + AND g.collector_health_status <> + 'HEALTHY' + +UNION ALL + +SELECT + g.machine_id, + 'ROOT_CASE_BUILDER_HEALTH', + 'CRITICAL', + g.builder_health_status, + 'HEALTHY', + 'Restablecer el constructor de casos raíz.', + false +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v2 g +WHERE g.builder_health_status <> + 'HEALTHY' + +UNION ALL + +SELECT + g.machine_id, + 'CRITICAL_SESSION_DQ', + 'CRITICAL', + g.critical_session_dq_count::text, + g.maximum_critical_session_dq_count::text, + 'Resolver las sesiones Shop Floor con errores críticos.', + false +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v2 g +WHERE g.critical_session_dq_count > + g.maximum_critical_session_dq_count + +UNION ALL + +SELECT + g.machine_id, + 'CONTEXT_CONFLICT_MINUTES', + 'CRITICAL', + g.recent_conflict_minutes::text, + g.maximum_conflict_minutes::text, + 'Resolver los solapamientos entre fuentes autoritativas.', + false +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v2 g +WHERE g.recent_conflict_minutes > + g.maximum_conflict_minutes + +UNION ALL + +SELECT + g.machine_id, + 'OBSERVED_ACTIVITY_HOURS', + 'INFO', + g.observed_activity_hours::text, + g.minimum_observed_activity_hours::text, + 'Acumular más horas reales de observación.', + false +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v2 g +WHERE g.observed_activity_hours < + g.minimum_observed_activity_hours + +UNION ALL + +SELECT + g.machine_id, + 'PENDING_ROOT_CASE_COUNT', + 'WARNING', + g.pending_root_case_count::text, + g.maximum_pending_review_count::text, + 'Resolver los casos raíz pendientes.', + false +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v2 g +WHERE g.pending_root_case_count > + g.maximum_pending_review_count + +UNION ALL + +SELECT + g.machine_id, + 'OLDEST_PENDING_ROOT_CASE_HOURS', + 'WARNING', + g.oldest_pending_root_case_hours::text, + g.maximum_oldest_pending_review_hours::text, + 'Revisar el caso raíz pendiente más antiguo.', + false +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v2 g +WHERE g.oldest_pending_root_case_hours > + g.maximum_oldest_pending_review_hours + +UNION ALL + +SELECT + g.machine_id, + 'CONFIRMED_CONTEXT_PCT', + 'WARNING', + g.confirmed_context_pct::text, + g.minimum_confirmed_context_pct::text, + 'Aumentar la actividad con contexto canónico confirmado.', + false +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v2 g +WHERE g.confirmed_context_pct < + g.minimum_confirmed_context_pct + +UNION ALL + +SELECT + g.machine_id, + 'UNCONFIRMED_CONTEXT_PCT', + 'WARNING', + g.unconfirmed_context_pct::text, + g.maximum_unconfirmed_context_pct::text, + 'Reducir actividad física sin contexto confirmado.', + false +FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v2 g +WHERE g.unconfirmed_context_pct > + g.maximum_unconfirmed_context_pct; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_rollout_blockers_v2 IS +'Bloqueos de rollout v2. La carga humana se expresa por casos raíz.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_rollout_readiness_summary_v2 +AS +WITH gate AS ( + SELECT * + FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v2 +), +blockers AS ( + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_rollout_blockers_v2 +) +SELECT + now() AS observed_at, + COUNT(*) AS machine_count, + COUNT(*) FILTER ( + WHERE readiness_status = + 'READY_FOR_CONTROLLED_SHADOW' + ) AS ready_machine_count, + COUNT(*) FILTER ( + WHERE readiness_status = + 'TECHNICAL_BLOCKED' + ) AS technical_blocked_machine_count, + COUNT(*) FILTER ( + WHERE readiness_status = + 'INSUFFICIENT_OBSERVATION' + ) AS insufficient_observation_machine_count, + COUNT(*) FILTER ( + WHERE readiness_status = + 'REVIEW_BACKLOG' + ) AS review_backlog_machine_count, + COUNT(*) FILTER ( + WHERE readiness_status = + 'COVERAGE_NOT_READY' + ) AS coverage_not_ready_machine_count, + COALESCE( + SUM(pending_root_case_count), + 0 + ) AS pending_root_case_count, + ( + SELECT COUNT(*) + FROM blockers + ) AS blocker_count, + ( + SELECT COUNT(*) + FROM blockers + WHERE blocker_severity = + 'CRITICAL' + ) AS critical_blocker_count, + CASE + WHEN COUNT(*) FILTER ( + WHERE readiness_status = + 'TECHNICAL_BLOCKED' + ) > 0 + THEN 'TECHNICAL_BLOCKED' + WHEN COUNT(*) FILTER ( + WHERE readiness_status = + 'READY_FOR_CONTROLLED_SHADOW' + ) = COUNT(*) + THEN 'READY_FOR_CONTROLLED_SHADOW' + ELSE 'NOT_READY' + END AS overall_readiness_status, + false AS ready_for_official_ledger, + false AS official_ledger_eligible +FROM gate; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_rollout_readiness_summary_v2 IS +'Resumen readiness v2 con conteo de decisiones humanas por caso raíz.'; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = + 'grafana_ucepsa_ro' + ) THEN + GRANT SELECT + ON + mv_loss_intelligence.context_review_case_policies + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_review_case_history_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_review_case_backlog_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_review_case_evidence_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_review_case_builder_health_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_canonical_context_readiness_metrics_v2 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v2 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_rollout_blockers_v2 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_rollout_readiness_summary_v2 + TO grafana_ucepsa_ro; + END IF; +END +$$; + +COMMIT; diff --git a/ucepsa/edge-oee-demo/tools/context_review_case_admin.py b/ucepsa/edge-oee-demo/tools/context_review_case_admin.py new file mode 100755 index 0000000..cfea914 --- /dev/null +++ b/ucepsa/edge-oee-demo/tools/context_review_case_admin.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +"""Administración de casos raíz v0.3.10.""" + +from __future__ import annotations + +import argparse +import json +import os +from typing import Any + +import psycopg2 +from psycopg2.extras import Json, RealDictCursor + + +CLASSIFICATIONS = ( + "ODOO_DOUBLE_START_ONE_VALID", + "ODOO_DOUBLE_START_BOTH_INVALID", + "ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED", + "AUTHORIZED_LEGACY_PRODUCTION", + "TEST_OR_SETUP", + "RESIDUAL_MATERIAL", + "ODOO_START_OMITTED", + "DATA_ISSUE", + "NOT_RELEVANT", + "OTHER", +) + + +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_review_case_admin_v0310", + ) + + +def print_json(value: Any) -> None: + print( + json.dumps( + value, + ensure_ascii=False, + indent=2, + default=str, + ) + ) + + +def list_cases(limit: int) -> None: + with connect() as connection: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + cursor.execute( + """ + SELECT + backlog_rank, + case_id, + case_status, + max_severity, + first_seen_at, + last_seen_at, + case_duration_min, + machine_id, + case_title, + production_orders, + odoo_workorder_ids, + linked_evidence_count, + review_owner, + review_status, + question_text, + recommended_action + FROM + mv_reports_ucepsa_prod + .li_context_review_case_backlog_v1 + ORDER BY backlog_rank + LIMIT %s + """, + (limit,), + ) + print_json( + [dict(row) for row in cursor.fetchall()] + ) + + +def show_case(case_id: int) -> None: + with connect() as connection: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + cursor.execute( + """ + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_review_case_history_v1 + WHERE case_id = %s + """, + (case_id,), + ) + case = cursor.fetchone() + if not case: + raise RuntimeError( + f"No existe el caso {case_id}" + ) + + cursor.execute( + """ + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_review_case_evidence_v1 + WHERE case_id = %s + ORDER BY episode_id + """, + (case_id,), + ) + evidence = [ + dict(row) + for row in cursor.fetchall() + ] + + cursor.execute( + """ + SELECT * + FROM + mv_loss_intelligence + .context_review_case_reviews + WHERE case_id = %s + ORDER BY reviewed_at, review_id + """, + (case_id,), + ) + reviews = [ + dict(row) + for row in cursor.fetchall() + ] + + print_json({ + "case": dict(case), + "evidence": evidence, + "reviews": reviews, + }) + + +def episode_classification( + root_classification: str, +) -> str: + mapping = { + "ODOO_DOUBLE_START_ONE_VALID": + "DATA_ISSUE", + "ODOO_DOUBLE_START_BOTH_INVALID": + "DATA_ISSUE", + "ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED": + "DATA_ISSUE", + "AUTHORIZED_LEGACY_PRODUCTION": + "AUTHORIZED_LEGACY_PRODUCTION", + "TEST_OR_SETUP": + "TEST_OR_SETUP", + "RESIDUAL_MATERIAL": + "RESIDUAL_MATERIAL", + "ODOO_START_OMITTED": + "ODOO_START_OMITTED", + "DATA_ISSUE": + "DATA_ISSUE", + "NOT_RELEVANT": + "NOT_RELEVANT", + "OTHER": + "OTHER", + } + return mapping[root_classification] + + +def review_case( + case_id: int, + classification: str, + reviewed_by: str, + notes: str | None, + selected_order_ref: str | None, + selected_workorder_id: int | None, +) -> None: + case_status = ( + "DISMISSED" + if classification == "NOT_RELEVANT" + else "REVIEWED" + ) + episode_status = ( + "DISMISSED" + if classification == "NOT_RELEVANT" + else "REVIEWED" + ) + episode_class = episode_classification( + classification + ) + + with connect() as connection: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + cursor.execute( + """ + SELECT * + FROM + mv_loss_intelligence + .context_review_cases + WHERE case_id = %s + FOR UPDATE + """, + (case_id,), + ) + previous = cursor.fetchone() + if not previous: + raise RuntimeError( + f"No existe el caso {case_id}" + ) + + cursor.execute( + """ + INSERT INTO + mv_loss_intelligence + .context_review_case_reviews ( + case_id, + previous_values, + new_review_status, + resolution_classification, + selected_order_ref, + selected_workorder_id, + reviewed_by, + notes, + evidence_json + ) + VALUES ( + %s, %s, %s, %s, + %s, %s, %s, %s, %s + ) + RETURNING * + """, + ( + case_id, + Json(dict(previous)), + case_status, + classification, + selected_order_ref, + selected_workorder_id, + reviewed_by, + notes, + Json({ + "source": + "context_review_case_admin_v0310", + "cascade_to_episode_reviews": + True, + }), + ), + ) + case_review = dict(cursor.fetchone()) + + cursor.execute( + """ + SELECT + h.* + FROM + mv_loss_intelligence + .context_review_case_evidence e + JOIN + mv_reports_ucepsa_prod + .li_shopfloor_context_incident_history_v1 h + ON h.episode_id = + e.episode_id + WHERE e.case_id = %s + ORDER BY h.episode_id + """, + (case_id,), + ) + episodes = [ + dict(row) + for row in cursor.fetchall() + ] + + cascaded = [] + for episode in episodes: + if episode["review_status"] != "PENDING": + continue + + cascade_notes = ( + f"Caso raíz {case_id}: " + f"{notes or classification}. " + f"Orden seleccionada: " + f"{selected_order_ref or '—'}; " + f"workorder seleccionado: " + f"{selected_workorder_id or '—'}." + ) + + cursor.execute( + """ + INSERT INTO + mv_loss_intelligence + .shopfloor_context_incident_reviews ( + episode_id, + previous_values, + new_review_status, + review_classification, + reviewed_by, + notes, + evidence_json + ) + VALUES ( + %s, %s, %s, %s, + %s, %s, %s + ) + RETURNING review_id + """, + ( + episode["episode_id"], + Json({ + "review_status": + episode["review_status"], + "review_classification": + episode[ + "review_classification" + ], + "reviewed_by": + episode["reviewed_by"], + "reviewed_at": + episode["reviewed_at"], + "review_notes": + episode["review_notes"], + }), + episode_status, + episode_class, + reviewed_by, + cascade_notes, + Json({ + "source_case_id": case_id, + "root_classification": + classification, + }), + ), + ) + cascaded.append({ + "episode_id": + episode["episode_id"], + "review_id": + cursor.fetchone()["review_id"], + }) + + cursor.execute( + """ + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_review_case_history_v1 + WHERE case_id = %s + """, + (case_id,), + ) + current = dict(cursor.fetchone()) + + print_json({ + "case_review": case_review, + "case": current, + "cascaded_episode_reviews": cascaded, + "warning": ( + "La revisión documenta la decisión y " + "cierra el backlog humano. No modifica Odoo " + "ni habilita el Ledger oficial." + ), + }) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Administración de casos raíz." + ) + sub = parser.add_subparsers( + dest="command", + required=True, + ) + + list_cmd = sub.add_parser("list") + list_cmd.add_argument( + "--limit", + type=int, + default=50, + ) + + show_cmd = sub.add_parser("show") + show_cmd.add_argument( + "--case-id", + type=int, + required=True, + ) + + review_cmd = sub.add_parser("review") + review_cmd.add_argument( + "--case-id", + type=int, + required=True, + ) + review_cmd.add_argument( + "--classification", + choices=CLASSIFICATIONS, + required=True, + ) + review_cmd.add_argument( + "--reviewed-by", + required=True, + ) + review_cmd.add_argument("--notes") + review_cmd.add_argument("--selected-order-ref") + review_cmd.add_argument( + "--selected-workorder-id", + type=int, + ) + + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + if args.command == "list": + list_cases(args.limit) + return 0 + + if args.command == "show": + show_case(args.case_id) + return 0 + + if args.command == "review": + review_case( + case_id=args.case_id, + classification=args.classification, + reviewed_by=args.reviewed_by, + notes=args.notes, + selected_order_ref= + args.selected_order_ref, + selected_workorder_id= + args.selected_workorder_id, + ) + return 0 + + raise RuntimeError("Comando no soportado") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ucepsa/edge-oee-demo/tools/context_review_case_builder.py b/ucepsa/edge-oee-demo/tools/context_review_case_builder.py new file mode 100755 index 0000000..70d1af6 --- /dev/null +++ b/ucepsa/edge-oee-demo/tools/context_review_case_builder.py @@ -0,0 +1,987 @@ +#!/usr/bin/env python3 +"""Constructor persistente de casos raíz de revisión v0.3.10.""" + +from __future__ import annotations + +import argparse +import json +import os +import signal +import sys +import time +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any, Iterable + +import psycopg2 +from psycopg2.extras import RealDictCursor + + +VERSION = "0.3.10" +BUILDER_NAME = "context_review_case_builder_v0310" +POLICY_CODE = "CONTEXT_ROOT_CASE_QUEUE_V1" +RUNNING = True + + +def stop_handler(signum: int, frame: Any) -> None: + del signum, frame + global RUNNING + RUNNING = False + + +signal.signal(signal.SIGTERM, stop_handler) +signal.signal(signal.SIGINT, stop_handler) + + +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 + + +@dataclass(frozen=True) +class Config: + tenant: str + site: str + pg_host: str + pg_port: int + pg_database: str + pg_user: str + pg_password: str + + +def load_config() -> Config: + 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 Config( + tenant=env_first( + "TENANT_OVERRIDE", + "TENANT", + default="ucepsa", + ) + or "ucepsa", + site=env_first( + "SITE_OVERRIDE", + "SITE", + default="ucepsa_onpremise", + ) + or "ucepsa_onpremise", + pg_host=env_first( + "PGHOST", + default="mv_ucepsa_postgres_hot", + ) + or "mv_ucepsa_postgres_hot", + pg_port=int( + env_first("PGPORT", default="5432") + or "5432" + ), + pg_database=str(database), + pg_user=str(user), + pg_password=str(password), + ) + + +def connect(config: Config): + return psycopg2.connect( + host=config.pg_host, + port=config.pg_port, + dbname=config.pg_database, + user=config.pg_user, + password=config.pg_password, + connect_timeout=10, + application_name=BUILDER_NAME, + ) + + +def severity_rank(value: str | None) -> int: + return { + "WARNING": 1, + "CRITICAL": 2, + }.get(value or "", 0) + + +def case_family(episode: dict[str, Any]) -> str: + code = str(episode["incident_code"]) + + if code == "MULTIPLE_OPEN_SESSIONS": + return "MULTIPLE_OPEN_SESSIONS" + if code == "RUNNING_WITHOUT_SHOPFLOOR_SESSION": + return "UNCONFIRMED_ACTIVITY" + if code in { + "ORDER_WITHOUT_SHOPFLOOR_SESSION", + "RUNNING_WITH_ORDER_NO_SHOPFLOOR_SESSION", + }: + return "ORDER_WITHOUT_SESSION" + if code in { + "OPEN_SESSION_ORDER_MISMATCH", + "WORKORDER_SESSION_STATE_MISMATCH", + }: + return "ORDER_SESSION_MISMATCH" + if code.startswith("SHOPFLOOR_SYNC_"): + return "SYNC_HEALTH" + return code + + +def family_owner(family: str) -> str: + if family == "MULTIPLE_OPEN_SESSIONS": + return "JOINT" + if family in { + "UNCONFIRMED_ACTIVITY", + "ORDER_WITHOUT_SESSION", + }: + return "PRODUCTION" + return "TECHNICAL" + + +def family_title(family: str) -> str: + return { + "MULTIPLE_OPEN_SESSIONS": + "Varias órdenes Shop Floor abiertas en una máquina", + "UNCONFIRMED_ACTIVITY": + "Actividad de máquina sin contexto confirmado", + "ORDER_WITHOUT_SESSION": + "Orden Odoo sin sesión Shop Floor utilizable", + "ORDER_SESSION_MISMATCH": + "Orden Odoo y sesión Shop Floor no coinciden", + "SYNC_HEALTH": + "Problema de sincronización Shop Floor", + }.get( + family, + family.replace("_", " ").title(), + ) + + +def question_text( + family: str, + machine_id: str | None, + orders: list[str], +) -> str: + machine = machine_id or "la instalación" + order_text = ( + ", ".join(orders) + if orders + else "las órdenes implicadas" + ) + + if family == "MULTIPLE_OPEN_SESSIONS": + return ( + f"¿Cuál de {order_text} era la orden realmente " + f"ejecutada en {machine} y qué debe hacerse con " + "las demás?" + ) + if family == "UNCONFIRMED_ACTIVITY": + return ( + f"¿La actividad de {machine} correspondía a " + "sistema antiguo, prueba o ajuste, material " + "residual o un inicio omitido en Shop Floor?" + ) + if family == "ORDER_WITHOUT_SESSION": + return ( + f"¿La orden publicada en {machine} llegó a " + "iniciarse realmente en Shop Floor?" + ) + if family == "ORDER_SESSION_MISMATCH": + return ( + f"¿Qué orden estaba realmente ejecutándose en " + f"{machine}?" + ) + return ( + f"¿Cuál es la causa operativa del caso detectado " + f"en {machine}?" + ) + + +def recommended_action(family: str) -> str: + return { + "MULTIPLE_OPEN_SESSIONS": + "Confirmar la orden real y corregir en Odoo las órdenes abiertas por error.", + "UNCONFIRMED_ACTIVITY": + "Clasificar el intervalo y crear contexto explícito solo cuando exista confirmación humana.", + "ORDER_WITHOUT_SESSION": + "Confirmar si la orden era real o una transición breve de Odoo.", + "ORDER_SESSION_MISMATCH": + "Corregir Odoo o el mapeo antes de atribuir producción o paros.", + "SYNC_HEALTH": + "Resolver el problema técnico antes de utilizar datos nuevos.", + }.get( + family, + "Revisar la evidencia técnica y documentar una única resolución.", + ) + + +def episode_interval( + episode: dict[str, Any], +) -> tuple[datetime, datetime]: + start = episode["first_seen_at"] + end = ( + episode.get("ended_at") + or episode.get("last_seen_at") + or start + ) + return start, end + + +def cluster_episodes( + episodes: list[dict[str, Any]], + grouping_window_s: int, +) -> list[list[dict[str, Any]]]: + grouped: dict[ + tuple[str | None, str], + list[dict[str, Any]], + ] = {} + + for episode in episodes: + family = case_family(episode) + grouped.setdefault( + ( + episode.get("machine_id"), + family, + ), + [], + ).append(episode) + + clusters: list[list[dict[str, Any]]] = [] + grace = timedelta(seconds=grouping_window_s) + + for (_, family), family_episodes in grouped.items(): + ordered = sorted( + family_episodes, + key=lambda row: ( + row["first_seen_at"], + row["episode_id"], + ), + ) + + if family != "MULTIPLE_OPEN_SESSIONS": + clusters.extend([[row] for row in ordered]) + continue + + current: list[dict[str, Any]] = [] + current_end: datetime | None = None + + for row in ordered: + start, end = episode_interval(row) + + if ( + not current + or current_end is None + or start <= current_end + grace + ): + current.append(row) + current_end = ( + end + if current_end is None + else max(current_end, end) + ) + continue + + clusters.append(current) + current = [row] + current_end = end + + if current: + clusters.append(current) + + return clusters + + +def existing_case_for_cluster( + cursor, + episode_ids: list[int], +) -> int | None: + cursor.execute( + """ + SELECT DISTINCT case_id + FROM + mv_loss_intelligence + .context_review_case_evidence + WHERE episode_id = + ANY(%s::bigint[]) + ORDER BY case_id + """, + (episode_ids,), + ) + rows = cursor.fetchall() + + if len(rows) > 1: + raise RuntimeError( + "Las evidencias del mismo cluster ya pertenecen " + f"a varios casos: {rows!r}" + ) + + return ( + int(rows[0]["case_id"]) + if rows + else None + ) + + +def case_key( + family: str, + machine_id: str | None, + episode_ids: list[int], +) -> str: + first_episode = min(episode_ids) + + if family == "MULTIPLE_OPEN_SESSIONS": + return ( + "ROOT:MULTIPLE_OPEN_SESSIONS:" + f"{machine_id or 'GLOBAL'}:" + f"{first_episode}" + ) + + return f"ROOT:EPISODE:{first_episode}" + + +def mirrored_review_state( + cluster: list[dict[str, Any]], +) -> tuple[str, str | None, str | None, datetime | None, str | None]: + statuses = { + str(row["review_status"]) + for row in cluster + } + + if "PENDING" in statuses: + return "PENDING", None, None, None, None + + reviewed = [ + row for row in cluster + if row["review_status"] == "REVIEWED" + ] + if reviewed: + latest = max( + reviewed, + key=lambda row: ( + row.get("reviewed_at") + or row["last_seen_at"] + ), + ) + classification = latest.get( + "review_classification" + ) + mapped = ( + classification + if classification in { + "AUTHORIZED_LEGACY_PRODUCTION", + "TEST_OR_SETUP", + "RESIDUAL_MATERIAL", + "ODOO_START_OMITTED", + "DATA_ISSUE", + "NOT_RELEVANT", + "OTHER", + } + else "OTHER" + ) + return ( + "REVIEWED", + mapped, + latest.get("reviewed_by"), + latest.get("reviewed_at"), + latest.get("review_notes"), + ) + + if statuses == {"DISMISSED"}: + return ( + "DISMISSED", + "NOT_RELEVANT", + None, + None, + "Todas las evidencias estaban descartadas.", + ) + + return "NOT_REQUIRED", None, None, None, None + + +def evidence_role(episode: dict[str, Any]) -> str: + scope = episode.get("incident_scope") + + if scope == "MACHINE": + return "MACHINE_ROOT" + if scope == "SESSION": + return "SESSION_EVIDENCE" + return "PRIMARY_EPISODE" + + +def process_cycle( + config: Config, + dry_run: bool = False, +) -> dict[str, Any]: + with connect(config) as connection: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + cursor.execute( + """ + SELECT pg_try_advisory_xact_lock( + hashtext(%s) + ) AS locked + """, + ( + ( + f"{config.tenant}:" + f"{config.site}:" + f"{BUILDER_NAME}" + ), + ), + ) + if not cursor.fetchone()["locked"]: + return { + "version": VERSION, + "status": "skipped_lock_busy", + } + + cursor.execute( + """ + SELECT + builder_interval_s, + grouping_window_s, + history_lookback_days + FROM + mv_loss_intelligence + .context_review_case_policies + WHERE tenant = %s + AND site = %s + AND policy_code = %s + AND active + LIMIT 1 + """, + ( + config.tenant, + config.site, + POLICY_CODE, + ), + ) + policy = cursor.fetchone() + if not policy: + raise RuntimeError( + f"No existe política activa {POLICY_CODE!r}" + ) + + cursor.execute( + """ + SELECT * + FROM + mv_reports_ucepsa_prod + .li_shopfloor_context_incident_history_v1 + WHERE review_required + AND ( + episode_status = 'OPEN' + OR first_seen_at >= + now() + - ( + %s + * interval '1 day' + ) + ) + ORDER BY + machine_id NULLS FIRST, + first_seen_at, + episode_id + """, + ( + int( + policy["history_lookback_days"] + ), + ), + ) + episodes = [ + dict(row) + for row in cursor.fetchall() + ] + + clusters = cluster_episodes( + episodes, + int(policy["grouping_window_s"]), + ) + + preview = [] + link_count = 0 + + for cluster in clusters: + ids = sorted( + int(row["episode_id"]) + for row in cluster + ) + family = case_family(cluster[0]) + machine_id = cluster[0].get( + "machine_id" + ) + orders = sorted({ + str(row["production_order"]) + for row in cluster + if row.get("production_order") + }) + first_seen = min( + row["first_seen_at"] + for row in cluster + ) + last_seen = max( + row["last_seen_at"] + for row in cluster + ) + open_rows = [ + row for row in cluster + if row["episode_status"] == "OPEN" + ] + ended_at = ( + None + if open_rows + else max( + row.get("ended_at") + or row["last_seen_at"] + for row in cluster + ) + ) + max_severity = max( + ( + str(row["max_severity"]) + for row in cluster + ), + key=severity_rank, + ) + review_status, classification, reviewed_by, reviewed_at, notes = ( + mirrored_review_state(cluster) + ) + + existing_case_id = existing_case_for_cluster( + cursor, + ids, + ) + key = case_key( + family, + machine_id, + ids, + ) + + item = { + "case_id": existing_case_id, + "case_key": key, + "case_family": family, + "machine_id": machine_id, + "episode_ids": ids, + "case_status": ( + "OPEN" + if open_rows + else "CLOSED" + ), + "first_seen_at": first_seen, + "last_seen_at": last_seen, + "ended_at": ended_at, + "max_severity": max_severity, + "review_status": review_status, + "orders": orders, + } + preview.append(item) + + if dry_run: + continue + + owner = family_owner(family) + title = family_title(family) + question = question_text( + family, + machine_id, + orders, + ) + action = recommended_action(family) + + if existing_case_id is None: + cursor.execute( + """ + INSERT INTO + mv_loss_intelligence + .context_review_cases ( + tenant, + site, + case_key, + case_family, + root_incident_code, + machine_id, + case_title, + question_text, + recommended_action, + case_status, + max_severity, + first_seen_at, + last_seen_at, + ended_at, + evidence_count, + review_owner, + review_required, + review_status, + resolution_classification, + reviewed_by, + reviewed_at, + review_notes, + official_eligible + ) + VALUES ( + %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, + %s, %s, %s, 0, + %s, true, %s, + %s, %s, %s, %s, false + ) + RETURNING case_id + """, + ( + config.tenant, + config.site, + key, + family, + str( + cluster[0][ + "incident_code" + ] + ), + machine_id, + title, + question, + action, + ( + "OPEN" + if open_rows + else "CLOSED" + ), + max_severity, + first_seen, + last_seen, + ended_at, + owner, + review_status, + classification, + reviewed_by, + reviewed_at, + notes, + ), + ) + case_id = int( + cursor.fetchone()["case_id"] + ) + else: + case_id = existing_case_id + cursor.execute( + """ + UPDATE + mv_loss_intelligence + .context_review_cases + SET + case_family = %s, + root_incident_code = %s, + machine_id = %s, + case_title = %s, + question_text = %s, + recommended_action = %s, + case_status = %s, + max_severity = %s, + first_seen_at = LEAST( + first_seen_at, + %s + ), + last_seen_at = GREATEST( + last_seen_at, + %s + ), + ended_at = %s, + review_owner = %s, + updated_at = now() + WHERE case_id = %s + """, + ( + family, + str( + cluster[0][ + "incident_code" + ] + ), + machine_id, + title, + question, + action, + ( + "OPEN" + if open_rows + else "CLOSED" + ), + max_severity, + first_seen, + last_seen, + ended_at, + owner, + case_id, + ), + ) + + for row in cluster: + cursor.execute( + """ + INSERT INTO + mv_loss_intelligence + .context_review_case_evidence ( + case_id, + episode_id, + evidence_role + ) + VALUES (%s, %s, %s) + ON CONFLICT (episode_id) + DO UPDATE SET + evidence_role = + EXCLUDED.evidence_role + WHERE + mv_loss_intelligence + .context_review_case_evidence + .case_id = + EXCLUDED.case_id + """, + ( + case_id, + int(row["episode_id"]), + evidence_role(row), + ), + ) + link_count += cursor.rowcount + + cursor.execute( + """ + UPDATE + mv_loss_intelligence + .context_review_cases c + SET + evidence_count = ( + SELECT COUNT(*) + FROM + mv_loss_intelligence + .context_review_case_evidence e + WHERE e.case_id = + c.case_id + ), + updated_at = now() + WHERE c.case_id = %s + """, + (case_id,), + ) + + if dry_run: + return { + "version": VERSION, + "status": "dry_run", + "episode_count": len(episodes), + "case_count": len(clusters), + "cases": preview, + "builder_interval_s": int( + policy["builder_interval_s"] + ), + } + + cursor.execute( + """ + INSERT INTO + mv_loss_intelligence + .context_review_case_builder_state ( + tenant, + site, + builder_name, + last_success_at, + last_episode_count, + last_case_count, + last_link_count, + last_error, + updated_at + ) + VALUES ( + %s, %s, %s, now(), + %s, %s, %s, NULL, now() + ) + ON CONFLICT ( + tenant, + site, + builder_name + ) + DO UPDATE SET + last_success_at = + EXCLUDED.last_success_at, + last_episode_count = + EXCLUDED.last_episode_count, + last_case_count = + EXCLUDED.last_case_count, + last_link_count = + EXCLUDED.last_link_count, + last_error = NULL, + updated_at = now() + """, + ( + config.tenant, + config.site, + BUILDER_NAME, + len(episodes), + len(clusters), + link_count, + ), + ) + + return { + "version": VERSION, + "status": "ok", + "episode_count": len(episodes), + "case_count": len(clusters), + "link_count": link_count, + "builder_interval_s": int( + policy["builder_interval_s"] + ), + } + + +def record_error( + config: Config, + error: Exception, +) -> None: + try: + with connect(config) as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO + mv_loss_intelligence + .context_review_case_builder_state ( + tenant, + site, + builder_name, + last_error, + updated_at + ) + VALUES ( + %s, %s, %s, %s, now() + ) + ON CONFLICT ( + tenant, + site, + builder_name + ) + DO UPDATE SET + last_error = + EXCLUDED.last_error, + updated_at = now() + """, + ( + config.tenant, + config.site, + BUILDER_NAME, + str(error)[:4000], + ), + ) + except Exception as state_error: + print( + json.dumps( + { + "status": + "builder_error_state_write_failed", + "error": str(error), + "state_error": str(state_error), + }, + ensure_ascii=False, + ), + file=sys.stderr, + flush=True, + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Construye casos raíz SHADOW a partir " + "de episodios técnicos." + ) + ) + mode = parser.add_mutually_exclusive_group( + required=True + ) + mode.add_argument("--once", action="store_true") + mode.add_argument("--watch", action="store_true") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--interval", type=int) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + config = load_config() + + while RUNNING: + try: + result = process_cycle( + config, + dry_run=args.dry_run, + ) + print( + json.dumps( + result, + ensure_ascii=False, + default=str, + ), + flush=True, + ) + + if args.once or args.dry_run: + return 0 + + interval = ( + args.interval + or int( + result.get( + "builder_interval_s", + 15, + ) + ) + ) + for _ in range(max(interval, 1)): + if not RUNNING: + break + time.sleep(1) + + except Exception as error: + record_error(config, error) + print( + json.dumps( + { + "version": VERSION, + "status": "error", + "error": str(error), + }, + ensure_ascii=False, + ), + file=sys.stderr, + flush=True, + ) + if args.once or args.dry_run: + return 1 + + for _ in range( + max(args.interval or 15, 1) + ): + if not RUNNING: + break + time.sleep(1) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())