Triage des échecs CI
Source: showcase/ci_failure_triage See in Playground
Deux étapes CI s’exécutent sur des conteneurs provisionnés, et la sortie brute de chaque étape est classifiée de façon déterministe par une petite fonction JavaScript avant toute autre chose. Seule l’étape que le classificateur signale effectivement comme échouée est confiée à un LLM distant pour un diagnostic en langage clair et une suggestion de correction ; l’étape jugée correcte par le classificateur ne coûte rien de plus que la classification elle-même, aucune requête LLM n’est faite pour elle.
Nécessite un jeton API Mélodium Services et une clé API d’un fournisseur LLM. Placez MELODIUM_API_TOKEN dans l’environnement et exécutez avec --api-report ; voir Cadence.CI pour obtenir un jeton et suivre l’exécution.
Exécution
cd showcase/ci_failure_triage
export MELODIUM_API_TOKEN="my-melodium-services-token"
melodium run --api-report Compo.toml --api_key sk-...Exécute deux étapes sur l’image standard python:3.13-slim, sans rien à cloner ni à installer :
unit_tests: trois assertions triviales, toujours vraies. Réussit toujours.integration_check: lit un dictionnaire de configuration via une clé mal orthographiée ('tiemout'au lieu de'timeout'). Échoue toujours avec uneKeyError.
Les deux sont délibérément déterministes, si bien que cet exemple s’exécute de la même façon à chaque fois, indépendamment de l’état d’un dépôt externe quelconque. Voici à quoi ressemble ci_failure_report.md :
## unit_tests
Status: **passed**
Classification: `{"category":"none","status":"passed","summary":"no known failure signature found in the step output"}`
No issues detected by the deterministic classifier; no AI analysis was requested for this step.
## integration_check
Status: **failed**
Classification: `{"category":"key_error","status":"failed","summary":"A dictionary key does not exist: tiemout"}`
## Root Cause
The script contains a typo in the dictionary key lookup: `config['tiemout']` instead of `config['timeout']`. Python raises a `KeyError` because `'tiemout'` does not exist as a key in the `config` dictionary. The first `print` succeeds (retries prints correctly), but execution halts on the second `print` when it tries to access the misspelled key.
## Suggested Fix
Correct the typo in the key name from `'tiemout'` to `'timeout'`:
```python
# Before (broken)
print('effective timeout:', config['tiemout'])
# After (fixed)
print('effective timeout:', config['timeout'])
```
This is a one-character transposition (`ie` to `ei`) and is the only change needed.Fonctionnement
Un modèle JavaScriptEngine contient le classificateur déterministe, qui compare la sortie capturée d’une étape à une courte liste de signatures d’échec connues :
model Classifier() : JavaScriptEngine {
code = ${{function classify(log) {
var signatures = [
{ re: /KeyError:\s*'([^']*)'/, category: "key_error", summary: "A dictionary key does not exist" },
{ re: /TypeError:\s*(.+)/, category: "type_error", summary: "A type error occurred" },
{ re: /ModuleNotFoundError|ImportError/, category: "dependency", summary: "A required module or dependency is missing" },
{ re: /SyntaxError:\s*(.+)/, category: "syntax_error", summary: "A syntax error was found" },
{ re: /AssertionError/, category: "assertion", summary: "An assertion failed" },
{ re: /Traceback \(most recent call last\)/, category: "runtime_error", summary: "An unhandled exception occurred" }
];
for (var i = 0; i < signatures.length; i++) {
var match = log.match(signatures[i].re);
if (match) {
var detail = match[1] ? (": " + match[1]) : "";
return {
status: "failed",
category: signatures[i].category,
summary: signatures[i].summary + detail
};
}
}
return {
status: "passed",
category: "none",
summary: "no known failure signature found in the step output"
};
}
function statusOf(decision) {
return decision.status;
}
}}
}Un unique RemoteLlm, Analyst, sert à expliquer et suggérer une correction, uniquement pour une étape déjà signalée comme échouée par le classificateur. Contrairement à showcase/smart_llm_router, il n’y a qu’un seul niveau ici, car le levier d’économie de cet exemple n’est pas « quel modèle » mais « faut-il appeler un modèle du tout ».
Capturer la sortie indépendamment du code de sortie
La commande réelle de chaque étape redirige vers un fichier et se termine par ; true, si bien que le conteneur sort toujours avec le code 0 et que le journal est toujours renvoyé en flux via data ; c’est ce traitement qui décide ensuite lui-même, à partir du texte capturé, si l’étape a réellement réussi, plutôt que de faire confiance au code de sortie du conteneur :
unitTests: ciStepAnalysis[dispatcher=dispatcher, classifier=classifier, analyst=analyst](
step_name = "unit_tests",
image = "python:3.13-slim",
commands = [|command("sh", ["-c", "python3 -c \"assert 1 + 1 == 2; assert 'melodium'.upper() == 'MELODIUM'; assert len([1, 2, 3]) == 3; print('all checks passed')\" > /mnt/data/log.txt 2>&1; true"])]
)
integrationCheck: ciStepAnalysis[dispatcher=dispatcher, classifier=classifier, analyst=analyst](
step_name = "integration_check",
image = "python:3.13-slim",
commands = [|command("sh", ["-c", "python3 -c \"config = {'timeout': 30, 'retries': 3}; print('effective retries:', config['retries']); print('effective timeout:', config['tiemout'])\" > /mnt/data/log.txt 2>&1; true"])]
)Classifier avant de dépenser une requête
Le journal capturé est décodé, réduit à un Block<string>, transformé en Json, et transmis à la fonction JS classify() ; un second petit appel JS, statusOf(decision), ne projette que le champ status, le même pipeline JS en deux étapes que showcase/smart_llm_router :
classifyCall: process[engine=classifier](code="classify(value)")
asJson.json -> classifyCall.value
decisionOpt: unwrapOr<Json>(default=|null())
classifyCall.result -> decisionOpt.optionDeux portes equalTo plus filterBlock routent ensuite selon ce statut : failed fait passer le journal brut vers le prompt du LLM ; passed fait passer à la place un message fixe et gratuit :
isFailed: equalTo<string>(value="failed")
statusLast.last -> isFailed.data
gateFailedLog: filterBlock<string>()
logLast.last -> gateFailedLog.value
isFailed.result -> gateFailedLog.select
diagnosisPrompt: stream<string>()
gateFailedLog.accepted -> diagnosisPrompt.block
diagnosis: llmChat[llm=analyst]()
diagnosisPrompt.stream -> diagnosis.promptisPassed: equalTo<string>(value="passed")
statusLast.last -> isPassed.data
passedMessage: emit<string>(value="No issues detected by the deterministic classifier; no AI analysis was requested for this step.")
Self.trigger -> passedMessage.trigger
gatePassed: filterBlock<string>()
passedMessage.emit -> gatePassed.value
isPassed.result -> gatePassed.selectExactement une seule porte transporte quelque chose, donc exactement un des deux cas, « appeler le LLM » ou « dire que rien n’est nécessaire », se produit, et fusionner les deux branches (majoritairement vides) ne fait que produire celle qui s’est déclenchée. chat, et non stream, est utilisé ici car il renvoie une réponse complète par prompt plutôt que des tokens au fur et à mesure, le bon choix quand le résultat va dans un rapport plutôt que vers une connexion en direct.
Assembler le rapport
La section de chaque étape (nom, statut, classification complète, et l’analyse ou le message fixe) est assemblée avec blockEntry/blockInsert/format, et les sections des deux étapes sont combinées de la même façon une fois de plus au niveau supérieur avant d’être écrites dans ci_failure_report.md :
sectionEntries: stream<StringMap>()
sectionText: format(format="## {step}\n\nStatus: **{status}**\nClassification: `{classification}`\n\n{analysis}\n\n")
sectionLast: trigger<string>()
withAnalysis.map -> sectionEntries.block,stream -> sectionText.entries,formatted -> sectionLast.stream
sectionLast.last -> Self.reportDépendances
[dependencies]
std = "0.10.3" # flux de base, journalisation, structures de données
process = "0.10.3" # exécution de processus externes
work = "0.10.3" # provisionnement de runners cloud
cicd = "0.10.3" # dispatch et orchestration d'étapes CI/CD
encoding = "0.10.3" # encodage / décodage UTF-8
json = "0.10.3" # analyse et sérialisation JSON
javascript = "0.10.3" # moteur JavaScript embarqué
ml = "0.10.3" # LLM, STT, TTS et inférence de modèles locaux
fs = "0.10.3" # lecture/écriture de fichiers locaux