feat(ucepsa): consolidate root cases into human decisions shadow

This commit is contained in:
Victor Fraile Garcia 2026-07-20 17:56:33 +02:00
parent 1fca1d6232
commit 72c57f4ce9
7 changed files with 2952 additions and 6 deletions

View File

@ -0,0 +1,160 @@
# UCEPSA — Decisiones humanas consolidadas SHADOW v0.3.11
## Objetivo
Reducir la carga de revisión:
```text
10 episodios técnicos
→ 7 casos raíz
→ aproximadamente 3 decisiones humanas
```
No se elimina ningún intervalo, caso o evidencia.
## Agrupación inicial
### Actividad sin contexto
```text
misma máquina
+ mismo día local Europe/Madrid
+ separación <= 15 min
→ una decisión humana
```
### Conflictos de arranque Odoo
```text
MULTIPLE_OPEN_SESSIONS
+ ORDER_WITHOUT_SESSION
+ misma máquina
+ separación <= 5 min
→ una decisión humana
```
No se inventan turnos horarios porque todavía no existe un calendario de turnos
confirmado. El día local actúa como frontera conservadora.
## Casos esperados
```text
CORT-00 casos 1/2/3/4
→ una decisión sobre sistema antiguo
CORT-00 casos 5/6
→ una decisión WH/MO/00660 vs WH/MO/00706
CORT-02 casos 7/8
→ una decisión sobre actividad posterior sin sesión
```
## Revisión
La revisión de una decisión:
- registra una única respuesta humana;
- revisa únicamente los casos raíz pendientes;
- revisa únicamente los episodios pendientes;
- conserva las revisiones ya existentes;
- no modifica Odoo;
- no crea contextos retrospectivos automáticamente;
- no habilita el Ledger oficial.
## Despliegue
```bash
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/113_ucepsa_context_decision_groups_shadow_v0311.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$FILE"
```
No hay servicio nuevo: la agrupación es determinista mediante vistas SQL.
## Runtime
```bash
RUNTIME=/srv/mesavault/edge-oee-ucepsa
TARGET=/srv/mesavault/40-clients/ucepsa/edge-oee-demo
install -m 0644 \
"$TARGET/tools/context_decision_group_admin.py" \
"$RUNTIME/tools/"
```
## Validación
```bash
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_context_decision_groups_v0311.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
root_case_assignment_duplicates = 0
recent_root_cases_without_decision_group = 0
decision_group_count_1_2_3_4 = 1
linked_case_count_1_2_3_4 = 4
decision_group_count_5_6 = 1
linked_case_count_5_6 = 2
decision_group_count_7_8 = 1
linked_case_count_7_8 = 2
decision_group_count_exceeds_root_case_count = 0
ready_v3_gate_mismatches = 0
official_decision_group_rows = 0
official_gate_rows = 0
official_operator_queue_rows = 0
```
## Consola
```bash
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_context_decision_groups_v0311.sh
"$MANAGER" list --limit 50
"$MANAGER" show \
--decision-group-key 'DECISION:...'
```
No revisar el grupo de doble arranque hasta recibir la respuesta de Juan Pablo.
Cuando responda:
```bash
"$MANAGER" review \
--decision-group-key 'DECISION:ODOO_ORDER_START_CONFLICT:CORT-00:20260720:5' \
--classification ODOO_DOUBLE_START_ONE_VALID \
--selected-order-ref "WH/MO/00706" \
--selected-workorder-id 698 \
--reviewed-by "Juan Pablo" \
--notes "Confirma la orden real y la orden abierta por error."
```
## Dashboard
Se mantiene el mismo UID y se añaden:
```text
Decisiones humanas pendientes
Casos raíz incluidos en cada decisión
Historial de decisiones humanas
```
Readiness v3 compara:
```text
evidencias pendientes
casos raíz pendientes
decisiones humanas pendientes
```

View File

@ -15,7 +15,7 @@
}
]
},
"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.",
"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.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
@ -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_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",
"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_decision_group_count\n AS \"Decisiones pendientes\",\n pending_root_case_count\n AS \"Casos raíz pendientes\",\n pending_review_count\n AS \"Evidencias pendientes\",\n oldest_pending_decision_group_hours\n AS \"Antigüedad decisió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_v3\nORDER BY machine_id",
"refId": "A"
}
],
"title": "Readiness por máquina · casos raíz",
"title": "Readiness por máquina · decisiones humanas",
"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_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",
"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_v3\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,7 +2231,7 @@
"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\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",
"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_decision_group_count\n AS \"Decisiones pendientes\",\n pending_root_case_count\n AS \"Casos raíz pendientes\",\n pending_episode_review_count\n AS \"Evidencias 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_v3",
"refId": "A"
}
],
@ -2609,6 +2609,378 @@
],
"title": "Historial de casos raíz · ú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"
}
},
{
"id": "custom.width",
"value": 110
}
]
},
{
"matcher": {
"id": "byName",
"options": "Pregunta operativa"
},
"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": 11,
"w": 24,
"x": 0,
"y": 157
},
"id": 23,
"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 decision_group_key AS \"Decisión\",\n CASE decision_status\n WHEN 'OPEN' THEN 'Abierta'\n ELSE 'Cerrada'\n END AS \"Estado\",\n max_severity AS \"Severidad\",\n machine_id AS \"Máquina\",\n decision_title AS \"Pregunta operativa\",\n array_to_string(\n root_case_ids,\n ', '\n ) AS \"Casos 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 pending_root_case_count\n AS \"Casos pendientes\",\n reviewed_root_case_count\n AS \"Casos revisados\",\n array_to_string(\n known_resolution_classifications,\n ', '\n ) AS \"Resolución conocida\",\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 decision_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_decision_group_backlog_v1\nORDER BY backlog_rank\nLIMIT 50",
"refId": "A"
}
],
"title": "Decisiones humanas pendientes",
"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": "Caso"
},
"properties": [
{
"id": "custom.width",
"value": 400
}
]
}
]
},
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 168
},
"id": 24,
"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 decision_case_role AS \"Rol\",\n case_id AS \"Caso raíz\",\n case_title AS \"Caso\",\n case_status AS \"Estado\",\n case_severity AS \"Severidad\",\n first_seen_at AS \"Inicio\",\n ended_at AS \"Fin\",\n case_duration_min AS \"Duración min\",\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_status AS \"Revisión\",\n COALESCE(\n case_resolution_classification,\n '—'\n ) AS \"Resolución\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_decision_group_cases_v1\nWHERE decision_review_status =\n 'PENDING'\nORDER BY\n decision_group_key,\n case_id\nLIMIT 200",
"refId": "A"
}
],
"title": "Casos raíz incluidos en cada 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": [
{
"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": "Pregunta operativa"
},
"properties": [
{
"id": "custom.width",
"value": 420
}
]
}
]
},
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 178
},
"id": 25,
"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 CASE decision_status\n WHEN 'OPEN' THEN 'Abierta'\n ELSE 'Cerrada'\n END AS \"Estado\",\n max_severity AS \"Severidad\",\n machine_id AS \"Máquina\",\n decision_title AS \"Pregunta operativa\",\n first_seen_at AS \"Inicio\",\n ended_at AS \"Fin\",\n decision_duration_min AS \"Duración min\",\n linked_root_case_count AS \"Casos raíz\",\n pending_root_case_count AS \"Pendientes\",\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_decision_group_history_v1\nWHERE first_seen_at >=\n now() - interval '7 days'\nORDER BY first_seen_at DESC\nLIMIT 100",
"refId": "A"
}
],
"title": "Historial de decisiones humanas · últimos 7 días",
"type": "table"
}
],
"refresh": "15s",
@ -2630,7 +3002,10 @@
"v039",
"root-cases",
"human-governance",
"v0310"
"v0310",
"decision-groups",
"temporal-consolidation",
"v0311"
],
"templating": {
"list": []

View File

@ -0,0 +1,275 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-deploy}"
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
REPO_ROOT="${DECISION_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
DASHBOARD_FILE="${DECISION_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
FOLDER_UID="${DECISION_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 decision groups "
"SHADOW v0.3.11"
),
},
)
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", [])) != 25:
errors.append(
"El dashboard no tiene 25 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 = {
23: "li_context_decision_group_backlog_v1",
24: "li_context_decision_group_cases_v1",
25: "li_context_decision_group_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} no consulta {fragment}"
)
readiness_sql = (
panels.get(17, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
if (
"li_canonical_context_readiness_gate_v3"
not in readiness_sql
):
errors.append(
"El panel readiness no usa gate v3"
)
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"),
"decision_group_panels": {
"backlog": 23 in panels,
"root_cases": 24 in panels,
"history": 25 in panels,
"readiness_v3":
"li_canonical_context_readiness_gate_v3"
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_decision_groups_dashboard_v0311.sh "
"{deploy|validate}"
)
PY

View File

@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-list}"
ROOT="${CONTEXT_DECISION_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
NETWORK="${CONTEXT_DECISION_NETWORK:-mv_ucepsa_net}"
POSTGRES_CONTAINER="${CONTEXT_DECISION_POSTGRES_CONTAINER:-mv_ucepsa_postgres_hot}"
TENANT_OVERRIDE="${CONTEXT_DECISION_TENANT_OVERRIDE:-ucepsa}"
SITE_OVERRIDE="${CONTEXT_DECISION_SITE_OVERRIDE:-ucepsa_onpremise}"
PGHOST_OVERRIDE="${CONTEXT_DECISION_PGHOST_OVERRIDE:-mv_ucepsa_postgres_hot}"
cd "$ROOT"
IMAGE="$(
docker inspect \
mv_ucepsa_odoo_order_state_pg_sink \
--format '{{.Config.Image}}'
)"
PGDATABASE_OVERRIDE="${CONTEXT_DECISION_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_decision_group_admin.py \
"$ACTION" \
"$@"

View File

@ -0,0 +1,221 @@
\pset pager off
\echo '=== 1. Objetos v0.3.11 ==='
SELECT
to_regclass(
'mv_loss_intelligence.context_decision_group_policies'
) AS policy_table,
to_regclass(
'mv_loss_intelligence.context_decision_group_reviews'
) AS reviews_table,
to_regclass(
'mv_reports_ucepsa_prod.li_context_decision_group_case_assignments_v1'
) AS assignments_view,
to_regclass(
'mv_reports_ucepsa_prod.li_context_decision_group_history_v1'
) AS history_view,
to_regclass(
'mv_reports_ucepsa_prod.li_context_decision_group_backlog_v1'
) AS backlog_view,
to_regclass(
'mv_reports_ucepsa_prod.li_context_decision_group_cases_v1'
) AS cases_view,
to_regclass(
'mv_reports_ucepsa_prod.li_canonical_context_readiness_gate_v3'
) AS readiness_v3_view;
\echo '=== 2. Política de consolidación ==='
SELECT
source_case_family,
decision_family,
max_gap_s,
require_same_local_day,
review_owner,
timezone_name,
active
FROM
mv_loss_intelligence
.context_decision_group_policies
WHERE tenant = 'ucepsa'
AND site = 'ucepsa_onpremise'
AND policy_code =
'CONTEXT_DECISION_GROUPS_V1'
ORDER BY source_case_family;
\echo '=== 3. Cada caso raíz aparece una sola vez ==='
SELECT COUNT(*) AS root_case_assignment_duplicates
FROM (
SELECT
case_id,
COUNT(*) AS n
FROM
mv_reports_ucepsa_prod
.li_context_decision_group_case_assignments_v1
GROUP BY case_id
HAVING COUNT(*) > 1
) duplicated;
\echo '=== 4. Casos recientes sin decisión consolidada ==='
SELECT COUNT(*) AS recent_root_cases_without_decision_group
FROM
mv_reports_ucepsa_prod
.li_context_review_case_history_v1 c
WHERE c.review_required
AND (
c.case_status = 'OPEN'
OR c.first_seen_at >=
now() - interval '30 days'
)
AND NOT EXISTS (
SELECT 1
FROM
mv_reports_ucepsa_prod
.li_context_decision_group_case_assignments_v1 a
WHERE a.case_id =
c.case_id
);
\echo '=== 5. CORT-00 actividad sin contexto: casos 1/2/3/4 ==='
SELECT
COUNT(
DISTINCT decision_group_key
) AS decision_group_count_1_2_3_4,
COUNT(*) AS linked_case_count_1_2_3_4
FROM
mv_reports_ucepsa_prod
.li_context_decision_group_case_assignments_v1
WHERE case_id IN (
1,
2,
3,
4
);
\echo '=== 6. CORT-00 doble arranque: casos 5/6 ==='
SELECT
COUNT(
DISTINCT decision_group_key
) AS decision_group_count_5_6,
COUNT(*) AS linked_case_count_5_6
FROM
mv_reports_ucepsa_prod
.li_context_decision_group_case_assignments_v1
WHERE case_id IN (
5,
6
);
\echo '=== 7. CORT-02 actividad posterior: casos 7/8 ==='
SELECT
COUNT(
DISTINCT decision_group_key
) AS decision_group_count_7_8,
COUNT(*) AS linked_case_count_7_8
FROM
mv_reports_ucepsa_prod
.li_context_decision_group_case_assignments_v1
WHERE case_id IN (
7,
8
);
\echo '=== 8. Decisiones humanas pendientes ==='
SELECT
backlog_rank,
decision_group_key,
decision_status,
max_severity,
machine_id,
decision_family,
decision_title,
root_case_ids,
production_orders,
odoo_workorder_ids,
pending_root_case_count,
reviewed_root_case_count,
known_resolution_classifications,
review_owner,
review_status,
question_text
FROM
mv_reports_ucepsa_prod
.li_context_decision_group_backlog_v1
ORDER BY backlog_rank
LIMIT 50;
\echo '=== 9. Comparación de carga humana ==='
SELECT
machine_id,
pending_review_count
AS pending_episode_review_count,
pending_root_case_count,
pending_decision_group_count,
pending_critical_decision_group_count,
oldest_pending_decision_group_hours,
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_v3
ORDER BY machine_id;
\echo '=== 10. Consolidación nunca aumenta la carga humana ==='
SELECT COUNT(*) AS decision_group_count_exceeds_root_case_count
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_gate_v3
WHERE pending_decision_group_count >
pending_root_case_count;
\echo '=== 11. READY v3 coherente ==='
SELECT COUNT(*) AS ready_v3_gate_mismatches
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_gate_v3
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 '=== 12. Nada oficial ==='
SELECT
(
SELECT COUNT(*)
FROM
mv_reports_ucepsa_prod
.li_context_decision_group_history_v1
WHERE official_eligible
) AS official_decision_group_rows,
(
SELECT COUNT(*)
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_gate_v3
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;

View File

@ -0,0 +1,650 @@
#!/usr/bin/env python3
"""Administración de decisiones humanas consolidadas v0.3.11."""
from __future__ import annotations
import argparse
import json
import os
from datetime import date, datetime
from decimal import Decimal
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_decision_group_admin_v0311"
),
)
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 list_groups(limit: int) -> None:
with connect() as connection:
with connection.cursor(
cursor_factory=RealDictCursor,
) as cursor:
cursor.execute(
"""
SELECT
backlog_rank,
decision_group_key,
decision_status,
max_severity,
first_seen_at,
last_seen_at,
decision_duration_min,
machine_id,
decision_title,
root_case_ids,
production_orders,
odoo_workorder_ids,
linked_root_case_count,
pending_root_case_count,
reviewed_root_case_count,
known_resolution_classifications,
review_owner,
review_status,
question_text,
recommended_action
FROM
mv_reports_ucepsa_prod
.li_context_decision_group_backlog_v1
ORDER BY backlog_rank
LIMIT %s
""",
(limit,),
)
print_json(
[dict(row) for row in cursor.fetchall()]
)
def show_group(decision_group_key: str) -> None:
with connect() as connection:
with connection.cursor(
cursor_factory=RealDictCursor,
) as cursor:
cursor.execute(
"""
SELECT *
FROM
mv_reports_ucepsa_prod
.li_context_decision_group_history_v1
WHERE decision_group_key = %s
""",
(decision_group_key,),
)
group = cursor.fetchone()
if not group:
raise RuntimeError(
"No existe la decisión "
f"{decision_group_key!r}"
)
cursor.execute(
"""
SELECT *
FROM
mv_reports_ucepsa_prod
.li_context_decision_group_cases_v1
WHERE decision_group_key = %s
ORDER BY case_id
""",
(decision_group_key,),
)
cases = [
dict(row)
for row in cursor.fetchall()
]
cursor.execute(
"""
SELECT *
FROM
mv_loss_intelligence
.context_decision_group_reviews
WHERE tenant = %s
AND site = %s
AND decision_group_key = %s
ORDER BY reviewed_at, review_id
""",
(
group["tenant"],
group["site"],
decision_group_key,
),
)
reviews = [
dict(row)
for row in cursor.fetchall()
]
print_json({
"decision_group": dict(group),
"root_cases": cases,
"reviews": reviews,
})
def episode_classification(
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[classification]
def review_group(
decision_group_key: str,
classification: str,
reviewed_by: str,
notes: str | None,
selected_order_ref: str | None,
selected_workorder_id: int | None,
) -> None:
if (
classification ==
"ODOO_DOUBLE_START_ONE_VALID"
and (
not selected_order_ref
or selected_workorder_id is None
)
):
raise RuntimeError(
"ODOO_DOUBLE_START_ONE_VALID exige "
"--selected-order-ref y "
"--selected-workorder-id."
)
new_status = (
"DISMISSED"
if classification == "NOT_RELEVANT"
else "REVIEWED"
)
episode_status = new_status
episode_class = episode_classification(
classification
)
with connect() as connection:
with connection.cursor(
cursor_factory=RealDictCursor,
) as cursor:
cursor.execute(
"""
SELECT *
FROM
mv_reports_ucepsa_prod
.li_context_decision_group_history_v1
WHERE decision_group_key = %s
""",
(decision_group_key,),
)
group = cursor.fetchone()
if not group:
raise RuntimeError(
"No existe la decisión "
f"{decision_group_key!r}"
)
if group["review_status"] != "PENDING":
raise RuntimeError(
"La decisión ya no está pendiente: "
f"{group['review_status']}"
)
cursor.execute(
"""
INSERT INTO
mv_loss_intelligence
.context_decision_group_reviews (
tenant,
site,
decision_group_key,
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, %s, %s
)
RETURNING *
""",
(
group["tenant"],
group["site"],
decision_group_key,
Json(json_safe(dict(group))),
new_status,
classification,
selected_order_ref,
selected_workorder_id,
reviewed_by,
notes,
Json({
"source":
"context_decision_group_admin_v0311",
"cascade_to_root_cases":
True,
"cascade_to_episodes":
True,
}),
),
)
group_review = dict(
cursor.fetchone()
)
cursor.execute(
"""
SELECT *
FROM
mv_reports_ucepsa_prod
.li_context_decision_group_cases_v1
WHERE decision_group_key = %s
ORDER BY case_id
""",
(decision_group_key,),
)
cases = [
dict(row)
for row in cursor.fetchall()
]
cascaded_cases = []
cascaded_episodes = []
for case in cases:
if (
case["case_review_status"]
!= "PENDING"
):
continue
case_notes = (
f"Decisión consolidada "
f"{decision_group_key}: "
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
.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 review_id
""",
(
case["case_id"],
Json(json_safe({
"review_status":
case[
"case_review_status"
],
"resolution_classification":
case[
"case_resolution_classification"
],
"reviewed_by":
case["case_reviewed_by"],
"reviewed_at":
case["case_reviewed_at"],
"review_notes":
case["case_review_notes"],
})),
new_status,
classification,
selected_order_ref,
selected_workorder_id,
reviewed_by,
case_notes,
Json({
"source_decision_group_key":
decision_group_key,
}),
),
)
cascaded_cases.append({
"case_id": case["case_id"],
"review_id":
cursor.fetchone()["review_id"],
})
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["case_id"],),
)
episodes = [
dict(row)
for row in cursor.fetchall()
]
for episode in episodes:
if (
episode["review_status"]
!= "PENDING"
):
continue
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(json_safe({
"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,
case_notes,
Json({
"source_decision_group_key":
decision_group_key,
"source_case_id":
case["case_id"],
"root_classification":
classification,
}),
),
)
cascaded_episodes.append({
"episode_id":
episode["episode_id"],
"review_id":
cursor.fetchone()[
"review_id"
],
})
cursor.execute(
"""
SELECT *
FROM
mv_reports_ucepsa_prod
.li_context_decision_group_history_v1
WHERE decision_group_key = %s
""",
(decision_group_key,),
)
current = dict(cursor.fetchone())
print_json({
"decision_group_review":
group_review,
"decision_group":
current,
"cascaded_root_case_reviews":
cascaded_cases,
"cascaded_episode_reviews":
cascaded_episodes,
"warning": (
"La decisión consolida la revisión. "
"No modifica Odoo, no crea contextos "
"retrospectivos automáticamente y no "
"habilita el Ledger oficial."
),
})
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Administración de decisiones "
"humanas consolidadas."
)
)
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(
"--decision-group-key",
required=True,
)
review_cmd = sub.add_parser("review")
review_cmd.add_argument(
"--decision-group-key",
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_groups(args.limit)
return 0
if args.command == "show":
show_group(
args.decision_group_key
)
return 0
if args.command == "review":
review_group(
decision_group_key=
args.decision_group_key,
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())