feat(lea): add dashboard competence promotion dry run
This commit is contained in:
@@ -15,15 +15,25 @@ from .verdicts import (
|
|||||||
iter_competence_verdicts,
|
iter_competence_verdicts,
|
||||||
store_competence_verdict,
|
store_competence_verdict,
|
||||||
)
|
)
|
||||||
|
from .promotions import (
|
||||||
|
CompetencePromotionError,
|
||||||
|
iter_competence_promotions,
|
||||||
|
promote_competence_from_verdicts,
|
||||||
|
summarize_competence_promotions,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CompetenceSummary",
|
"CompetenceSummary",
|
||||||
|
"CompetencePromotionError",
|
||||||
"CompetenceVerdictError",
|
"CompetenceVerdictError",
|
||||||
"build_competence_replay_actions",
|
"build_competence_replay_actions",
|
||||||
"build_competence_replay_payload",
|
"build_competence_replay_payload",
|
||||||
"find_competence",
|
"find_competence",
|
||||||
|
"iter_competence_promotions",
|
||||||
"iter_competence_verdicts",
|
"iter_competence_verdicts",
|
||||||
"load_competence_catalog_actions",
|
"load_competence_catalog_actions",
|
||||||
"load_competences",
|
"load_competences",
|
||||||
|
"promote_competence_from_verdicts",
|
||||||
|
"summarize_competence_promotions",
|
||||||
"store_competence_verdict",
|
"store_competence_verdict",
|
||||||
]
|
]
|
||||||
|
|||||||
666
core/competences/promotions.py
Normal file
666
core/competences/promotions.py
Normal file
@@ -0,0 +1,666 @@
|
|||||||
|
"""Promote Lea competences from supervised verdict evidence."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import difflib
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Iterable, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from .catalog import (
|
||||||
|
DEFAULT_COMPETENCE_ROOT,
|
||||||
|
KNOWN_STATES,
|
||||||
|
REPO_ROOT,
|
||||||
|
load_competence_file,
|
||||||
|
)
|
||||||
|
from .replay import find_competence
|
||||||
|
from .verdicts import DEFAULT_VERDICT_LOG, iter_competence_verdicts
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_PROMOTION_LOG = REPO_ROOT / "data" / "competences" / "promotions.jsonl"
|
||||||
|
PROMOTION_SCHEMA_VERSION = "lea_competence_promotion.v1"
|
||||||
|
PROMOTABLE_STATES = {"candidate", "stable"}
|
||||||
|
|
||||||
|
|
||||||
|
class CompetencePromotionError(ValueError):
|
||||||
|
"""Raised when a competence promotion request is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
def promote_competence_from_verdicts(
|
||||||
|
competence_id: str,
|
||||||
|
payload: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
competence_root: Path | str = DEFAULT_COMPETENCE_ROOT,
|
||||||
|
verdict_log_path: Path | str = DEFAULT_VERDICT_LOG,
|
||||||
|
promotion_log_path: Path | str = DEFAULT_PROMOTION_LOG,
|
||||||
|
states: Optional[Iterable[str]] = None,
|
||||||
|
now: Optional[datetime] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Dry-run or apply a dashboard-controlled competence promotion.
|
||||||
|
|
||||||
|
``dry_run=True`` never writes. A real write requires the exact
|
||||||
|
``dry_run_token`` returned by a prior dry-run for the same evidence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise CompetencePromotionError("Payload promotion invalide")
|
||||||
|
|
||||||
|
dry_run = bool(payload.get("dry_run", True))
|
||||||
|
promotion_id = _promotion_id(payload, dry_run=dry_run)
|
||||||
|
target_state = _target_state(payload)
|
||||||
|
confirmed_by = _text(payload.get("confirmed_by") or "human:dom", "confirmed_by")
|
||||||
|
verdict_ids = _verdict_ids(payload.get("verdict_ids"))
|
||||||
|
timestamp = _timestamp(now)
|
||||||
|
|
||||||
|
root = Path(competence_root)
|
||||||
|
promotion_log = Path(promotion_log_path)
|
||||||
|
|
||||||
|
existing = _find_existing_promotion(promotion_id, log_path=promotion_log)
|
||||||
|
if existing:
|
||||||
|
duplicate = dict(existing)
|
||||||
|
duplicate["duplicate"] = True
|
||||||
|
duplicate["dry_run"] = dry_run
|
||||||
|
return duplicate
|
||||||
|
|
||||||
|
plan = _build_promotion_plan(
|
||||||
|
competence_id=competence_id,
|
||||||
|
target_state=target_state,
|
||||||
|
verdict_ids=verdict_ids,
|
||||||
|
promotion_id=promotion_id,
|
||||||
|
confirmed_by=confirmed_by,
|
||||||
|
timestamp=timestamp,
|
||||||
|
competence_root=root,
|
||||||
|
verdict_log_path=verdict_log_path,
|
||||||
|
states=states,
|
||||||
|
)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return {
|
||||||
|
**plan,
|
||||||
|
"dry_run": True,
|
||||||
|
"write_applied": False,
|
||||||
|
"duplicate": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
provided_token = _text(payload.get("dry_run_token"), "dry_run_token")
|
||||||
|
if provided_token != plan["dry_run_token"]:
|
||||||
|
raise CompetencePromotionError("dry_run_token invalide ou absent")
|
||||||
|
if not plan["eligible"]:
|
||||||
|
raise CompetencePromotionError(
|
||||||
|
"Promotion refusee: " + "; ".join(plan["blocking_reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
record = {
|
||||||
|
"schema_version": PROMOTION_SCHEMA_VERSION,
|
||||||
|
"promotion_id": promotion_id,
|
||||||
|
"competence_id": competence_id,
|
||||||
|
"from_state": plan["from_state"],
|
||||||
|
"to_state": target_state,
|
||||||
|
"triggered_by": confirmed_by,
|
||||||
|
"promoted_at": timestamp,
|
||||||
|
"evidence_verdict_ids": verdict_ids,
|
||||||
|
"evidence_summary": plan["evidence_summary"],
|
||||||
|
"yaml_path_before": plan["yaml_path_before"],
|
||||||
|
"yaml_path_after": plan["yaml_path_after"],
|
||||||
|
"backup_path": "",
|
||||||
|
"dry_run_token": plan["dry_run_token"],
|
||||||
|
"write_back_enabled": True,
|
||||||
|
"yaml_write": True,
|
||||||
|
"duplicate": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_path = _apply_yaml_plan(plan, root=root, timestamp=timestamp)
|
||||||
|
record["backup_path"] = _relative_path(backup_path)
|
||||||
|
_append_jsonl(promotion_log, record)
|
||||||
|
|
||||||
|
return {
|
||||||
|
**plan,
|
||||||
|
"dry_run": False,
|
||||||
|
"write_applied": True,
|
||||||
|
"promotion": record,
|
||||||
|
"backup_path": record["backup_path"],
|
||||||
|
"promotions_log_path": _relative_path(promotion_log),
|
||||||
|
"duplicate": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_competence_promotions(
|
||||||
|
*,
|
||||||
|
competence_root: Path | str = DEFAULT_COMPETENCE_ROOT,
|
||||||
|
verdict_log_path: Path | str = DEFAULT_VERDICT_LOG,
|
||||||
|
states: Optional[Iterable[str]] = None,
|
||||||
|
) -> list[Dict[str, Any]]:
|
||||||
|
"""Return dashboard-safe promotion state for all known competences."""
|
||||||
|
|
||||||
|
root = Path(competence_root)
|
||||||
|
summaries: list[Dict[str, Any]] = []
|
||||||
|
for state in KNOWN_STATES:
|
||||||
|
if states and state not in set(states):
|
||||||
|
continue
|
||||||
|
state_dir = root / state
|
||||||
|
if not state_dir.exists():
|
||||||
|
continue
|
||||||
|
for path in sorted(state_dir.glob("*.yaml")):
|
||||||
|
competence = load_competence_file(path, repo_root=REPO_ROOT)
|
||||||
|
verdicts = iter_competence_verdicts(
|
||||||
|
log_path=verdict_log_path,
|
||||||
|
competence_id=competence.id,
|
||||||
|
)
|
||||||
|
counts = _verdict_counts(verdicts)
|
||||||
|
valid_ids = [
|
||||||
|
str(verdict.get("verdict_id"))
|
||||||
|
for verdict in verdicts
|
||||||
|
if verdict.get("verdict_kind") == "valid" and verdict.get("verdict_id")
|
||||||
|
]
|
||||||
|
targets = {}
|
||||||
|
for target in _available_targets(competence.learning_state):
|
||||||
|
try:
|
||||||
|
plan = _build_promotion_plan(
|
||||||
|
competence_id=competence.id,
|
||||||
|
target_state=target,
|
||||||
|
verdict_ids=valid_ids,
|
||||||
|
promotion_id=str(uuid.uuid4()),
|
||||||
|
confirmed_by="dashboard:summary",
|
||||||
|
timestamp=_timestamp(None),
|
||||||
|
competence_root=root,
|
||||||
|
verdict_log_path=verdict_log_path,
|
||||||
|
states=states,
|
||||||
|
)
|
||||||
|
targets[target] = {
|
||||||
|
"eligible": plan["eligible"],
|
||||||
|
"blocking_reasons": plan["blocking_reasons"],
|
||||||
|
"recommended_verdict_ids": valid_ids,
|
||||||
|
}
|
||||||
|
except (CompetencePromotionError, KeyError) as exc:
|
||||||
|
targets[target] = {
|
||||||
|
"eligible": False,
|
||||||
|
"blocking_reasons": [str(exc)],
|
||||||
|
"recommended_verdict_ids": valid_ids,
|
||||||
|
}
|
||||||
|
|
||||||
|
summaries.append({
|
||||||
|
"id": competence.id,
|
||||||
|
"name": competence.name,
|
||||||
|
"intent_fr": competence.intent_fr,
|
||||||
|
"learning_state": competence.learning_state,
|
||||||
|
"source_path": competence.source_path,
|
||||||
|
"verdict_counts": counts,
|
||||||
|
"distinct_contexts": len(_distinct_contexts([
|
||||||
|
verdict for verdict in verdicts
|
||||||
|
if verdict.get("verdict_kind") == "valid"
|
||||||
|
])),
|
||||||
|
"latest_verdict_at": _latest_verdict_at(verdicts),
|
||||||
|
"eligible_targets": targets,
|
||||||
|
"regression_suspected": _regression_suspected(verdicts),
|
||||||
|
})
|
||||||
|
|
||||||
|
return sorted(summaries, key=lambda item: (item["learning_state"], item["id"]))
|
||||||
|
|
||||||
|
|
||||||
|
def iter_competence_promotions(
|
||||||
|
*,
|
||||||
|
log_path: Path | str = DEFAULT_PROMOTION_LOG,
|
||||||
|
competence_id: Optional[str] = None,
|
||||||
|
) -> list[Dict[str, Any]]:
|
||||||
|
log = Path(log_path)
|
||||||
|
if not log.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
records: list[Dict[str, Any]] = []
|
||||||
|
with log.open("r", encoding="utf-8") as handle:
|
||||||
|
for line in handle:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
record = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
continue
|
||||||
|
if competence_id and record.get("competence_id") != competence_id:
|
||||||
|
continue
|
||||||
|
records.append(record)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def _build_promotion_plan(
|
||||||
|
*,
|
||||||
|
competence_id: str,
|
||||||
|
target_state: str,
|
||||||
|
verdict_ids: list[str],
|
||||||
|
promotion_id: str,
|
||||||
|
confirmed_by: str,
|
||||||
|
timestamp: str,
|
||||||
|
competence_root: Path,
|
||||||
|
verdict_log_path: Path | str,
|
||||||
|
states: Optional[Iterable[str]],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
competence = find_competence(competence_id, root=competence_root, states=states)
|
||||||
|
if target_state == competence.learning_state:
|
||||||
|
raise CompetencePromotionError("target_state identique a l'etat courant")
|
||||||
|
if target_state not in _available_targets(competence.learning_state):
|
||||||
|
raise CompetencePromotionError(
|
||||||
|
f"Promotion {competence.learning_state} -> {target_state} interdite"
|
||||||
|
)
|
||||||
|
|
||||||
|
source_path = _absolute_source_path(competence.source_path)
|
||||||
|
data = _load_yaml_mapping(source_path)
|
||||||
|
verdicts = _selected_verdicts(
|
||||||
|
competence_id=competence_id,
|
||||||
|
verdict_ids=verdict_ids,
|
||||||
|
verdict_log_path=verdict_log_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
evidence_summary = _evidence_summary(verdicts)
|
||||||
|
blocking_reasons = _blocking_reasons(
|
||||||
|
current_state=competence.learning_state,
|
||||||
|
target_state=target_state,
|
||||||
|
verdicts=verdicts,
|
||||||
|
all_verdicts=iter_competence_verdicts(
|
||||||
|
log_path=verdict_log_path,
|
||||||
|
competence_id=competence_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
eligible = not blocking_reasons
|
||||||
|
|
||||||
|
updated = _updated_yaml_data(
|
||||||
|
data=data,
|
||||||
|
competence_id=competence_id,
|
||||||
|
current_state=competence.learning_state,
|
||||||
|
target_state=target_state,
|
||||||
|
verdicts=verdicts,
|
||||||
|
promotion_id=promotion_id,
|
||||||
|
confirmed_by=confirmed_by,
|
||||||
|
timestamp=timestamp,
|
||||||
|
)
|
||||||
|
current_text = source_path.read_text(encoding="utf-8")
|
||||||
|
updated_text = yaml.safe_dump(
|
||||||
|
updated,
|
||||||
|
allow_unicode=True,
|
||||||
|
sort_keys=False,
|
||||||
|
default_flow_style=False,
|
||||||
|
)
|
||||||
|
target_path = competence_root / target_state / f"{competence_id}.yaml"
|
||||||
|
yaml_diff = "\n".join(difflib.unified_diff(
|
||||||
|
current_text.splitlines(),
|
||||||
|
updated_text.splitlines(),
|
||||||
|
fromfile=_relative_path(source_path),
|
||||||
|
tofile=_relative_path(target_path),
|
||||||
|
lineterm="",
|
||||||
|
))
|
||||||
|
dry_run_token = _dry_run_token(
|
||||||
|
promotion_id=promotion_id,
|
||||||
|
competence_id=competence_id,
|
||||||
|
target_state=target_state,
|
||||||
|
verdict_ids=verdict_ids,
|
||||||
|
source_text=current_text,
|
||||||
|
updated_text=updated_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema_version": PROMOTION_SCHEMA_VERSION,
|
||||||
|
"promotion_id": promotion_id,
|
||||||
|
"competence_id": competence_id,
|
||||||
|
"from_state": competence.learning_state,
|
||||||
|
"to_state": target_state,
|
||||||
|
"target_state": target_state,
|
||||||
|
"confirmed_by": confirmed_by,
|
||||||
|
"eligible": eligible,
|
||||||
|
"blocking_reasons": blocking_reasons,
|
||||||
|
"evidence_summary": evidence_summary,
|
||||||
|
"verdict_ids": verdict_ids,
|
||||||
|
"yaml_path_before": _relative_path(source_path),
|
||||||
|
"yaml_path_after": _relative_path(target_path),
|
||||||
|
"yaml_diff": yaml_diff,
|
||||||
|
"dry_run_token": dry_run_token,
|
||||||
|
"_source_path": source_path,
|
||||||
|
"_target_path": target_path,
|
||||||
|
"_updated_text": updated_text,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _blocking_reasons(
|
||||||
|
*,
|
||||||
|
current_state: str,
|
||||||
|
target_state: str,
|
||||||
|
verdicts: list[Dict[str, Any]],
|
||||||
|
all_verdicts: list[Dict[str, Any]],
|
||||||
|
) -> list[str]:
|
||||||
|
valid = [verdict for verdict in verdicts if verdict.get("verdict_kind") == "valid"]
|
||||||
|
reasons: list[str] = []
|
||||||
|
if len(valid) != len(verdicts):
|
||||||
|
reasons.append("Tous les verdict_ids selectionnes doivent etre valid")
|
||||||
|
if not valid:
|
||||||
|
reasons.append("Au moins un verdict valid est requis")
|
||||||
|
missing_evidence = [
|
||||||
|
str(verdict.get("verdict_id"))
|
||||||
|
for verdict in valid
|
||||||
|
if not verdict.get("workflow_id") or not verdict.get("step_results")
|
||||||
|
]
|
||||||
|
if missing_evidence:
|
||||||
|
reasons.append(
|
||||||
|
"Evidence workflow_id/step_results manquante: "
|
||||||
|
+ ", ".join(missing_evidence)
|
||||||
|
)
|
||||||
|
|
||||||
|
if current_state == "candidate" and target_state == "stable":
|
||||||
|
contexts = _distinct_contexts(valid)
|
||||||
|
if len(valid) < 3:
|
||||||
|
reasons.append(f"3 verdicts valid requis pour stable ({len(valid)}/3)")
|
||||||
|
if len(contexts) < 3:
|
||||||
|
reasons.append(f"3 contextes distincts requis pour stable ({len(contexts)}/3)")
|
||||||
|
invalid_unexplained = [
|
||||||
|
verdict for verdict in all_verdicts
|
||||||
|
if verdict.get("verdict_kind") == "invalid" and not _is_explained(verdict)
|
||||||
|
]
|
||||||
|
if invalid_unexplained:
|
||||||
|
reasons.append(
|
||||||
|
"Invalid non explique present: "
|
||||||
|
+ ", ".join(str(v.get("verdict_id")) for v in invalid_unexplained)
|
||||||
|
)
|
||||||
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
|
def _updated_yaml_data(
|
||||||
|
*,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
competence_id: str,
|
||||||
|
current_state: str,
|
||||||
|
target_state: str,
|
||||||
|
verdicts: list[Dict[str, Any]],
|
||||||
|
promotion_id: str,
|
||||||
|
confirmed_by: str,
|
||||||
|
timestamp: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
updated = json.loads(json.dumps(data, ensure_ascii=False))
|
||||||
|
updated["learning_state"] = target_state
|
||||||
|
updated["last_updated_at"] = timestamp
|
||||||
|
|
||||||
|
promotion = updated.setdefault("promotion", {})
|
||||||
|
history = promotion.setdefault("history", [])
|
||||||
|
if isinstance(history, list):
|
||||||
|
history.append({
|
||||||
|
"at": timestamp,
|
||||||
|
"from": current_state,
|
||||||
|
"to": target_state,
|
||||||
|
"by": confirmed_by,
|
||||||
|
"reason": "Promotion dashboard supervisee par verdicts humains",
|
||||||
|
"promotion_id": promotion_id,
|
||||||
|
"evidence_verdict_ids": [
|
||||||
|
verdict.get("verdict_id") for verdict in verdicts
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
generalisation = updated.setdefault("generalisation", {})
|
||||||
|
seen_contexts = generalisation.setdefault("seen_contexts", [])
|
||||||
|
if isinstance(seen_contexts, list):
|
||||||
|
existing_ids = {
|
||||||
|
context.get("verdict_id")
|
||||||
|
for context in seen_contexts
|
||||||
|
if isinstance(context, dict)
|
||||||
|
}
|
||||||
|
for verdict in verdicts:
|
||||||
|
verdict_id = verdict.get("verdict_id")
|
||||||
|
if verdict_id in existing_ids:
|
||||||
|
continue
|
||||||
|
context = verdict.get("context_signature") or {}
|
||||||
|
seen_contexts.append({
|
||||||
|
"at": timestamp,
|
||||||
|
"verdict_id": verdict_id,
|
||||||
|
"promotion_id": promotion_id,
|
||||||
|
"machine_id": context.get("machine_id", ""),
|
||||||
|
"workflow_id": verdict.get("workflow_id", ""),
|
||||||
|
"screen_state_initial": context.get("screen_state_initial", ""),
|
||||||
|
"screen_state_after_action": context.get("screen_state_after_action", ""),
|
||||||
|
"verdict_at": verdict.get("verdict_at", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_yaml_plan(plan: Dict[str, Any], *, root: Path, timestamp: str) -> Path:
|
||||||
|
source_path = Path(plan["_source_path"])
|
||||||
|
target_path = Path(plan["_target_path"])
|
||||||
|
updated_text = str(plan["_updated_text"])
|
||||||
|
|
||||||
|
backup_path = source_path.with_name(
|
||||||
|
f"{source_path.name}.{timestamp.replace(':', '').replace('+', '_')}.bak"
|
||||||
|
)
|
||||||
|
shutil.copy2(source_path, backup_path)
|
||||||
|
|
||||||
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = target_path.with_suffix(target_path.suffix + ".tmp")
|
||||||
|
tmp_path.write_text(updated_text, encoding="utf-8")
|
||||||
|
|
||||||
|
try:
|
||||||
|
load_competence_file(tmp_path, repo_root=REPO_ROOT)
|
||||||
|
tmp_path.replace(target_path)
|
||||||
|
load_competence_file(target_path, repo_root=REPO_ROOT)
|
||||||
|
if source_path != target_path and source_path.exists():
|
||||||
|
source_path.unlink()
|
||||||
|
except Exception:
|
||||||
|
if tmp_path.exists():
|
||||||
|
tmp_path.unlink()
|
||||||
|
if source_path.exists():
|
||||||
|
shutil.copy2(backup_path, source_path)
|
||||||
|
raise
|
||||||
|
|
||||||
|
return backup_path
|
||||||
|
|
||||||
|
|
||||||
|
def _selected_verdicts(
|
||||||
|
*,
|
||||||
|
competence_id: str,
|
||||||
|
verdict_ids: list[str],
|
||||||
|
verdict_log_path: Path | str,
|
||||||
|
) -> list[Dict[str, Any]]:
|
||||||
|
all_records = iter_competence_verdicts(
|
||||||
|
log_path=verdict_log_path,
|
||||||
|
competence_id=competence_id,
|
||||||
|
)
|
||||||
|
by_id = {str(record.get("verdict_id")): record for record in all_records}
|
||||||
|
missing = [verdict_id for verdict_id in verdict_ids if verdict_id not in by_id]
|
||||||
|
if missing:
|
||||||
|
raise CompetencePromotionError(
|
||||||
|
"Verdicts introuvables: " + ", ".join(missing)
|
||||||
|
)
|
||||||
|
return [by_id[verdict_id] for verdict_id in verdict_ids]
|
||||||
|
|
||||||
|
|
||||||
|
def _evidence_summary(verdicts: list[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"counts": _verdict_counts(verdicts),
|
||||||
|
"distinct_contexts": len(_distinct_contexts([
|
||||||
|
verdict for verdict in verdicts
|
||||||
|
if verdict.get("verdict_kind") == "valid"
|
||||||
|
])),
|
||||||
|
"verdicts": [
|
||||||
|
{
|
||||||
|
"verdict_id": verdict.get("verdict_id"),
|
||||||
|
"verdict_kind": verdict.get("verdict_kind"),
|
||||||
|
"verdict_at": verdict.get("verdict_at"),
|
||||||
|
"workflow_id": verdict.get("workflow_id", ""),
|
||||||
|
"machine_id": (verdict.get("context_signature") or {}).get("machine_id", ""),
|
||||||
|
"step_results_count": len(verdict.get("step_results") or []),
|
||||||
|
}
|
||||||
|
for verdict in verdicts
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _verdict_counts(verdicts: list[Dict[str, Any]]) -> Dict[str, int]:
|
||||||
|
return {
|
||||||
|
"valid": sum(1 for item in verdicts if item.get("verdict_kind") == "valid"),
|
||||||
|
"invalid": sum(1 for item in verdicts if item.get("verdict_kind") == "invalid"),
|
||||||
|
"inconclusive": sum(
|
||||||
|
1 for item in verdicts if item.get("verdict_kind") == "inconclusive"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _distinct_contexts(verdicts: list[Dict[str, Any]]) -> set[str]:
|
||||||
|
contexts: set[str] = set()
|
||||||
|
for verdict in verdicts:
|
||||||
|
context = verdict.get("context_signature") or {}
|
||||||
|
parts = [
|
||||||
|
str(context.get("machine_id") or ""),
|
||||||
|
str(context.get("os_name") or ""),
|
||||||
|
str(context.get("os_version") or ""),
|
||||||
|
str(context.get("keyboard_layout") or ""),
|
||||||
|
str(context.get("screen_resolution") or ""),
|
||||||
|
str(context.get("scaling") or ""),
|
||||||
|
str(context.get("app_name") or ""),
|
||||||
|
str(context.get("app_version") or ""),
|
||||||
|
str(context.get("screen_state_initial") or ""),
|
||||||
|
str(context.get("screen_state_after_action") or ""),
|
||||||
|
]
|
||||||
|
contexts.add("|".join(parts))
|
||||||
|
return contexts
|
||||||
|
|
||||||
|
|
||||||
|
def _regression_suspected(verdicts: list[Dict[str, Any]]) -> bool:
|
||||||
|
latest = sorted(
|
||||||
|
verdicts,
|
||||||
|
key=lambda item: str(item.get("verdict_at") or ""),
|
||||||
|
reverse=True,
|
||||||
|
)[:3]
|
||||||
|
return len(latest) == 3 and all(
|
||||||
|
item.get("verdict_kind") == "invalid" for item in latest
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_explained(verdict: Dict[str, Any]) -> bool:
|
||||||
|
evidence = verdict.get("evidence") if isinstance(verdict.get("evidence"), dict) else {}
|
||||||
|
if evidence.get("explained") is True:
|
||||||
|
return True
|
||||||
|
return bool(str(verdict.get("comments") or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _available_targets(current_state: str) -> list[str]:
|
||||||
|
if current_state == "observed":
|
||||||
|
return ["candidate"]
|
||||||
|
if current_state == "candidate":
|
||||||
|
return ["stable"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _target_state(payload: Dict[str, Any]) -> str:
|
||||||
|
target = _text(payload.get("target_state"), "target_state")
|
||||||
|
if target not in PROMOTABLE_STATES:
|
||||||
|
raise CompetencePromotionError("target_state doit etre candidate ou stable")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _promotion_id(payload: Dict[str, Any], *, dry_run: bool) -> str:
|
||||||
|
value = payload.get("promotion_id")
|
||||||
|
if value is None and dry_run:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
text = _text(value, "promotion_id")
|
||||||
|
_validate_uuid(text, field="promotion_id")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _verdict_ids(value: Any) -> list[str]:
|
||||||
|
if not isinstance(value, list) or not value:
|
||||||
|
raise CompetencePromotionError("verdict_ids doit etre une liste non vide")
|
||||||
|
verdict_ids: list[str] = []
|
||||||
|
for item in value:
|
||||||
|
text = _text(item, "verdict_id")
|
||||||
|
_validate_uuid(text, field="verdict_id")
|
||||||
|
verdict_ids.append(text)
|
||||||
|
return verdict_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any, field: str) -> str:
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise CompetencePromotionError(f"{field} requis")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_uuid(value: str, *, field: str) -> None:
|
||||||
|
try:
|
||||||
|
parsed = uuid.UUID(value, version=4)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise CompetencePromotionError(f"{field} doit etre un UUID v4") from exc
|
||||||
|
if str(parsed) != value.lower():
|
||||||
|
raise CompetencePromotionError(f"{field} UUID v4 invalide")
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp(now: Optional[datetime]) -> str:
|
||||||
|
timestamp = now or datetime.now(timezone.utc)
|
||||||
|
if timestamp.tzinfo is None:
|
||||||
|
timestamp = timestamp.replace(tzinfo=timezone.utc)
|
||||||
|
return timestamp.astimezone(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _dry_run_token(
|
||||||
|
*,
|
||||||
|
promotion_id: str,
|
||||||
|
competence_id: str,
|
||||||
|
target_state: str,
|
||||||
|
verdict_ids: list[str],
|
||||||
|
source_text: str,
|
||||||
|
updated_text: str,
|
||||||
|
) -> str:
|
||||||
|
payload = {
|
||||||
|
"promotion_id": promotion_id,
|
||||||
|
"competence_id": competence_id,
|
||||||
|
"target_state": target_state,
|
||||||
|
"verdict_ids": verdict_ids,
|
||||||
|
"source_hash": hashlib.sha256(source_text.encode("utf-8")).hexdigest(),
|
||||||
|
"updated_hash": hashlib.sha256(updated_text.encode("utf-8")).hexdigest(),
|
||||||
|
}
|
||||||
|
raw = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
||||||
|
return hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _find_existing_promotion(
|
||||||
|
promotion_id: str,
|
||||||
|
*,
|
||||||
|
log_path: Path,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
for record in iter_competence_promotions(log_path=log_path):
|
||||||
|
if record.get("promotion_id") == promotion_id:
|
||||||
|
return record
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_yaml_mapping(path: Path) -> Dict[str, Any]:
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
data = yaml.safe_load(handle) or {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise CompetencePromotionError(f"{path} doit contenir un objet YAML")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _absolute_source_path(source_path: str) -> Path:
|
||||||
|
path = Path(source_path)
|
||||||
|
if path.is_absolute():
|
||||||
|
return path
|
||||||
|
return REPO_ROOT / path
|
||||||
|
|
||||||
|
|
||||||
|
def _relative_path(path: Path) -> str:
|
||||||
|
try:
|
||||||
|
return str(path.resolve().relative_to(REPO_ROOT.resolve()))
|
||||||
|
except ValueError:
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_verdict_at(verdicts: list[Dict[str, Any]]) -> str:
|
||||||
|
values = [str(item.get("verdict_at") or "") for item in verdicts]
|
||||||
|
return max(values) if values else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _append_jsonl(log_path: Path, record: Dict[str, Any]) -> None:
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with log_path.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(record, ensure_ascii=False, sort_keys=True))
|
||||||
|
handle.write("\n")
|
||||||
213
tests/unit/test_competence_promotions.py
Normal file
213
tests/unit/test_competence_promotions.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.competences.promotions import (
|
||||||
|
CompetencePromotionError,
|
||||||
|
iter_competence_promotions,
|
||||||
|
promote_competence_from_verdicts,
|
||||||
|
summarize_competence_promotions,
|
||||||
|
)
|
||||||
|
from core.competences.verdicts import store_competence_verdict
|
||||||
|
|
||||||
|
|
||||||
|
def _write_competence(root, state="observed", competence_id="demo_competence"):
|
||||||
|
state_dir = root / state
|
||||||
|
state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = state_dir / f"{competence_id}.yaml"
|
||||||
|
path.write_text(
|
||||||
|
"\n".join([
|
||||||
|
"schema_version: 1",
|
||||||
|
f"id: {competence_id}",
|
||||||
|
"name: Demo competence",
|
||||||
|
f"learning_state: {state}",
|
||||||
|
"intent:",
|
||||||
|
" fr: demo competence",
|
||||||
|
"methods: []",
|
||||||
|
"promotion:",
|
||||||
|
" candidate_requires:",
|
||||||
|
" - cleaned_segment_validated",
|
||||||
|
" stable_requires:",
|
||||||
|
" min_successes: 3",
|
||||||
|
" distinct_contexts: 3",
|
||||||
|
" max_unexplained_failures: 0",
|
||||||
|
"generalisation:",
|
||||||
|
" seen_contexts: []",
|
||||||
|
"failure_log: []",
|
||||||
|
"created_at: '2026-05-29T00:00:00+00:00'",
|
||||||
|
"last_updated_at: '2026-05-29T00:00:00+00:00'",
|
||||||
|
]),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_verdict(root, log_path, competence_id="demo_competence", machine="machine_a"):
|
||||||
|
verdict_id = str(uuid4())
|
||||||
|
return store_competence_verdict(
|
||||||
|
competence_id,
|
||||||
|
{
|
||||||
|
"verdict_id": verdict_id,
|
||||||
|
"verdict_kind": "valid",
|
||||||
|
"verdict_by": "human:dom",
|
||||||
|
"workflow_id": f"workflow_{machine}",
|
||||||
|
"step_results": [
|
||||||
|
{"step_id": "step_1", "action_type": "keyboard_shortcut", "status": "success"}
|
||||||
|
],
|
||||||
|
"context_signature": {
|
||||||
|
"machine_id": machine,
|
||||||
|
"screen_state_initial": f"before_{machine}",
|
||||||
|
"screen_state_after_action": f"after_{machine}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
competence_root=root,
|
||||||
|
log_path=log_path,
|
||||||
|
now=datetime(2026, 5, 29, 17, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_promote_competence_dry_run_does_not_write_yaml(tmp_path):
|
||||||
|
root = tmp_path / "competences"
|
||||||
|
source = _write_competence(root, state="observed")
|
||||||
|
verdict_log = tmp_path / "verdicts.jsonl"
|
||||||
|
verdict = _valid_verdict(root, verdict_log)
|
||||||
|
before = source.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
result = promote_competence_from_verdicts(
|
||||||
|
"demo_competence",
|
||||||
|
{
|
||||||
|
"promotion_id": str(uuid4()),
|
||||||
|
"target_state": "candidate",
|
||||||
|
"verdict_ids": [verdict["verdict_id"]],
|
||||||
|
"confirmed_by": "human:dom",
|
||||||
|
"dry_run": True,
|
||||||
|
},
|
||||||
|
competence_root=root,
|
||||||
|
verdict_log_path=verdict_log,
|
||||||
|
promotion_log_path=tmp_path / "promotions.jsonl",
|
||||||
|
now=datetime(2026, 5, 29, 18, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["dry_run"] is True
|
||||||
|
assert result["eligible"] is True
|
||||||
|
assert result["write_applied"] is False
|
||||||
|
assert "learning_state: candidate" in result["yaml_diff"]
|
||||||
|
assert source.read_text(encoding="utf-8") == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_promote_competence_confirm_writes_backup_and_audit(tmp_path):
|
||||||
|
root = tmp_path / "competences"
|
||||||
|
source = _write_competence(root, state="observed")
|
||||||
|
verdict_log = tmp_path / "verdicts.jsonl"
|
||||||
|
promotion_log = tmp_path / "promotions.jsonl"
|
||||||
|
verdict = _valid_verdict(root, verdict_log)
|
||||||
|
promotion_id = str(uuid4())
|
||||||
|
|
||||||
|
dry_run = promote_competence_from_verdicts(
|
||||||
|
"demo_competence",
|
||||||
|
{
|
||||||
|
"promotion_id": promotion_id,
|
||||||
|
"target_state": "candidate",
|
||||||
|
"verdict_ids": [verdict["verdict_id"]],
|
||||||
|
"confirmed_by": "human:dom",
|
||||||
|
"dry_run": True,
|
||||||
|
},
|
||||||
|
competence_root=root,
|
||||||
|
verdict_log_path=verdict_log,
|
||||||
|
promotion_log_path=promotion_log,
|
||||||
|
now=datetime(2026, 5, 29, 18, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = promote_competence_from_verdicts(
|
||||||
|
"demo_competence",
|
||||||
|
{
|
||||||
|
"promotion_id": promotion_id,
|
||||||
|
"target_state": "candidate",
|
||||||
|
"verdict_ids": [verdict["verdict_id"]],
|
||||||
|
"confirmed_by": "human:dom",
|
||||||
|
"dry_run": False,
|
||||||
|
"dry_run_token": dry_run["dry_run_token"],
|
||||||
|
},
|
||||||
|
competence_root=root,
|
||||||
|
verdict_log_path=verdict_log,
|
||||||
|
promotion_log_path=promotion_log,
|
||||||
|
now=datetime(2026, 5, 29, 18, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
target = root / "candidate" / "demo_competence.yaml"
|
||||||
|
assert result["write_applied"] is True
|
||||||
|
assert target.exists()
|
||||||
|
assert not source.exists()
|
||||||
|
written = target.read_text(encoding="utf-8")
|
||||||
|
assert "learning_state: candidate" in written
|
||||||
|
assert verdict["verdict_id"] in written
|
||||||
|
assert list(source.parent.glob("demo_competence.yaml.*.bak"))
|
||||||
|
promotions = iter_competence_promotions(log_path=promotion_log)
|
||||||
|
assert promotions[0]["promotion_id"] == promotion_id
|
||||||
|
assert promotions[0]["yaml_write"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_promote_competence_requires_prior_dry_run_token(tmp_path):
|
||||||
|
root = tmp_path / "competences"
|
||||||
|
_write_competence(root, state="observed")
|
||||||
|
verdict_log = tmp_path / "verdicts.jsonl"
|
||||||
|
verdict = _valid_verdict(root, verdict_log)
|
||||||
|
|
||||||
|
with pytest.raises(CompetencePromotionError, match="dry_run_token"):
|
||||||
|
promote_competence_from_verdicts(
|
||||||
|
"demo_competence",
|
||||||
|
{
|
||||||
|
"promotion_id": str(uuid4()),
|
||||||
|
"target_state": "candidate",
|
||||||
|
"verdict_ids": [verdict["verdict_id"]],
|
||||||
|
"confirmed_by": "human:dom",
|
||||||
|
"dry_run": False,
|
||||||
|
},
|
||||||
|
competence_root=root,
|
||||||
|
verdict_log_path=verdict_log,
|
||||||
|
promotion_log_path=tmp_path / "promotions.jsonl",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stable_promotion_requires_three_distinct_contexts(tmp_path):
|
||||||
|
root = tmp_path / "competences"
|
||||||
|
_write_competence(root, state="candidate")
|
||||||
|
verdict_log = tmp_path / "verdicts.jsonl"
|
||||||
|
verdict = _valid_verdict(root, verdict_log, machine="machine_a")
|
||||||
|
|
||||||
|
result = promote_competence_from_verdicts(
|
||||||
|
"demo_competence",
|
||||||
|
{
|
||||||
|
"promotion_id": str(uuid4()),
|
||||||
|
"target_state": "stable",
|
||||||
|
"verdict_ids": [verdict["verdict_id"]],
|
||||||
|
"confirmed_by": "human:dom",
|
||||||
|
"dry_run": True,
|
||||||
|
},
|
||||||
|
competence_root=root,
|
||||||
|
verdict_log_path=verdict_log,
|
||||||
|
promotion_log_path=tmp_path / "promotions.jsonl",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["eligible"] is False
|
||||||
|
assert any("3 verdicts valid requis" in reason for reason in result["blocking_reasons"])
|
||||||
|
assert any("3 contextes distincts requis" in reason for reason in result["blocking_reasons"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_competence_promotions_reports_eligible_candidate(tmp_path):
|
||||||
|
root = tmp_path / "competences"
|
||||||
|
_write_competence(root, state="observed")
|
||||||
|
verdict_log = tmp_path / "verdicts.jsonl"
|
||||||
|
verdict = _valid_verdict(root, verdict_log)
|
||||||
|
|
||||||
|
summary = summarize_competence_promotions(
|
||||||
|
competence_root=root,
|
||||||
|
verdict_log_path=verdict_log,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary[0]["id"] == "demo_competence"
|
||||||
|
assert summary[0]["verdict_counts"]["valid"] == 1
|
||||||
|
candidate = summary[0]["eligible_targets"]["candidate"]
|
||||||
|
assert candidate["eligible"] is True
|
||||||
|
assert candidate["recommended_verdict_ids"] == [verdict["verdict_id"]]
|
||||||
@@ -54,6 +54,14 @@ class TestDashboardRoutes:
|
|||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
assert 'faiss' in data
|
assert 'faiss' in data
|
||||||
|
|
||||||
|
def test_knowledge_base_stats_include_competences(self, client):
|
||||||
|
"""La base de connaissances expose les competences supervisees."""
|
||||||
|
resp = client.get('/api/knowledge-base/stats')
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert 'competences' in data
|
||||||
|
assert 'items' in data['competences']
|
||||||
|
|
||||||
def test_version(self, client):
|
def test_version(self, client):
|
||||||
"""L'API version retourne la version actuelle."""
|
"""L'API version retourne la version actuelle."""
|
||||||
resp = client.get('/api/version')
|
resp = client.get('/api/version')
|
||||||
|
|||||||
@@ -107,3 +107,42 @@ def test_list_competence_verdicts_endpoint(monkeypatch):
|
|||||||
assert data["verdicts"] == [
|
assert data["verdicts"] == [
|
||||||
{"competence_id": "key_win_r_wait_explorer_exe", "verdict_kind": "valid"}
|
{"competence_id": "key_win_r_wait_explorer_exe", "verdict_kind": "valid"}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_promote_competence_endpoint_dry_run(monkeypatch):
|
||||||
|
def fake_promote(competence_id, payload):
|
||||||
|
assert competence_id == "key_win_r_wait_explorer_exe"
|
||||||
|
assert payload["dry_run"] is True
|
||||||
|
return {
|
||||||
|
"promotion_id": payload["promotion_id"],
|
||||||
|
"competence_id": competence_id,
|
||||||
|
"target_state": "candidate",
|
||||||
|
"dry_run": True,
|
||||||
|
"eligible": True,
|
||||||
|
"dry_run_token": "token",
|
||||||
|
"write_applied": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
lea_competences_module,
|
||||||
|
"promote_competence_from_verdicts",
|
||||||
|
fake_promote,
|
||||||
|
)
|
||||||
|
|
||||||
|
with _app().test_client() as client:
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/lea/competences/key_win_r_wait_explorer_exe/promote",
|
||||||
|
json={
|
||||||
|
"promotion_id": "123e4567-e89b-42d3-a456-426614174000",
|
||||||
|
"target_state": "candidate",
|
||||||
|
"verdict_ids": ["123e4567-e89b-42d3-a456-426614174001"],
|
||||||
|
"confirmed_by": "human:dom",
|
||||||
|
"dry_run": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["dry_run"] is True
|
||||||
|
assert data["yaml_write"] is False
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ from core.competences.verdicts import (
|
|||||||
iter_competence_verdicts,
|
iter_competence_verdicts,
|
||||||
store_competence_verdict,
|
store_competence_verdict,
|
||||||
)
|
)
|
||||||
|
from core.competences.promotions import (
|
||||||
|
CompetencePromotionError,
|
||||||
|
promote_competence_from_verdicts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
lea_competences_bp = Blueprint(
|
lea_competences_bp = Blueprint(
|
||||||
@@ -59,3 +63,41 @@ def list_competence_verdicts(competence_id: str):
|
|||||||
"write_back_enabled": False,
|
"write_back_enabled": False,
|
||||||
"yaml_write": False,
|
"yaml_write": False,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@lea_competences_bp.route("/<competence_id>/promote", methods=["POST"])
|
||||||
|
def promote_competence(competence_id: str):
|
||||||
|
"""Dry-run or apply a supervised dashboard promotion."""
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
promotion = promote_competence_from_verdicts(competence_id, payload)
|
||||||
|
except KeyError:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": f"Competence '{competence_id}' introuvable",
|
||||||
|
}), 404
|
||||||
|
except CompetencePromotionError as exc:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": str(exc),
|
||||||
|
"write_back_enabled": False,
|
||||||
|
"yaml_write": False,
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
status = 200 if promotion.get("dry_run") or promotion.get("duplicate") else 201
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"competence_id": competence_id,
|
||||||
|
"promotion": _public_promotion_payload(promotion),
|
||||||
|
"dry_run": promotion.get("dry_run", False),
|
||||||
|
"write_back_enabled": not promotion.get("dry_run", False),
|
||||||
|
"yaml_write": bool(promotion.get("write_applied", False)),
|
||||||
|
}), status
|
||||||
|
|
||||||
|
|
||||||
|
def _public_promotion_payload(promotion: dict):
|
||||||
|
return {
|
||||||
|
key: value for key, value in promotion.items()
|
||||||
|
if not str(key).startswith("_")
|
||||||
|
}
|
||||||
|
|||||||
@@ -2465,10 +2465,56 @@ def knowledge_base_stats():
|
|||||||
'sessions': _kb_sessions_stats(),
|
'sessions': _kb_sessions_stats(),
|
||||||
'patterns': _kb_patterns_stats(),
|
'patterns': _kb_patterns_stats(),
|
||||||
'workflows': _kb_workflows_stats(),
|
'workflows': _kb_workflows_stats(),
|
||||||
|
'competences': _kb_competences_stats(),
|
||||||
}
|
}
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/v1/lea/competences/<competence_id>/promote', methods=['POST'])
|
||||||
|
def dashboard_promote_competence(competence_id):
|
||||||
|
"""Dry-run or apply a supervised competence promotion from the dashboard."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from core.competences.promotions import (
|
||||||
|
CompetencePromotionError,
|
||||||
|
promote_competence_from_verdicts,
|
||||||
|
)
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
promotion = promote_competence_from_verdicts(competence_id, payload)
|
||||||
|
except KeyError:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': f"Competence '{competence_id}' introuvable",
|
||||||
|
}), 404
|
||||||
|
except CompetencePromotionError as exc:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(exc),
|
||||||
|
'write_back_enabled': False,
|
||||||
|
'yaml_write': False,
|
||||||
|
}), 400
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(exc),
|
||||||
|
'write_back_enabled': False,
|
||||||
|
'yaml_write': False,
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
status = 200 if promotion.get('dry_run') or promotion.get('duplicate') else 201
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'competence_id': competence_id,
|
||||||
|
'promotion': {
|
||||||
|
key: value for key, value in promotion.items()
|
||||||
|
if not str(key).startswith('_')
|
||||||
|
},
|
||||||
|
'dry_run': promotion.get('dry_run', False),
|
||||||
|
'write_back_enabled': not promotion.get('dry_run', False),
|
||||||
|
'yaml_write': bool(promotion.get('write_applied', False)),
|
||||||
|
}), status
|
||||||
|
|
||||||
|
|
||||||
def _kb_faiss_stats() -> dict:
|
def _kb_faiss_stats() -> dict:
|
||||||
"""Statistiques de l'index FAISS."""
|
"""Statistiques de l'index FAISS."""
|
||||||
faiss_index_path = DATA_PATH / "faiss_index" / "main.index"
|
faiss_index_path = DATA_PATH / "faiss_index" / "main.index"
|
||||||
@@ -2597,6 +2643,29 @@ def _kb_workflows_stats() -> dict:
|
|||||||
return {'total': total}
|
return {'total': total}
|
||||||
|
|
||||||
|
|
||||||
|
def _kb_competences_stats() -> dict:
|
||||||
|
"""Statistiques des compétences Lea YAML et verdicts supervisés."""
|
||||||
|
try:
|
||||||
|
from core.competences.promotions import summarize_competence_promotions
|
||||||
|
competences = summarize_competence_promotions()
|
||||||
|
by_state = {}
|
||||||
|
for item in competences:
|
||||||
|
state = item.get('learning_state', 'unknown')
|
||||||
|
by_state[state] = by_state.get(state, 0) + 1
|
||||||
|
return {
|
||||||
|
'total': len(competences),
|
||||||
|
'by_state': by_state,
|
||||||
|
'items': competences,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
'total': 0,
|
||||||
|
'by_state': {},
|
||||||
|
'items': [],
|
||||||
|
'error': str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _dir_size(path: Path) -> int:
|
def _dir_size(path: Path) -> int:
|
||||||
"""Calcule la taille totale d'un dossier (non récursif profond pour la perf)."""
|
"""Calcule la taille totale d'un dossier (non récursif profond pour la perf)."""
|
||||||
total = 0
|
total = 0
|
||||||
|
|||||||
@@ -89,6 +89,74 @@
|
|||||||
justify-content: center; font-size: 11px; font-weight: 700;
|
justify-content: center; font-size: 11px; font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.state-badge {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
min-width: 78px; padding: 4px 8px; border-radius: 6px;
|
||||||
|
border: 1px solid #334155; background: #0f172a;
|
||||||
|
color: #cbd5e1; font-size: 11px; font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.state-badge.observed { color: #fbbf24; border-color: #92400e; }
|
||||||
|
.state-badge.candidate { color: #93c5fd; border-color: #1d4ed8; }
|
||||||
|
.state-badge.stable { color: #6ee7b7; border-color: #047857; }
|
||||||
|
.verdict-strip { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.verdict-pill {
|
||||||
|
display: inline-flex; gap: 5px; align-items: center;
|
||||||
|
padding: 4px 7px; border-radius: 6px; background: #0f172a;
|
||||||
|
border: 1px solid #334155; font-size: 12px;
|
||||||
|
}
|
||||||
|
.verdict-pill.valid { color: #6ee7b7; }
|
||||||
|
.verdict-pill.invalid { color: #fca5a5; }
|
||||||
|
.verdict-pill.inconclusive { color: #fcd34d; }
|
||||||
|
.action-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.action-btn {
|
||||||
|
border: 1px solid #2563eb; background: #1d4ed8; color: #fff;
|
||||||
|
border-radius: 6px; padding: 7px 10px; font-size: 12px;
|
||||||
|
font-weight: 700; cursor: pointer; min-height: 32px;
|
||||||
|
}
|
||||||
|
.action-btn:hover:not(:disabled) { background: #2563eb; }
|
||||||
|
.action-btn:disabled {
|
||||||
|
cursor: not-allowed; opacity: 0.45; border-color: #475569;
|
||||||
|
background: #1e293b; color: #94a3b8;
|
||||||
|
}
|
||||||
|
.modal-backdrop {
|
||||||
|
display: none; position: fixed; inset: 0; z-index: 50;
|
||||||
|
background: rgba(15,23,42,0.82); padding: 24px;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-backdrop.visible { display: flex; }
|
||||||
|
.modal {
|
||||||
|
width: min(980px, 96vw); max-height: 90vh; overflow: auto;
|
||||||
|
background: #1e293b; border: 1px solid #475569;
|
||||||
|
border-radius: 8px; box-shadow: 0 24px 70px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 16px 18px; border-bottom: 1px solid #334155;
|
||||||
|
}
|
||||||
|
.modal-title { font-size: 17px; font-weight: 700; color: #f8fafc; }
|
||||||
|
.modal-close {
|
||||||
|
border: 1px solid #475569; background: #0f172a; color: #cbd5e1;
|
||||||
|
border-radius: 6px; padding: 5px 8px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.modal-body { padding: 18px; }
|
||||||
|
.diff-box {
|
||||||
|
white-space: pre-wrap; overflow-x: auto; background: #020617;
|
||||||
|
border: 1px solid #334155; border-radius: 6px; padding: 12px;
|
||||||
|
color: #cbd5e1; font-size: 12px; line-height: 1.4;
|
||||||
|
max-height: 320px;
|
||||||
|
}
|
||||||
|
.evidence-list { margin: 10px 0 16px; color: #cbd5e1; font-size: 13px; }
|
||||||
|
.modal-actions {
|
||||||
|
display: flex; justify-content: flex-end; gap: 10px;
|
||||||
|
padding: 14px 18px; border-top: 1px solid #334155;
|
||||||
|
}
|
||||||
|
.secondary-btn {
|
||||||
|
border: 1px solid #475569; background: #0f172a; color: #cbd5e1;
|
||||||
|
border-radius: 6px; padding: 8px 12px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.danger-note { color: #fcd34d; font-size: 12px; margin-top: 8px; }
|
||||||
|
|
||||||
/* === Loading === */
|
/* === Loading === */
|
||||||
.loading {
|
.loading {
|
||||||
text-align: center; padding: 40px; color: #64748b; font-size: 14px;
|
text-align: center; padding: 40px; color: #64748b; font-size: 14px;
|
||||||
@@ -193,7 +261,48 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section 4 : Workflows -->
|
<!-- Section 4 : Compétences apprises par supervision -->
|
||||||
|
<div class="section-title"><span class="icon">🧪</span> Compétences apprises par supervision</div>
|
||||||
|
|
||||||
|
<div class="grid-3" id="competencesGrid">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="stat-value" id="competenceTotal">--</div>
|
||||||
|
<div class="stat-label">Compétences YAML</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="stat-value success" id="competenceCandidate">--</div>
|
||||||
|
<div class="stat-label">Candidates</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="stat-value warning" id="competenceObserved">--</div>
|
||||||
|
<div class="stat-label">Observées</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="competencesCard">
|
||||||
|
<h2><span class="icon">📌</span> Promotions supervisées</h2>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
Les boutons ci-dessous font un dry-run, affichent le diff YAML, puis demandent confirmation. Aucun write-back silencieux.
|
||||||
|
</div>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="kb-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Compétence</th>
|
||||||
|
<th>État</th>
|
||||||
|
<th>Verdicts</th>
|
||||||
|
<th>Contextes</th>
|
||||||
|
<th>Promotion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="competencesTableBody">
|
||||||
|
<tr><td colspan="5" class="loading">Chargement...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 5 : Workflows -->
|
||||||
<div class="section-title"><span class="icon">🔄</span> Workflows</div>
|
<div class="section-title"><span class="icon">🔄</span> Workflows</div>
|
||||||
|
|
||||||
<div class="grid-3" id="workflowsGrid">
|
<div class="grid-3" id="workflowsGrid">
|
||||||
@@ -205,9 +314,33 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="promotionModal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="promotionTitle">Promotion supervisée</div>
|
||||||
|
<button class="modal-close" onclick="closePromotionModal()">Fermer</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="promotionStatus"></div>
|
||||||
|
<h2 style="font-size:14px;margin:14px 0 8px;color:#94a3b8;">Verdicts utilisés</h2>
|
||||||
|
<div class="evidence-list" id="promotionEvidence">--</div>
|
||||||
|
<h2 style="font-size:14px;margin:14px 0 8px;color:#94a3b8;">Diff YAML</h2>
|
||||||
|
<pre class="diff-box" id="promotionDiff">Chargement...</pre>
|
||||||
|
<div class="danger-note">Le write-back ne s'exécute qu'après confirmation explicite.</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="secondary-btn" onclick="closePromotionModal()">Annuler</button>
|
||||||
|
<button class="action-btn" id="confirmPromotionButton" onclick="confirmPromotion()" disabled>Confirmer la promotion</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', loadKnowledgeBase);
|
document.addEventListener('DOMContentLoaded', loadKnowledgeBase);
|
||||||
|
|
||||||
|
let knowledgeBaseData = null;
|
||||||
|
let currentPromotion = null;
|
||||||
|
|
||||||
async function loadKnowledgeBase() {
|
async function loadKnowledgeBase() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/knowledge-base/stats');
|
const resp = await fetch('/api/knowledge-base/stats');
|
||||||
@@ -216,7 +349,9 @@ async function loadKnowledgeBase() {
|
|||||||
renderFaiss(data.faiss);
|
renderFaiss(data.faiss);
|
||||||
renderSessions(data.sessions);
|
renderSessions(data.sessions);
|
||||||
renderPatterns(data.patterns);
|
renderPatterns(data.patterns);
|
||||||
|
renderCompetences(data.competences);
|
||||||
renderWorkflows(data.workflows);
|
renderWorkflows(data.workflows);
|
||||||
|
knowledgeBaseData = data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erreur chargement base de connaissances:', err);
|
console.error('Erreur chargement base de connaissances:', err);
|
||||||
}
|
}
|
||||||
@@ -283,11 +418,169 @@ function renderWorkflows(workflows) {
|
|||||||
document.getElementById('workflowCount').textContent = workflows.total.toLocaleString('fr-FR');
|
document.getElementById('workflowCount').textContent = workflows.total.toLocaleString('fr-FR');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCompetences(competences) {
|
||||||
|
const items = competences?.items || [];
|
||||||
|
const byState = competences?.by_state || {};
|
||||||
|
document.getElementById('competenceTotal').textContent = (competences?.total || 0).toLocaleString('fr-FR');
|
||||||
|
document.getElementById('competenceCandidate').textContent = (byState.candidate || 0).toLocaleString('fr-FR');
|
||||||
|
document.getElementById('competenceObserved').textContent = (byState.observed || 0).toLocaleString('fr-FR');
|
||||||
|
|
||||||
|
const tbody = document.getElementById('competencesTableBody');
|
||||||
|
if (items.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" style="color:#64748b;text-align:center;padding:20px;">Aucune compétence YAML chargée</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = items.map(item => {
|
||||||
|
const targetEntries = Object.entries(item.eligible_targets || {});
|
||||||
|
const buttons = targetEntries.length === 0
|
||||||
|
? '<span style="color:#64748b;font-size:12px;">Aucune promotion disponible</span>'
|
||||||
|
: targetEntries.map(([target, info]) => {
|
||||||
|
const title = (info.blocking_reasons || []).join(' · ') || `Promouvoir vers ${target}`;
|
||||||
|
const verdictIds = JSON.stringify(info.recommended_verdict_ids || []).replace(/"/g, '"');
|
||||||
|
return `<button class="action-btn" ${info.eligible ? '' : 'disabled'} title="${escapeHtml(title)}" onclick="openPromotion('${escapeAttr(item.id)}','${escapeAttr(target)}',${verdictIds})">Promouvoir ${escapeHtml(target)}</button>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const counts = item.verdict_counts || {};
|
||||||
|
const warning = item.regression_suspected
|
||||||
|
? '<div class="alert alert-warning" style="margin:8px 0 0;padding:7px 9px;">Régression suspectée</div>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>${escapeHtml(item.id)}</strong>
|
||||||
|
<div style="color:#94a3b8;font-size:12px;margin-top:4px;">${escapeHtml(item.intent_fr || item.name || '')}</div>
|
||||||
|
${warning}
|
||||||
|
</td>
|
||||||
|
<td><span class="state-badge ${escapeAttr(item.learning_state)}">${escapeHtml(item.learning_state)}</span></td>
|
||||||
|
<td>
|
||||||
|
<div class="verdict-strip">
|
||||||
|
<span class="verdict-pill valid">${counts.valid || 0} valid</span>
|
||||||
|
<span class="verdict-pill invalid">${counts.invalid || 0} invalid</span>
|
||||||
|
<span class="verdict-pill inconclusive">${counts.inconclusive || 0} incertain</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>${item.distinct_contexts || 0}</td>
|
||||||
|
<td><div class="action-row">${buttons}</div></td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPromotion(competenceId, targetState, verdictIds) {
|
||||||
|
currentPromotion = null;
|
||||||
|
document.getElementById('promotionModal').classList.add('visible');
|
||||||
|
document.getElementById('promotionTitle').textContent = `Promotion ${competenceId} -> ${targetState}`;
|
||||||
|
document.getElementById('promotionStatus').innerHTML = '<div class="alert alert-info">Dry-run en cours...</div>';
|
||||||
|
document.getElementById('promotionEvidence').textContent = '--';
|
||||||
|
document.getElementById('promotionDiff').textContent = 'Chargement...';
|
||||||
|
document.getElementById('confirmPromotionButton').disabled = true;
|
||||||
|
|
||||||
|
const promotionId = newPromotionId();
|
||||||
|
const payload = {
|
||||||
|
promotion_id: promotionId,
|
||||||
|
target_state: targetState,
|
||||||
|
verdict_ids: verdictIds,
|
||||||
|
confirmed_by: 'human:dom',
|
||||||
|
dry_run: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/v1/lea/competences/${encodeURIComponent(competenceId)}/promote`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || !data.success) throw new Error(data.error || resp.statusText);
|
||||||
|
currentPromotion = {
|
||||||
|
competenceId,
|
||||||
|
payload: {
|
||||||
|
...payload,
|
||||||
|
dry_run: false,
|
||||||
|
dry_run_token: data.promotion.dry_run_token,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
renderPromotionDryRun(data.promotion);
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('promotionStatus').innerHTML = `<div class="alert alert-warning">${escapeHtml(err.message || String(err))}</div>`;
|
||||||
|
document.getElementById('promotionDiff').textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPromotionDryRun(promotion) {
|
||||||
|
const evidence = promotion.evidence_summary?.verdicts || [];
|
||||||
|
document.getElementById('promotionEvidence').innerHTML = evidence.length
|
||||||
|
? evidence.map(v => `
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(String(v.verdict_id || '').slice(0, 8))}</strong>
|
||||||
|
· ${escapeHtml(v.verdict_kind || '')}
|
||||||
|
· ${escapeHtml(v.machine_id || 'machine inconnue')}
|
||||||
|
· steps ${v.step_results_count || 0}
|
||||||
|
</div>
|
||||||
|
`).join('')
|
||||||
|
: '<span style="color:#64748b;">Aucun verdict utilisable</span>';
|
||||||
|
document.getElementById('promotionDiff').textContent = promotion.yaml_diff || '(aucun changement)';
|
||||||
|
if (promotion.eligible) {
|
||||||
|
document.getElementById('promotionStatus').innerHTML = "<div class=\"alert alert-success\">Dry-run OK. Le YAML n'a pas été modifié.</div>";
|
||||||
|
document.getElementById('confirmPromotionButton').disabled = false;
|
||||||
|
} else {
|
||||||
|
const reasons = (promotion.blocking_reasons || []).join(' · ');
|
||||||
|
document.getElementById('promotionStatus').innerHTML = `<div class="alert alert-warning">Promotion bloquée : ${escapeHtml(reasons)}</div>`;
|
||||||
|
document.getElementById('confirmPromotionButton').disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmPromotion() {
|
||||||
|
if (!currentPromotion) return;
|
||||||
|
const button = document.getElementById('confirmPromotionButton');
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = 'Promotion...';
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/v1/lea/competences/${encodeURIComponent(currentPromotion.competenceId)}/promote`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(currentPromotion.payload),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || !data.success) throw new Error(data.error || resp.statusText);
|
||||||
|
document.getElementById('promotionStatus').innerHTML = '<div class="alert alert-success">Promotion appliquée. Backup et audit trail enregistrés.</div>';
|
||||||
|
setTimeout(() => {
|
||||||
|
closePromotionModal();
|
||||||
|
loadKnowledgeBase();
|
||||||
|
}, 900);
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('promotionStatus').innerHTML = `<div class="alert alert-warning">${escapeHtml(err.message || String(err))}</div>`;
|
||||||
|
button.disabled = false;
|
||||||
|
} finally {
|
||||||
|
button.textContent = 'Confirmer la promotion';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePromotionModal() {
|
||||||
|
document.getElementById('promotionModal').classList.remove('visible');
|
||||||
|
currentPromotion = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newPromotionId() {
|
||||||
|
if (window.crypto?.randomUUID) return window.crypto.randomUUID();
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, char => {
|
||||||
|
const value = Math.floor(Math.random() * 16);
|
||||||
|
const resolved = char === 'x' ? value : (value & 0x3) | 0x8;
|
||||||
|
return resolved.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.appendChild(document.createTextNode(text));
|
div.appendChild(document.createTextNode(text));
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeAttr(text) {
|
||||||
|
return String(text).replace(/[^a-zA-Z0-9_.:-]/g, '');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user