Files
ai-resume/corpus/guides.py

228 lines
7.8 KiB
Python

"""Platform guides: one small file per platform, configuration and public copy.
`platforms/<name>.yml` says what a platform is, where it lives, what to include,
what to exclude, which resume area it feeds, and whether it is enabled. The same
wording is shown on the public site, so every value here is written to be read
by a visitor, not just by the pipeline.
The schema is fixed and validated at load time. An unknown area, a missing
`what`, or a guide whose filename and `name` disagree is a configuration error,
not a default. A disabled guide is loaded and shown, but not harvested.
There is exactly one YAML dialect in this repository and one parser for it
(`tools/validate_content.py`); this module imports that parser rather than
adding a second one.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from . import loader
from .payloads import AREAS # one area enum for guides and payloads
# The platforms the pipeline knows how to harvest. `curated` is content/ and is
# always on: it is tier 1 and has no guide because it is not an external estate.
KINDS = ("gitea", "obsidian", "graphify")
REQUIRED = ("name", "enabled", "what", "fits", "read")
DEFAULT_LIMITS = {
"max_documents": 4000,
"max_file_kb": 200,
"max_files_per_repo": 40,
"max_commits": 20,
"max_tree_entries": 3000,
"max_nodes": 20000,
"max_edges": 40000,
}
class GuideError(RuntimeError):
"""A platform guide is missing, malformed, or inconsistent."""
@dataclass
class Guide:
name: str
enabled: bool
what: str
fits: list
read: list
base: str = ""
auth: str = ""
include: dict = field(default_factory=dict)
exclude: dict = field(default_factory=dict)
limits: dict = field(default_factory=dict)
public: bool = True
path: str = "" # repository-relative, for the public page and error messages
# -- derived ----------------------------------------------------------
@property
def kind(self) -> str:
return self.name
@property
def primary_area(self) -> str:
return self.fits[0]
def limit(self, name: str) -> int:
return int(self.limits.get(name) or DEFAULT_LIMITS.get(name) or 0)
def to_public_dict(self) -> dict:
"""What the site shows. No auth variable values, only its name."""
return {
"name": self.name,
"enabled": self.enabled,
"what": self.what,
"fits": list(self.fits),
"read": list(self.read),
"base": self.base,
"include": {
"repos": _as_list((self.include or {}).get("repos")),
"paths": _as_list((self.include or {}).get("paths")),
},
"exclude": {
"repos": _as_list((self.exclude or {}).get("repos")),
"paths": _as_list((self.exclude or {}).get("paths")),
},
"public": self.public,
"path": self.path,
}
def to_dict(self) -> dict:
data = self.to_public_dict()
data["limits"] = dict(self.limits)
data["auth"] = self.auth # the variable name, never its value
return data
def load_guides(root: str, directory: str = None) -> dict:
"""Load every guide under platforms/, keyed by name. Sorted, deterministic."""
directory = directory or os.path.join(root, "platforms")
if not os.path.isdir(directory):
raise GuideError(f"platform guide directory not found: {directory}")
validator = loader.load_validator(root)
guides = {}
for filename in sorted(os.listdir(directory)):
if not filename.endswith((".yml", ".yaml")):
continue
path = os.path.join(directory, filename)
rel = os.path.relpath(path, root).replace(os.sep, "/")
guide = _load_one(validator, path, rel, os.path.splitext(filename)[0])
if guide.name in guides:
raise GuideError(f"{rel}: duplicate platform name {guide.name!r}")
guides[guide.name] = guide
missing = sorted(set(KINDS) - set(guides))
if missing:
raise GuideError(
f"no platform guide for {', '.join(missing)} under {directory}. "
"One small file per platform is required (docs/phase-4-corpus.md §2)."
)
return guides
def _load_one(validator, path: str, rel: str, stem: str) -> Guide:
with open(path, "r", encoding="utf-8") as handle:
text = handle.read()
lines = [(number, line) for number, line in enumerate(text.splitlines(), start=1)]
try:
node = validator.parse_front_matter(lines)
except validator.YamlError as exc:
raise GuideError(f"{rel}:{exc.line}: invalid YAML: {exc.msg}") from exc
values = loader.to_python(node)
if not isinstance(values, dict):
raise GuideError(f"{rel}: a platform guide must be a mapping")
for key in REQUIRED:
if values.get(key) in (None, "", []):
raise GuideError(f"{rel}: missing required key {key!r}")
name = str(values["name"]).strip()
if name != stem:
raise GuideError(f"{rel}: name {name!r} does not match the filename {stem!r}")
if name not in KINDS:
raise GuideError(f"{rel}: unknown platform {name!r}; expected one of {list(KINDS)}")
fits = [str(area) for area in values["fits"]]
unknown = [area for area in fits if area not in AREAS]
if unknown:
raise GuideError(f"{rel}: fits contains unknown area(s) {unknown}; allowed {list(AREAS)}")
read = [str(item) for item in values["read"]]
if not read:
raise GuideError(f"{rel}: read must name at least one thing to harvest")
include = _mapping(values.get("include"), f"{rel}: include")
exclude = _mapping(values.get("exclude"), f"{rel}: exclude")
limits = _mapping(values.get("limits"), f"{rel}: limits")
for key, value in limits.items():
if isinstance(value, list):
# `graph_paths` is the one list-valued limit: the places a graph may
# be committed, tried in order.
continue
try:
limits[key] = int(value)
except (TypeError, ValueError):
raise GuideError(f"{rel}: limit {key!r} must be an integer, got {value!r}") from None
return Guide(
name=name,
enabled=bool(values.get("enabled")),
what=str(values["what"]).strip(),
fits=fits,
read=read,
base=str(values.get("base") or "").strip(),
auth=str(values.get("auth") or "").strip(),
include=include,
exclude=exclude,
limits=limits,
public=bool(values.get("public", True)),
path=rel,
)
def _mapping(value, where: str) -> dict:
if value is None:
return {}
if not isinstance(value, dict):
raise GuideError(f"{where} must be a mapping")
out = {}
for key, item in value.items():
if isinstance(item, list):
out[key] = [str(entry) for entry in item]
else:
out[key] = item
return out
def _as_list(value) -> list:
"""`repos: all` is a scalar; the public manifest still emits a list."""
if value in (None, ""):
return []
if isinstance(value, list):
return [str(item) for item in value]
return [str(value)]
def enabled_guides(guides: dict) -> list:
return [guide for _, guide in sorted(guides.items()) if guide.enabled]
def auth_value(guide: Guide, environ=None) -> str:
"""Read a guide's token from the environment. Never from a file in the repo."""
if not guide.auth:
return ""
environ = os.environ if environ is None else environ
return environ.get(guide.auth, "")
def build_public_manifest(guides: dict) -> dict:
"""The guides as the public site shows them. Deterministic JSON shape."""
return {
"version": 1,
"platforms": [guide.to_public_dict() for _, guide in sorted(guides.items())],
}