#!/usr/bin/env python3
"""Generate public semantic exports from canonical Markdown/YAML records."""
from __future__ import annotations
import argparse
import datetime
import json
import pathlib
import re
import sys
import xml.sax.saxutils
from typing import Any
import yaml
ROOT = pathlib.Path(__file__).resolve().parent.parent
PUBLIC_BASE = "https://ateeducacion.github.io/normativa_educativa_canaria/"
SCHEMA_CONTEXT = "https://schema.org/"
START_MARKER = ""
END_MARKER = ""
FRONTMATTER = re.compile(r"\A---\n(.*?)\n---", re.S)
LEGISLATION_PATH = ROOT / "docs" / "datos" / "legislacion.jsonld"
CURRICULA_PATH = ROOT / "docs" / "datos" / "curriculos.jsonld"
SOURCES_PATH = ROOT / "docs" / "datos" / "fuentes.jsonld"
CATALOG_PATH = ROOT / "docs" / "datos" / "catalogo.jsonld"
AKN_DIR = ROOT / "docs" / "datos" / "akoma-ntoso"
PILOT_PATH = ROOT / "11_calidad" / "akoma_ntoso_pilotos.yaml"
INDEX_HTML = ROOT / "docs" / "index.html"
STAGE_LABELS = {
"infantil": "Educación Infantil",
"primaria": "Educación Primaria",
"eso": "Educación Secundaria Obligatoria",
"bachillerato": "Bachillerato",
"formacion-profesional": "Formación Profesional",
"regimen-especial": "Régimen especial",
}
def normalize(value: Any) -> Any:
"""Convert PyYAML dates to the JSON-compatible ISO representation."""
if isinstance(value, (datetime.datetime, datetime.date)):
return value.isoformat()
if isinstance(value, dict):
return {key: normalize(item) for key, item in value.items()}
if isinstance(value, list):
return [normalize(item) for item in value]
return value
def load_frontmatter(path: pathlib.Path) -> dict[str, Any]:
"""Load the YAML object that opens a Markdown ficha."""
match = FRONTMATTER.match(path.read_text(encoding="utf-8"))
if match is None:
raise ValueError(f"{path.relative_to(ROOT)} no contiene frontmatter YAML")
data = normalize(yaml.safe_load(match.group(1)))
if not isinstance(data, dict):
raise ValueError(f"{path.relative_to(ROOT)} no contiene un objeto YAML")
return data
def load_yaml_document(path: pathlib.Path) -> dict[str, Any]:
"""Load a standalone YAML document and require a mapping."""
data = normalize(yaml.safe_load(path.read_text(encoding="utf-8")))
if not isinstance(data, dict):
raise ValueError(f"{path.relative_to(ROOT)} no contiene un objeto YAML")
return data
def load_norms() -> list[tuple[pathlib.Path, dict[str, Any]]]:
"""Load normative frontmatter and retain its canonical repository path."""
return [
(path, load_frontmatter(path))
for path in sorted(ROOT.glob("02_normativa/**/NOR-*.md"))
]
def load_curricula() -> list[tuple[pathlib.Path, dict[str, Any]]]:
"""Load canonical curricular YAML records."""
return [
(path, load_yaml_document(path))
for path in sorted(ROOT.glob("03_curriculos/**/CUR-*.yaml"))
]
def load_sources() -> list[tuple[pathlib.Path, dict[str, Any]]]:
"""Load official-source frontmatter."""
return [
(path, load_frontmatter(path))
for path in sorted(ROOT.glob("01_fuentes/**/FTE-*.md"))
]
def public_url(path: pathlib.Path) -> str:
"""Return a stable GitHub Pages URL for a repository path."""
return PUBLIC_BASE + path.relative_to(ROOT).as_posix()
def legislation_ref(identifier: str, paths_by_id: dict[str, pathlib.Path]) -> dict[str, str]:
"""Build an internal semantic reference without inventing an official URI."""
path = paths_by_id.get(identifier)
if path is None:
raise ValueError(f"La relación apunta a una norma inexistente: {identifier}")
return {"@id": public_url(path), "@type": "Legislation"}
def legal_force(status: str) -> str | None:
"""Map the controlled status prefix to Schema.org legal-force terms."""
if status.startswith(("Derogada parcialmente", "Parcialmente vigente")):
return "https://schema.org/PartiallyInForce"
if status.startswith(("Derogada", "Superada", "Sustituida", "Histórica")):
return "https://schema.org/NotInForce"
if status.startswith("Vigente"):
return "https://schema.org/InForce"
return None
def xml_text(value: str) -> str:
"""Escape text for Akoma Ntoso content."""
return xml.sax.saxutils.escape(value, {"\"": """})
def build_legislation() -> dict[str, Any]:
"""Build a Schema.org Legislation graph for every canonical normative record."""
norms = load_norms()
paths_by_id = {data["id"]: path for path, data in norms}
graph = []
for path, data in norms:
identifiers: list[Any] = [
{
"@type": "PropertyValue",
"propertyID": "identificador interno",
"value": data["id"],
}
]
if data.get("uri_eli"):
identifiers.append(
{
"@type": "PropertyValue",
"propertyID": "ELI",
"value": data["uri_eli"],
}
)
item: dict[str, Any] = {
"@id": public_url(path),
"@type": "Legislation",
"name": data["titulo"],
"alternateName": data["nombre_corto"],
"identifier": identifiers,
"legislationIdentifier": [data["id"]]
+ ([data["uri_eli"]] if data.get("uri_eli") else []),
"legislationType": data["tipo_norma"],
"legislationJurisdiction": {
"@type": "AdministrativeArea",
"name": "Comunidad Autónoma de Canarias"
if data["ambito"] == "canarias"
else data["ambito"],
},
"legislationPassedBy": {
"@type": "Organization",
"name": data["autoridad"],
},
"inLanguage": "es",
"url": public_url(path),
"sameAs": list(
dict.fromkeys(
[data["url_oficial"]]
+ ([data["uri_eli"]] if data.get("uri_eli") else [])
)
),
}
for source, target in (
("fecha_disposicion", "legislationDate"),
("fecha_publicacion", "datePublished"),
):
if data.get(source):
item[target] = data[source]
if data.get("temas"):
item["keywords"] = data["temas"]
force = legal_force(data["estado_vigencia"])
if force:
item["legislationLegalForce"] = {"@id": force}
relations = data.get("relaciones") or {}
for source, target in (
("modifica_a", "legislationAmends"),
("deroga_a", "legislationRepeals"),
):
references = [
legislation_ref(identifier, paths_by_id)
for identifier in relations.get(source, [])
if identifier.startswith("NOR-")
]
if references:
item[target] = references
developed = [
legislation_ref(identifier, paths_by_id)
for identifier in relations.get("desarrolla_a", [])
if identifier.startswith("NOR-")
]
if developed:
item["isBasedOn"] = developed
graph.append(item)
return {"@context": SCHEMA_CONTEXT, "@graph": graph}
def build_curricula(paths_by_norm: dict[str, pathlib.Path]) -> dict[str, Any]:
"""Build a Schema.org LearningResource graph for every curricular record."""
graph = []
for path, data in load_curricula():
item: dict[str, Any] = {
"@id": public_url(path),
"@type": ["LearningResource", "Course"],
"name": data["titulo"],
"identifier": data["id"],
"learningResourceType": "curriculum",
"educationalLevel": STAGE_LABELS.get(data["etapa"], data["etapa"]),
"about": data["materia"],
"inLanguage": "es",
"url": public_url(path),
"sameAs": data["url_oficial"],
"creativeWorkStatus": data["estado_extraccion"],
}
if data.get("cursos"):
item["educationalUse"] = data["cursos"]
if data.get("fecha_consulta"):
item["dateModified"] = data["fecha_consulta"]
if data.get("norma_base") and data["norma_base"] in paths_by_norm:
item["isBasedOn"] = legislation_ref(data["norma_base"], paths_by_norm)
graph.append(item)
return {"@context": SCHEMA_CONTEXT, "@graph": graph}
def build_sources() -> dict[str, Any]:
"""Build a Schema.org WebPage graph for every official source record."""
graph = []
for path, data in load_sources():
item: dict[str, Any] = {
"@id": public_url(path),
"@type": "WebPage",
"name": data["titulo"],
"identifier": data["id"],
"url": data["url_oficial"],
"sameAs": public_url(path),
"inLanguage": "es",
"publisher": {
"@type": "Organization",
"name": data["autoridad"],
},
}
if data.get("tipo_fuente"):
item["additionalType"] = data["tipo_fuente"]
if data.get("fecha_consulta"):
item["dateModified"] = data["fecha_consulta"]
graph.append(item)
return {"@context": SCHEMA_CONTEXT, "@graph": graph}
def load_pilots() -> list[dict[str, Any]]:
"""Load the reviewed Akoma Ntoso portion catalogue."""
data = load_yaml_document(PILOT_PATH)
pilots = data.get("pilotos")
if not isinstance(pilots, list) or not pilots:
raise ValueError("akoma_ntoso_pilotos.yaml debe declarar una lista de pilotos")
return pilots
def pilot_kind(pilot: dict[str, Any]) -> str:
"""Return 'articulo' or the declared disposition kind of a pilot."""
return pilot.get("tipo", "articulo")
def block_eid(pilot: dict[str, Any]) -> str:
"""Return the AKN eId of the reviewed portion."""
kind = pilot_kind(pilot)
if kind == "articulo":
return f"art_{pilot['articulo']}"
suffix = "adi" if kind == "disposicion_adicional" else "fin"
return f"dis_{suffix}_{pilot['ordinal']}"
def file_stem(pilot: dict[str, Any]) -> str:
"""Return the public filename stem of a reviewed portion."""
kind = pilot_kind(pilot)
if kind == "articulo":
return f"{pilot['id']}-articulo-{pilot['articulo']}"
suffix = "adicional" if kind == "disposicion_adicional" else "final"
return f"{pilot['id']}-disposicion-{suffix}-{pilot['ordinal']}"
def akn_path(pilot: dict[str, Any]) -> pathlib.Path:
"""Return the public XML path of a reviewed Akoma Ntoso portion."""
return AKN_DIR / f"{file_stem(pilot)}.xml"
def pilot_label(pilot: dict[str, Any]) -> str:
"""Return a human-readable description of a reviewed portion."""
kind = pilot_kind(pilot)
if kind == "articulo":
return f"{pilot['id']} artículo {pilot['articulo']}"
suffix = "adicional" if kind == "disposicion_adicional" else "final"
return f"{pilot['id']} disposición {suffix} {pilot['ordinal']}"
def render_akoma_ntoso(pilot: dict[str, Any], eli: str | None) -> str:
"""Render a reviewed article or disposition as an Akoma Ntoso 3.0 portion."""
work = pilot["work_uri"]
author = pilot["author_eid"]
eid = block_eid(pilot)
kind = pilot_kind(pilot)
eli_line = (
f'
{xml_text(text)}
" for text in texts) paragraphs.append( f"