feat(ucepsa): add daily governance agenda shadow

This commit is contained in:
Victor Fraile Garcia 2026-07-20 18:08:32 +02:00
parent 72c57f4ce9
commit b06f15534f
7 changed files with 2246 additions and 2 deletions

View File

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

View File

@ -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, "editable": true,
"fiscalYearStartMonth": 0, "fiscalYearStartMonth": 0,
"graphTooltip": 1, "graphTooltip": 1,
@ -2981,6 +2981,311 @@
], ],
"title": "Historial de decisiones humanas · últimos 7 días", "title": "Historial de decisiones humanas · últimos 7 días",
"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"
}
}
]
},
{
"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", "refresh": "15s",
@ -3005,7 +3310,11 @@
"v0310", "v0310",
"decision-groups", "decision-groups",
"temporal-consolidation", "temporal-consolidation",
"v0311" "v0311",
"daily-governance",
"agenda",
"evidence-pack",
"v0312"
], ],
"templating": { "templating": {
"list": [] "list": []

View 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

View 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" \
"$@"

View File

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

View File

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

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