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