feat(ucepsa): add root context review case queue shadow

This commit is contained in:
Victor Fraile Garcia 2026-07-20 17:28:10 +02:00
parent 9dccf500e2
commit 1fca1d6232
8 changed files with 3921 additions and 7 deletions

View File

@ -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 <id>
```
Cuando Juan Pablo confirme la orden real del conflicto:
```bash
"$MANAGER" admin review \
--case-id <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.

View File

@ -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, "editable": true,
"fiscalYearStartMonth": 0, "fiscalYearStartMonth": 0,
"graphTooltip": 1, "graphTooltip": 1,
@ -1384,7 +1384,7 @@
"refId": "A" "refId": "A"
} }
], ],
"title": "Incidencias pendientes de revisión", "title": "Evidencias técnicas pendientes · auditoría",
"type": "table" "type": "table"
}, },
{ {
@ -2045,11 +2045,11 @@
"editorMode": "code", "editorMode": "code",
"format": "table", "format": "table",
"rawQuery": true, "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" "refId": "A"
} }
], ],
"title": "Readiness de contexto por máquina · ventana de 7 días", "title": "Readiness por máquina · casos raíz",
"type": "table" "type": "table"
}, },
{ {
@ -2166,7 +2166,7 @@
"editorMode": "code", "editorMode": "code",
"format": "table", "format": "table",
"rawQuery": true, "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" "refId": "A"
} }
], ],
@ -2231,12 +2231,384 @@
"editorMode": "code", "editorMode": "code",
"format": "table", "format": "table",
"rawQuery": true, "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" "refId": "A"
} }
], ],
"title": "Resumen de readiness del contexto canónico", "title": "Resumen de readiness del contexto canónico",
"type": "table" "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", "refresh": "15s",
@ -2255,7 +2627,10 @@
"v038", "v038",
"readiness", "readiness",
"rollout-gate", "rollout-gate",
"v039" "v039",
"root-cases",
"human-governance",
"v0310"
], ],
"templating": { "templating": {
"list": [] "list": []

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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())

View File

@ -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())