Spaces:
Running
Running
File size: 9,722 Bytes
330d570 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 | """Generate the drift-prone reference docs from the code that owns them.
Two things in the documentation pack rot the moment code changes: the ~1,500-line
configuration surface (``backend/config.py``) and the live prompt text
(``backend/prompts/``). Rather than hand-maintain them, this script regenerates:
* ``docs/reference/configuration.md`` β every ``Settings`` field, grouped by the
``# ββ group ββ`` banners in ``config.py``, with env var, type, default, docs.
* ``docs/reference/prompts.md`` β every live prompt constant and ``build_*``
builder under ``backend/prompts/``.
Determinism matters: the output must not depend on the host or the clock, because
``backend/tests/test_docs_freshness.py`` regenerates and diffs against the
committed files in CI. So: no timestamps, and host-specific ``default_factory``
values (e.g. ``data_dir`` β ``~/.rics_v2``) render as ``(per-host default)``.
Usage::
python -m backend.scripts.gen_docs # write the files
python -m backend.scripts.gen_docs --check # exit 1 if they would change
"""
from __future__ import annotations
import importlib
import inspect
import re
import sys
from pathlib import Path
from pydantic import AliasChoices
from pydantic.fields import FieldInfo
from pydantic_core import PydanticUndefined
from backend.config import REPO_ROOT, Settings
CONFIG_DOC_REL = "docs/reference/configuration.md"
PROMPTS_DOC_REL = "docs/reference/prompts.md"
_GENERATED_BANNER = (
"<!-- GENERATED FILE β do not edit by hand. "
"Run `python -m backend.scripts.gen_docs` after changing the source. -->"
)
# ββ configuration.md βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# A group banner in config.py, e.g. ``# ββ LLM (OpenAI / Gemini) ββββββ``.
_GROUP_RE = re.compile(r"^\s*#\s*[β=-]{2,}\s*(.+?)\s*[β=-]{2,}\s*$")
# A top-level field declaration inside the Settings class (exactly 4-space indent).
_FIELD_RE = re.compile(r"^ {4}([a-z_][a-z0-9_]*)\s*:\s*[^=]+=")
def _config_groups() -> list[tuple[str, list[str]]]:
"""Ordered (group_label, [field_name, ...]) parsed from config.py source.
Field order follows declaration order; grouping follows the ``# ββ ββ ββ``
banners. Fields declared before any banner land in an "Ungrouped" bucket.
"""
source = Path(inspect.getfile(Settings)).read_text(encoding="utf-8")
fields = Settings.model_fields
groups: list[tuple[str, list[str]]] = []
label = "Ungrouped"
bucket: list[str] = []
for line in source.splitlines():
banner = _GROUP_RE.match(line)
if banner:
if bucket:
groups.append((label, bucket))
label = banner.group(1).strip()
bucket = []
continue
m = _FIELD_RE.match(line)
if m and m.group(1) in fields:
bucket.append(m.group(1))
if bucket:
groups.append((label, bucket))
return groups
def _env_names(name: str, field: FieldInfo) -> str:
"""Environment variable name(s) an operator sets for this field."""
alias = field.validation_alias
uppers: list[str] = []
if isinstance(alias, AliasChoices):
uppers = [c for c in alias.choices if isinstance(c, str) and c.isupper()]
elif isinstance(alias, str) and alias.isupper():
uppers = [alias]
if not uppers:
uppers = [name.upper()]
return " / ".join(dict.fromkeys(uppers))
def _type_name(field: FieldInfo) -> str:
ann = field.annotation
name = getattr(ann, "__name__", None) or str(ann).replace("typing.", "")
# Union types render as "bool | None"; escape the pipe so it does not break
# the markdown table column.
return name.replace("|", r"\|")
def _default_repr(field: FieldInfo) -> str:
if field.default_factory is not None:
return "`[]`" if field.default_factory is list else "(per-host default)"
d = field.default
if d is PydanticUndefined:
return "(required)"
if isinstance(d, str):
return '`""`' if d == "" else f"`{d}`"
return f"`{d}`"
def _one_line(text: str | None) -> str:
if not text:
return ""
return re.sub(r"\s+", " ", text).replace("|", r"\|").strip()
def render_configuration_md() -> str:
fields = Settings.model_fields
lines = [
"# Reference β Configuration",
"",
_GENERATED_BANNER,
"",
"> **Generated** from [backend/config.py](../../backend/config.py) by "
"[backend/scripts/gen_docs.py](../../backend/scripts/gen_docs.py). "
"Do not hand-edit. The narrative lives in "
"[10 β Deployment & configuration](../10-deployment-and-configuration.md).",
"",
"Every setting is overridable via an environment variable (prefix-free, "
"case-insensitive) or the repo-root `.env`. `config.py` defaults are the "
"source of truth; `.env.example` is a curated sample and may differ. "
"`(per-host default)` marks values computed at runtime (e.g. the data dir "
"under the current user's home).",
"",
]
for label, names in _config_groups():
lines.append(f"## {label}")
lines.append("")
lines.append("| Env var | Type | Default | Description |")
lines.append("|---------|------|---------|-------------|")
for name in names:
f = fields[name]
lines.append(
f"| `{_env_names(name, f)}` | {_type_name(f)} | "
f"{_default_repr(f)} | {_one_line(f.description)} |"
)
lines.append("")
return "\n".join(lines).strip() + "\n"
# ββ prompts.md βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Fixed, ordered so output is stable. Each is a module under backend/prompts/.
_PROMPT_MODULES: tuple[str, ...] = (
"discovery_prompt",
"mapping_prompt",
"past_report_mapping_prompt",
"grounding_prompt",
"repair_prompt",
"vision_prompt",
"notes_expander_prompt",
"notes_guidance",
"minimum_weave_prompt",
"medium_expand_prompt",
"maximum_compose_prompt",
"prompt_few_shot_examples",
"prompt_message_assembly",
)
def _prompt_entries(module_name: str) -> list[tuple[str, str]]:
"""(display_name, markdown_body) for public prompt constants + build_* funcs."""
mod = importlib.import_module(f"backend.prompts.{module_name}")
entries: list[tuple[str, str]] = []
for name, obj in sorted(vars(mod).items()):
if name.startswith("_"):
continue
if isinstance(obj, str) and len(obj.strip()) > 40:
entries.append((name, f"```text\n{obj.strip()}\n```"))
elif callable(obj) and name.startswith("build_"):
try:
src = inspect.getsource(obj).strip()
except (OSError, TypeError):
continue
entries.append((f"{name}()", f"```python\n{src}\n```"))
return entries
def render_prompts_md() -> str:
lines = [
"# Reference β Live AI prompts",
"",
_GENERATED_BANNER,
"",
"> **Generated** from [backend/prompts/](../../backend/prompts/) by "
"[backend/scripts/gen_docs.py](../../backend/scripts/gen_docs.py). "
"Do not hand-edit β change the prompt module and regenerate. The narrative "
"(which stage calls what, the model matrix, the assembly layer) lives in "
"[07 β Prompts & models](../07-prompts-and-models.md).",
"",
"Each section is one prompt module: its public prompt strings and its "
"`build_*` assembler functions, verbatim from the source.",
"",
]
for module_name in _PROMPT_MODULES:
entries = _prompt_entries(module_name)
if not entries:
continue
lines.extend([f"## `{module_name}`", ""])
for name, body in entries:
lines.extend([f"### `{name}`", "", body, ""])
return "\n".join(lines).strip() + "\n"
# ββ driver βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def render_all() -> dict[str, str]:
"""Map of repo-relative doc path β generated content (no disk writes)."""
return {
CONFIG_DOC_REL: render_configuration_md(),
PROMPTS_DOC_REL: render_prompts_md(),
}
def write_all() -> list[Path]:
written: list[Path] = []
for rel, content in render_all().items():
target = REPO_ROOT / rel
target.parent.mkdir(parents=True, exist_ok=True)
# Force LF so regeneration on Windows and Linux (CI) is byte-identical.
target.write_text(content, encoding="utf-8", newline="\n")
written.append(target)
return written
def _check() -> int:
stale: list[str] = []
for rel, content in render_all().items():
target = REPO_ROOT / rel
current = target.read_text(encoding="utf-8") if target.exists() else ""
if current != content:
stale.append(rel)
if stale:
print("Stale reference docs (run `python -m backend.scripts.gen_docs`):")
for rel in stale:
print(f" - {rel}")
return 1
print("Reference docs are fresh.")
return 0
if __name__ == "__main__":
if "--check" in sys.argv[1:]:
raise SystemExit(_check())
for path in write_all():
print(f"Wrote {path}")
|