Les enseignants ont besoin de moyennes à jour immédiatement après la publication ou modification des notes, sans attendre un batch nocturne. Le système recalcule via Domain Events synchrones : statistiques d'évaluation (min/max/moyenne/médiane), moyennes matières pondérées (normalisation /20), et moyenne générale par élève. Les résultats sont stockés dans des tables dénormalisées avec cache Redis (TTL 5 min). Trois endpoints API exposent les données avec contrôle d'accès par rôle. Une commande console permet le backfill des données historiques au déploiement.
594 lines
21 KiB
Python
594 lines
21 KiB
Python
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = ["pyyaml"]
|
|
# ///
|
|
|
|
#!/usr/bin/env python3
|
|
"""
|
|
BMad Init — Project configuration bootstrap and config loader.
|
|
|
|
Config files (flat YAML per module):
|
|
- _bmad/core/config.yaml (core settings — user_name, language, output_folder, etc.)
|
|
- _bmad/{module}/config.yaml (module settings + core values merged in)
|
|
|
|
Usage:
|
|
# Fast path — load all vars for a module (includes core vars)
|
|
python bmad_init.py load --module bmb --all --project-root /path
|
|
|
|
# Load specific vars with optional defaults
|
|
python bmad_init.py load --module bmb --vars var1:default1,var2 --project-root /path
|
|
|
|
# Load core only
|
|
python bmad_init.py load --all --project-root /path
|
|
|
|
# Check if init is needed
|
|
python bmad_init.py check --project-root /path
|
|
python bmad_init.py check --module bmb --skill-path /path/to/skill --project-root /path
|
|
|
|
# Resolve module defaults given core answers
|
|
python bmad_init.py resolve-defaults --module bmb --core-answers '{"output_folder":"..."}' --project-root /path
|
|
|
|
# Write config from answered questions
|
|
python bmad_init.py write --answers '{"core": {...}, "bmb": {...}}' --project-root /path
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
|
|
# =============================================================================
|
|
# Project Root Detection
|
|
# =============================================================================
|
|
|
|
def find_project_root(llm_provided=None):
|
|
"""
|
|
Find project root by looking for _bmad folder.
|
|
|
|
Args:
|
|
llm_provided: Path explicitly provided via --project-root.
|
|
|
|
Returns:
|
|
Path to project root, or None if not found.
|
|
"""
|
|
if llm_provided:
|
|
candidate = Path(llm_provided)
|
|
if (candidate / '_bmad').exists():
|
|
return candidate
|
|
# First run — _bmad won't exist yet but LLM path is still valid
|
|
if candidate.is_dir():
|
|
return candidate
|
|
|
|
for start_dir in [Path.cwd(), Path(__file__).resolve().parent]:
|
|
current_dir = start_dir
|
|
while current_dir != current_dir.parent:
|
|
if (current_dir / '_bmad').exists():
|
|
return current_dir
|
|
current_dir = current_dir.parent
|
|
|
|
return None
|
|
|
|
|
|
# =============================================================================
|
|
# Module YAML Loading
|
|
# =============================================================================
|
|
|
|
def load_module_yaml(path):
|
|
"""
|
|
Load and parse a module.yaml file, separating metadata from variable definitions.
|
|
|
|
Returns:
|
|
Dict with 'meta' (code, name, etc.) and 'variables' (var definitions)
|
|
and 'directories' (list of dir templates), or None on failure.
|
|
"""
|
|
try:
|
|
with open(path, 'r', encoding='utf-8') as f:
|
|
raw = yaml.safe_load(f)
|
|
except Exception:
|
|
return None
|
|
|
|
if not raw or not isinstance(raw, dict):
|
|
return None
|
|
|
|
meta_keys = {'code', 'name', 'description', 'default_selected', 'header', 'subheader'}
|
|
meta = {}
|
|
variables = {}
|
|
directories = []
|
|
|
|
for key, value in raw.items():
|
|
if key == 'directories':
|
|
directories = value if isinstance(value, list) else []
|
|
elif key in meta_keys:
|
|
meta[key] = value
|
|
elif isinstance(value, dict) and 'prompt' in value:
|
|
variables[key] = value
|
|
# Skip comment-only entries (## var_name lines become None values)
|
|
|
|
return {'meta': meta, 'variables': variables, 'directories': directories}
|
|
|
|
|
|
def find_core_module_yaml():
|
|
"""Find the core module.yaml bundled with this skill."""
|
|
return Path(__file__).resolve().parent.parent / 'resources' / 'core-module.yaml'
|
|
|
|
|
|
def find_target_module_yaml(module_code, project_root, skill_path=None):
|
|
"""
|
|
Find module.yaml for a given module code.
|
|
|
|
Search order:
|
|
1. skill_path/assets/module.yaml (calling skill's assets)
|
|
2. skill_path/module.yaml (calling skill's root)
|
|
3. _bmad/{module_code}/module.yaml (installed module location)
|
|
"""
|
|
search_paths = []
|
|
|
|
if skill_path:
|
|
sp = Path(skill_path)
|
|
search_paths.append(sp / 'assets' / 'module.yaml')
|
|
search_paths.append(sp / 'module.yaml')
|
|
|
|
if project_root and module_code:
|
|
search_paths.append(Path(project_root) / '_bmad' / module_code / 'module.yaml')
|
|
|
|
for path in search_paths:
|
|
if path.exists():
|
|
return path
|
|
|
|
return None
|
|
|
|
|
|
# =============================================================================
|
|
# Config Loading (Flat per-module files)
|
|
# =============================================================================
|
|
|
|
def load_config_file(path):
|
|
"""Load a flat YAML config file. Returns dict or None."""
|
|
try:
|
|
with open(path, 'r', encoding='utf-8') as f:
|
|
data = yaml.safe_load(f)
|
|
return data if isinstance(data, dict) else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def load_module_config(module_code, project_root):
|
|
"""Load config for a specific module from _bmad/{module}/config.yaml."""
|
|
config_path = Path(project_root) / '_bmad' / module_code / 'config.yaml'
|
|
return load_config_file(config_path)
|
|
|
|
|
|
def resolve_project_root_placeholder(value, project_root):
|
|
"""Replace {project-root} placeholder with actual path."""
|
|
if not value or not isinstance(value, str):
|
|
return value
|
|
if '{project-root}' in value:
|
|
return value.replace('{project-root}', str(project_root))
|
|
return value
|
|
|
|
|
|
def parse_var_specs(vars_string):
|
|
"""
|
|
Parse variable specs: var_name:default_value,var_name2:default_value2
|
|
No default = returns null if missing.
|
|
"""
|
|
if not vars_string:
|
|
return []
|
|
specs = []
|
|
for spec in vars_string.split(','):
|
|
spec = spec.strip()
|
|
if not spec:
|
|
continue
|
|
if ':' in spec:
|
|
parts = spec.split(':', 1)
|
|
specs.append({'name': parts[0].strip(), 'default': parts[1].strip()})
|
|
else:
|
|
specs.append({'name': spec, 'default': None})
|
|
return specs
|
|
|
|
|
|
# =============================================================================
|
|
# Template Expansion
|
|
# =============================================================================
|
|
|
|
def expand_template(value, context):
|
|
"""
|
|
Expand {placeholder} references in a string using context dict.
|
|
|
|
Supports: {project-root}, {value}, {output_folder}, {directory_name}, etc.
|
|
"""
|
|
if not value or not isinstance(value, str):
|
|
return value
|
|
result = value
|
|
for key, val in context.items():
|
|
placeholder = '{' + key + '}'
|
|
if placeholder in result and val is not None:
|
|
result = result.replace(placeholder, str(val))
|
|
return result
|
|
|
|
|
|
def apply_result_template(var_def, raw_value, context):
|
|
"""
|
|
Apply a variable's result template to transform the raw user answer.
|
|
|
|
E.g., result: "{project-root}/{value}" with value="_bmad-output"
|
|
becomes "/Users/foo/project/_bmad-output"
|
|
"""
|
|
result_template = var_def.get('result')
|
|
if not result_template:
|
|
return raw_value
|
|
|
|
ctx = dict(context)
|
|
ctx['value'] = raw_value
|
|
return expand_template(result_template, ctx)
|
|
|
|
|
|
# =============================================================================
|
|
# Load Command (Fast Path)
|
|
# =============================================================================
|
|
|
|
def cmd_load(args):
|
|
"""Load config vars — the fast path."""
|
|
project_root = find_project_root(llm_provided=args.project_root)
|
|
if not project_root:
|
|
print(json.dumps({'error': 'Project root not found (_bmad folder not detected)'}),
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
module_code = args.module or 'core'
|
|
|
|
# Load the module's config (which includes core vars)
|
|
config = load_module_config(module_code, project_root)
|
|
if config is None:
|
|
print(json.dumps({
|
|
'init_required': True,
|
|
'missing_module': module_code,
|
|
}), file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Resolve {project-root} in all values
|
|
for key in config:
|
|
config[key] = resolve_project_root_placeholder(config[key], project_root)
|
|
|
|
if args.all:
|
|
print(json.dumps(config, indent=2))
|
|
else:
|
|
var_specs = parse_var_specs(args.vars)
|
|
if not var_specs:
|
|
print(json.dumps({'error': 'Either --vars or --all must be specified'}),
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
result = {}
|
|
for spec in var_specs:
|
|
val = config.get(spec['name'])
|
|
if val is not None and val != '':
|
|
result[spec['name']] = val
|
|
elif spec['default'] is not None:
|
|
result[spec['name']] = spec['default']
|
|
else:
|
|
result[spec['name']] = None
|
|
print(json.dumps(result, indent=2))
|
|
|
|
|
|
# =============================================================================
|
|
# Check Command
|
|
# =============================================================================
|
|
|
|
def cmd_check(args):
|
|
"""Check if config exists and return status with module.yaml questions if needed."""
|
|
project_root = find_project_root(llm_provided=args.project_root)
|
|
if not project_root:
|
|
print(json.dumps({
|
|
'status': 'no_project',
|
|
'message': 'No project root found. Provide --project-root to bootstrap.',
|
|
}, indent=2))
|
|
return
|
|
|
|
project_root = Path(project_root)
|
|
module_code = args.module
|
|
|
|
# Check core config
|
|
core_config = load_module_config('core', project_root)
|
|
core_exists = core_config is not None
|
|
|
|
# If no module requested, just check core
|
|
if not module_code or module_code == 'core':
|
|
if core_exists:
|
|
print(json.dumps({'status': 'ready', 'project_root': str(project_root)}, indent=2))
|
|
else:
|
|
core_yaml_path = find_core_module_yaml()
|
|
core_module = load_module_yaml(core_yaml_path) if core_yaml_path.exists() else None
|
|
print(json.dumps({
|
|
'status': 'core_missing',
|
|
'project_root': str(project_root),
|
|
'core_module': core_module,
|
|
}, indent=2))
|
|
return
|
|
|
|
# Module requested — check if its config exists
|
|
module_config = load_module_config(module_code, project_root)
|
|
if module_config is not None:
|
|
print(json.dumps({'status': 'ready', 'project_root': str(project_root)}, indent=2))
|
|
return
|
|
|
|
# Module config missing — find its module.yaml for questions
|
|
target_yaml_path = find_target_module_yaml(
|
|
module_code, project_root, skill_path=args.skill_path
|
|
)
|
|
target_module = load_module_yaml(target_yaml_path) if target_yaml_path else None
|
|
|
|
result = {
|
|
'project_root': str(project_root),
|
|
}
|
|
|
|
if not core_exists:
|
|
result['status'] = 'core_missing'
|
|
core_yaml_path = find_core_module_yaml()
|
|
result['core_module'] = load_module_yaml(core_yaml_path) if core_yaml_path.exists() else None
|
|
else:
|
|
result['status'] = 'module_missing'
|
|
result['core_vars'] = core_config
|
|
|
|
result['target_module'] = target_module
|
|
if target_yaml_path:
|
|
result['target_module_yaml_path'] = str(target_yaml_path)
|
|
|
|
print(json.dumps(result, indent=2))
|
|
|
|
|
|
# =============================================================================
|
|
# Resolve Defaults Command
|
|
# =============================================================================
|
|
|
|
def cmd_resolve_defaults(args):
|
|
"""Given core answers, resolve a module's variable defaults."""
|
|
project_root = find_project_root(llm_provided=args.project_root)
|
|
if not project_root:
|
|
print(json.dumps({'error': 'Project root not found'}), file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
core_answers = json.loads(args.core_answers)
|
|
except json.JSONDecodeError as e:
|
|
print(json.dumps({'error': f'Invalid JSON in --core-answers: {e}'}),
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Build context for template expansion
|
|
context = {
|
|
'project-root': str(project_root),
|
|
'directory_name': Path(project_root).name,
|
|
}
|
|
context.update(core_answers)
|
|
|
|
# Find and load the module's module.yaml
|
|
module_code = args.module
|
|
target_yaml_path = find_target_module_yaml(
|
|
module_code, project_root, skill_path=args.skill_path
|
|
)
|
|
if not target_yaml_path:
|
|
print(json.dumps({'error': f'No module.yaml found for module: {module_code}'}),
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
module_def = load_module_yaml(target_yaml_path)
|
|
if not module_def:
|
|
print(json.dumps({'error': f'Failed to parse module.yaml at: {target_yaml_path}'}),
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Resolve defaults in each variable
|
|
resolved_vars = {}
|
|
for var_name, var_def in module_def['variables'].items():
|
|
default = var_def.get('default', '')
|
|
resolved_default = expand_template(str(default), context)
|
|
resolved_vars[var_name] = dict(var_def)
|
|
resolved_vars[var_name]['default'] = resolved_default
|
|
|
|
result = {
|
|
'module_code': module_code,
|
|
'meta': module_def['meta'],
|
|
'variables': resolved_vars,
|
|
'directories': module_def['directories'],
|
|
}
|
|
print(json.dumps(result, indent=2))
|
|
|
|
|
|
# =============================================================================
|
|
# Write Command
|
|
# =============================================================================
|
|
|
|
def cmd_write(args):
|
|
"""Write config files from answered questions."""
|
|
project_root = find_project_root(llm_provided=args.project_root)
|
|
if not project_root:
|
|
if args.project_root:
|
|
project_root = Path(args.project_root)
|
|
else:
|
|
print(json.dumps({'error': 'Project root not found and --project-root not provided'}),
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
project_root = Path(project_root)
|
|
|
|
try:
|
|
answers = json.loads(args.answers)
|
|
except json.JSONDecodeError as e:
|
|
print(json.dumps({'error': f'Invalid JSON in --answers: {e}'}),
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
context = {
|
|
'project-root': str(project_root),
|
|
'directory_name': project_root.name,
|
|
}
|
|
|
|
# Load module.yaml definitions to get result templates
|
|
core_yaml_path = find_core_module_yaml()
|
|
core_def = load_module_yaml(core_yaml_path) if core_yaml_path.exists() else None
|
|
|
|
files_written = []
|
|
dirs_created = []
|
|
|
|
# Process core answers first (needed for module config expansion)
|
|
core_answers_raw = answers.get('core', {})
|
|
core_config = {}
|
|
|
|
if core_answers_raw and core_def:
|
|
for var_name, raw_value in core_answers_raw.items():
|
|
var_def = core_def['variables'].get(var_name, {})
|
|
expanded = apply_result_template(var_def, raw_value, context)
|
|
core_config[var_name] = expanded
|
|
|
|
# Write core config
|
|
core_dir = project_root / '_bmad' / 'core'
|
|
core_dir.mkdir(parents=True, exist_ok=True)
|
|
core_config_path = core_dir / 'config.yaml'
|
|
|
|
# Merge with existing if present
|
|
existing = load_config_file(core_config_path) or {}
|
|
existing.update(core_config)
|
|
|
|
_write_config_file(core_config_path, existing, 'CORE')
|
|
files_written.append(str(core_config_path))
|
|
elif core_answers_raw:
|
|
# No core_def available — write raw values
|
|
core_config = dict(core_answers_raw)
|
|
core_dir = project_root / '_bmad' / 'core'
|
|
core_dir.mkdir(parents=True, exist_ok=True)
|
|
core_config_path = core_dir / 'config.yaml'
|
|
existing = load_config_file(core_config_path) or {}
|
|
existing.update(core_config)
|
|
_write_config_file(core_config_path, existing, 'CORE')
|
|
files_written.append(str(core_config_path))
|
|
|
|
# Update context with resolved core values for module expansion
|
|
context.update(core_config)
|
|
|
|
# Process module answers
|
|
for module_code, module_answers_raw in answers.items():
|
|
if module_code == 'core':
|
|
continue
|
|
|
|
# Find module.yaml for result templates
|
|
target_yaml_path = find_target_module_yaml(
|
|
module_code, project_root, skill_path=args.skill_path
|
|
)
|
|
module_def = load_module_yaml(target_yaml_path) if target_yaml_path else None
|
|
|
|
# Build module config: start with core values, then add module values
|
|
# Re-read core config to get the latest (may have been updated above)
|
|
latest_core = load_module_config('core', project_root) or core_config
|
|
module_config = dict(latest_core)
|
|
|
|
for var_name, raw_value in module_answers_raw.items():
|
|
if module_def:
|
|
var_def = module_def['variables'].get(var_name, {})
|
|
expanded = apply_result_template(var_def, raw_value, context)
|
|
else:
|
|
expanded = raw_value
|
|
module_config[var_name] = expanded
|
|
context[var_name] = expanded # Available for subsequent template expansion
|
|
|
|
# Write module config
|
|
module_dir = project_root / '_bmad' / module_code
|
|
module_dir.mkdir(parents=True, exist_ok=True)
|
|
module_config_path = module_dir / 'config.yaml'
|
|
|
|
existing = load_config_file(module_config_path) or {}
|
|
existing.update(module_config)
|
|
|
|
module_name = module_def['meta'].get('name', module_code.upper()) if module_def else module_code.upper()
|
|
_write_config_file(module_config_path, existing, module_name)
|
|
files_written.append(str(module_config_path))
|
|
|
|
# Create directories declared in module.yaml
|
|
if module_def and module_def.get('directories'):
|
|
for dir_template in module_def['directories']:
|
|
dir_path = expand_template(dir_template, context)
|
|
if dir_path:
|
|
Path(dir_path).mkdir(parents=True, exist_ok=True)
|
|
dirs_created.append(dir_path)
|
|
|
|
result = {
|
|
'status': 'written',
|
|
'files_written': files_written,
|
|
'dirs_created': dirs_created,
|
|
}
|
|
print(json.dumps(result, indent=2))
|
|
|
|
|
|
def _write_config_file(path, data, module_label):
|
|
"""Write a config YAML file with a header comment."""
|
|
from datetime import datetime, timezone
|
|
with open(path, 'w', encoding='utf-8') as f:
|
|
f.write(f'# {module_label} Module Configuration\n')
|
|
f.write(f'# Generated by bmad-init\n')
|
|
f.write(f'# Date: {datetime.now(timezone.utc).isoformat()}\n\n')
|
|
yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
|
|
|
|
# =============================================================================
|
|
# CLI Entry Point
|
|
# =============================================================================
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description='BMad Init — Project configuration bootstrap and config loader.'
|
|
)
|
|
subparsers = parser.add_subparsers(dest='command')
|
|
|
|
# --- load ---
|
|
load_parser = subparsers.add_parser('load', help='Load config vars (fast path)')
|
|
load_parser.add_argument('--module', help='Module code (omit for core only)')
|
|
load_parser.add_argument('--vars', help='Comma-separated vars with optional defaults')
|
|
load_parser.add_argument('--all', action='store_true', help='Return all config vars')
|
|
load_parser.add_argument('--project-root', help='Project root path')
|
|
|
|
# --- check ---
|
|
check_parser = subparsers.add_parser('check', help='Check if init is needed')
|
|
check_parser.add_argument('--module', help='Module code to check (optional)')
|
|
check_parser.add_argument('--skill-path', help='Path to the calling skill folder')
|
|
check_parser.add_argument('--project-root', help='Project root path')
|
|
|
|
# --- resolve-defaults ---
|
|
resolve_parser = subparsers.add_parser('resolve-defaults',
|
|
help='Resolve module defaults given core answers')
|
|
resolve_parser.add_argument('--module', required=True, help='Module code')
|
|
resolve_parser.add_argument('--core-answers', required=True, help='JSON string of core answers')
|
|
resolve_parser.add_argument('--skill-path', help='Path to calling skill folder')
|
|
resolve_parser.add_argument('--project-root', help='Project root path')
|
|
|
|
# --- write ---
|
|
write_parser = subparsers.add_parser('write', help='Write config files')
|
|
write_parser.add_argument('--answers', required=True, help='JSON string of all answers')
|
|
write_parser.add_argument('--skill-path', help='Path to calling skill (for module.yaml lookup)')
|
|
write_parser.add_argument('--project-root', help='Project root path')
|
|
|
|
args = parser.parse_args()
|
|
if args.command is None:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
commands = {
|
|
'load': cmd_load,
|
|
'check': cmd_check,
|
|
'resolve-defaults': cmd_resolve_defaults,
|
|
'write': cmd_write,
|
|
}
|
|
|
|
handler = commands.get(args.command)
|
|
if handler:
|
|
handler(args)
|
|
else:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|