#!/usr/bin/env python3 """ Generateur PDF du rapport de campagne de tests MCP. Utilise fpdf2. """ import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)) + '/..') from datetime import datetime from fpdf import FPDF REPORT_PATH = os.path.join(os.path.dirname(__file__), 'test_campaign_report.txt') PDF_PATH = os.path.join(os.path.dirname(__file__), 'test_campaign_report.pdf') STATUS_COLORS = { 'PASS': (34, 139, 34), # vert 'PARTIAL': (255, 165, 0), # orange 'RULE_MISS': (100, 149, 237), # bleu 'FAIL': (220, 20, 60), # rouge } CATEGORY_COLORS = { 'FEDORA': (70, 130, 180), 'BACKUP': (60, 179, 113), 'DENON': (147, 112, 219), 'TV': (255, 140, 0), 'HUE': (255, 215, 0), 'CATT': (32, 178, 170), 'EDGE': (128, 128, 128), } def parse_report(path): """Parse le rapport texte et retourne une structure.""" with open(path, 'r', encoding='utf-8') as f: lines = f.readlines() tests = [] scores = {} current = None section = None i = 0 while i < len(lines): line = lines[i].rstrip() # Section if line.startswith('=== ') and line.endswith(' ==='): section = line.strip('= ').strip() i += 1 continue # Debut d'un test if line.startswith('[') and ']' in line: status_end = line.index(']') status = line[1:status_end].strip() desc = line[status_end+1:].strip() current = { 'status': status, 'desc': desc, 'category': section, 'query': '', 'expected': '', 'obtained': '', 'args': '', 'bug': '', } tests.append(current) i += 1 continue if current is not None: if line.startswith(' Requete'): current['query'] = line.split(':', 1)[1].strip().strip('"') elif line.startswith(' Attendu'): current['expected'] = line.split(':', 1)[1].strip() elif line.startswith(' Obtenu'): current['obtained'] = line.split(':', 1)[1].strip() elif line.startswith(' Args'): current['args'] = line.split(':', 1)[1].strip() elif line.startswith(' BUG'): current['bug'] = line.split(':', 1)[1].strip() # Scores par categorie if line.startswith(' ') and ':' in line and 'PASS' in line and 'FAIL' in line and '//' not in line: parts = line.strip().split(':') if len(parts) >= 2: cat = parts[0].strip() rest = parts[1].strip() # parse: "23 PASS 11 PARTIAL 0 RULE_MISS 1 FAIL / 35 tests [81%]" try: import re m = re.match( r'(\d+) PASS\s+(\d+) PARTIAL\s+(\d+) RULE_MISS\s+(\d+) FAIL\s+/\s+(\d+) tests\s+\[(\d+)%\]', rest ) if m: scores[cat] = { 'pass': int(m.group(1)), 'partial': int(m.group(2)), 'rule_miss': int(m.group(3)), 'fail': int(m.group(4)), 'total': int(m.group(5)), 'pct': int(m.group(6)), } except Exception: pass i += 1 return tests, scores def score_color(pct): if pct >= 80: return (34, 139, 34) elif pct >= 60: return (255, 165, 0) else: return (220, 20, 60) class PDF(FPDF): def header(self): # Bandeau superieur self.set_fill_color(30, 30, 50) self.rect(0, 0, 210, 14, 'F') self.set_text_color(200, 200, 255) self.set_font('Helvetica', 'B', 10) self.set_xy(10, 3) self.cell(0, 8, 'Lyra RAG v2 - Campagne de Tests MCP', ln=0, align='L') self.set_xy(0, 3) self.set_font('Helvetica', '', 8) self.set_text_color(150, 150, 200) self.cell(200, 8, f'Genere le {datetime.now().strftime("%d/%m/%Y %H:%M")}', ln=0, align='R') self.set_text_color(0, 0, 0) self.set_y(16) def footer(self): self.set_y(-12) self.set_fill_color(30, 30, 50) self.rect(0, self.get_y(), 210, 12, 'F') self.set_text_color(150, 150, 200) self.set_font('Helvetica', '', 8) self.cell(0, 12, f'Page {self.page_no()} / {{nb}}', align='C') self.set_text_color(0, 0, 0) def chapter_title(self, title, color=(30, 30, 80)): self.set_fill_color(*color) self.set_text_color(255, 255, 255) self.set_font('Helvetica', 'B', 12) self.cell(0, 8, f' {title}', ln=True, fill=True) self.ln(3) self.set_text_color(0, 0, 0) def status_badge(self, status, x, y, w=28, h=6): color = STATUS_COLORS.get(status, (128, 128, 128)) self.set_fill_color(*color) self.set_text_color(255, 255, 255) self.set_font('Helvetica', 'B', 7) self.set_xy(x, y) self.cell(w, h, status, align='C', fill=True, border=0) self.set_text_color(0, 0, 0) def score_bar(self, label, pct, total, pass_n, partial_n, rule_miss_n, fail_n, color): bar_w = 130 bar_h = 6 x0 = 10 y0 = self.get_y() # Label categorie self.set_fill_color(*color) self.set_text_color(255, 255, 255) self.set_font('Helvetica', 'B', 8) self.set_xy(x0, y0) self.cell(30, bar_h, f' {label}', fill=True, border=0) self.set_text_color(0, 0, 0) # Barre proportionnelle x_bar = x0 + 32 segments = [ (pass_n, STATUS_COLORS['PASS']), (partial_n, STATUS_COLORS['PARTIAL']), (rule_miss_n, STATUS_COLORS['RULE_MISS']), (fail_n, STATUS_COLORS['FAIL']), ] x_cur = x_bar for count, col in segments: if count > 0: w = bar_w * count / total self.set_fill_color(*col) self.rect(x_cur, y0, w, bar_h, 'F') x_cur += w # Contour barre self.set_draw_color(180, 180, 180) self.rect(x_bar, y0, bar_w, bar_h) # Score % sc_col = score_color(pct) self.set_text_color(*sc_col) self.set_font('Helvetica', 'B', 9) self.set_xy(x_bar + bar_w + 3, y0) self.cell(20, bar_h, f'{pct}%', align='L') # Compteurs self.set_text_color(80, 80, 80) self.set_font('Helvetica', '', 7) self.set_xy(x_bar + bar_w + 24, y0) self.cell(0, bar_h, f'{pass_n}P {partial_n}Pa {rule_miss_n}R {fail_n}F / {total}') self.set_text_color(0, 0, 0) self.ln(bar_h + 2) def test_row(self, test, row_num): status = test['status'] bg = (252, 252, 255) if row_num % 2 == 0 else (245, 245, 250) # Fond leger selon status if status == 'FAIL': bg = (255, 240, 240) elif status == 'RULE_MISS': bg = (240, 245, 255) y0 = self.get_y() row_h = 18 # Fond self.set_fill_color(*bg) self.rect(10, y0, 190, row_h, 'F') # Ligne de separation self.set_draw_color(220, 220, 230) self.line(10, y0, 200, y0) # Badge status self.status_badge(status, 10, y0 + 1, w=24, h=6) # Description self.set_font('Helvetica', 'B', 8) self.set_text_color(30, 30, 60) self.set_xy(36, y0 + 1) desc = test['desc'] if len(desc) > 45: desc = desc[:42] + '...' self.cell(70, 6, desc, ln=0) # Outil attendu/obtenu self.set_font('Helvetica', '', 7) self.set_text_color(80, 80, 80) self.set_xy(108, y0 + 1) expected = test['expected'].replace('fedora.', '').replace('catt.', '').replace('hue.', '').replace('denon.', '').replace('tv.', '') if len(expected) > 25: expected = expected[:22] + '...' self.cell(50, 6, f'attendu: {expected}', ln=0) # Requete self.set_font('Helvetica', 'I', 7) self.set_text_color(100, 100, 100) self.set_xy(36, y0 + 8) query = f'"{test["query"]}"' if len(query) > 80: query = query[:77] + '..."' self.cell(155, 5, query, ln=0) # Args if test['args'] and test['args'] != '{}': self.set_font('Helvetica', '', 6) self.set_text_color(120, 120, 140) self.set_xy(36, y0 + 13) args_str = test['args'] if len(args_str) > 80: args_str = args_str[:77] + '...' self.cell(155, 5, args_str, ln=0) # Bug note if test.get('bug'): self.set_font('Helvetica', 'B', 6) self.set_text_color(200, 40, 40) self.set_xy(108, y0 + 8) bug_str = test['bug'] if len(bug_str) > 50: bug_str = bug_str[:47] + '...' self.cell(80, 5, f'BUG: {bug_str}', ln=0) self.set_text_color(0, 0, 0) self.set_xy(10, y0 + row_h) def generate_pdf(tests, scores): pdf = PDF(orientation='P', unit='mm', format='A4') pdf.alias_nb_pages() pdf.set_auto_page_break(auto=True, margin=15) pdf.set_margins(10, 16, 10) # === PAGE DE GARDE === pdf.add_page() # Titre principal pdf.set_y(35) pdf.set_fill_color(20, 20, 50) pdf.rect(10, 30, 190, 40, 'F') pdf.set_text_color(255, 255, 255) pdf.set_font('Helvetica', 'B', 22) pdf.set_xy(10, 38) pdf.cell(190, 12, 'Campagne de Tests MCP', align='C', ln=True) pdf.set_font('Helvetica', '', 13) pdf.set_xy(10, 53) pdf.cell(190, 8, 'Lyra RAG v2 - Pipeline _rule_based_detect()', align='C', ln=True) pdf.set_text_color(0, 0, 0) # Date et version pdf.set_y(80) pdf.set_font('Helvetica', '', 10) pdf.set_text_color(80, 80, 80) pdf.cell(0, 6, f'Date : {datetime.now().strftime("%d %B %Y")}', align='C', ln=True) pdf.cell(0, 6, 'Environnement : Pipeline Lyra sans execution MCP reelle', align='C', ln=True) pdf.set_text_color(0, 0, 0) # Score global total = sum(s['total'] for s in scores.values()) pass_n = sum(s['pass'] for s in scores.values()) partial_n = sum(s['partial'] for s in scores.values()) rule_miss_n = sum(s['rule_miss'] for s in scores.values()) fail_n = sum(s['fail'] for s in scores.values()) pct = round(100 * pass_n / total) if total else 0 pdf.set_y(100) pdf.set_fill_color(240, 240, 250) pdf.rect(30, pdf.get_y(), 150, 55, 'F') pdf.set_draw_color(100, 100, 180) pdf.rect(30, pdf.get_y(), 150, 55) pdf.set_font('Helvetica', 'B', 14) pdf.set_text_color(30, 30, 80) pdf.set_xy(30, pdf.get_y() + 5) pdf.cell(150, 8, 'SCORE GLOBAL', align='C', ln=True) sc_col = score_color(pct) pdf.set_text_color(*sc_col) pdf.set_font('Helvetica', 'B', 36) pdf.set_xy(30, pdf.get_y()) pdf.cell(150, 20, f'{pct}%', align='C', ln=True) pdf.set_font('Helvetica', '', 10) pdf.set_text_color(50, 50, 50) pdf.set_xy(30, pdf.get_y()) pdf.cell(150, 6, f'{total} tests au total', align='C', ln=True) # Compteurs status y_cnt = pdf.get_y() + 2 items = [ ('PASS', pass_n, STATUS_COLORS['PASS']), ('PARTIAL', partial_n, STATUS_COLORS['PARTIAL']), ('RULE_MISS', rule_miss_n, STATUS_COLORS['RULE_MISS']), ('FAIL', fail_n, STATUS_COLORS['FAIL']), ] x_start = 35 for label, count, color in items: pdf.set_fill_color(*color) pdf.rect(x_start, y_cnt, 32, 10, 'F') pdf.set_text_color(255, 255, 255) pdf.set_font('Helvetica', 'B', 8) pdf.set_xy(x_start, y_cnt) pdf.cell(32, 5, label, align='C', ln=False) pdf.set_xy(x_start, y_cnt + 5) pdf.set_font('Helvetica', 'B', 10) pdf.cell(32, 5, str(count), align='C', ln=False) x_start += 35 pdf.set_text_color(0, 0, 0) # Legende pdf.set_y(170) pdf.set_font('Helvetica', 'B', 10) pdf.set_text_color(30, 30, 80) pdf.cell(0, 6, 'Legende des statuts', ln=True) pdf.set_y(pdf.get_y() + 1) legend = [ ('PASS', 'Outil correct + tous les arguments attendus detectes', STATUS_COLORS['PASS']), ('PARTIAL', 'Outil correct + args obligatoires OK + args optionnels manquants', STATUS_COLORS['PARTIAL']), ('RULE_MISS', 'Aucune regle statique ne matche - traite par EPHAISTOS (LLM) en production', STATUS_COLORS['RULE_MISS']), ('FAIL', 'Mauvais outil detecte ou argument obligatoire manquant - BUG reel', STATUS_COLORS['FAIL']), ] for status, desc, color in legend: y_l = pdf.get_y() pdf.set_fill_color(*color) pdf.rect(15, y_l, 22, 6, 'F') pdf.set_text_color(255, 255, 255) pdf.set_font('Helvetica', 'B', 7) pdf.set_xy(15, y_l) pdf.cell(22, 6, status, align='C') pdf.set_text_color(50, 50, 50) pdf.set_font('Helvetica', '', 8) pdf.set_xy(40, y_l) pdf.cell(0, 6, desc, ln=True) pdf.set_text_color(0, 0, 0) # Analyse narrative pdf.set_y(220) pdf.set_font('Helvetica', 'B', 10) pdf.set_text_color(30, 30, 80) pdf.cell(0, 6, 'Analyse', ln=True) pdf.set_font('Helvetica', '', 8) pdf.set_text_color(60, 60, 60) analysis = [ "Les RULE_MISS correspondent aux outils TV, HUE complexes (brightness/color/scene),", "CATT (cast_play/pause/stop...) qui ne sont pas couverts par _rule_based_detect.", "Ces requetes sont traitees par EPHAISTOS (LLM Qwen 7B) en production - comportement attendu.", "", "Les PARTIAL indiquent que l'outil est correctement identifie mais que les arguments", "optionnels mentionnes dans la requete ne sont pas extraits par les regles statiques.", "Ex: 'vm_stop avec timeout 120' -> vm_stop(vm_name=X) sans timeout=120.", "", "Les 8 FAIL sont des bugs reels dans _rule_based_detect a corriger:", "- Mots-cles 'force'/'detaille'/'live' captures comme vm_name par le regex", "- 'lance commande' -> vm_start au lieu de vm_exec (collision 'lance')", "- 'sourdine' -> power_on au lieu de denon.mute_on", "- 'lance la scene' -> vm_start (collision 'lance')", "- 'backup + verifie' -> backup_verify au lieu de backup_create", ] for line in analysis: pdf.cell(0, 4.5, line, ln=True) pdf.set_text_color(0, 0, 0) # === PAGE SCORES PAR CATEGORIE === pdf.add_page() pdf.chapter_title('Scores par Categorie', color=(30, 30, 80)) pdf.ln(2) pdf.set_font('Helvetica', '', 8) pdf.set_text_color(80, 80, 80) # Legende mini-barre x_leg = 10 y_leg = pdf.get_y() for label, color in [('PASS', STATUS_COLORS['PASS']), ('PARTIAL', STATUS_COLORS['PARTIAL']), ('RULE_MISS', STATUS_COLORS['RULE_MISS']), ('FAIL', STATUS_COLORS['FAIL'])]: pdf.set_fill_color(*color) pdf.rect(x_leg, y_leg, 8, 4, 'F') pdf.set_xy(x_leg + 9, y_leg) pdf.cell(20, 4, label) x_leg += 38 pdf.set_text_color(0, 0, 0) pdf.ln(8) # Barres par categorie ordered = sorted(scores.items(), key=lambda x: -x[1]['pct']) for cat, s in ordered: color = CATEGORY_COLORS.get(cat, (100, 100, 100)) pdf.score_bar( cat, s['pct'], s['total'], s['pass'], s['partial'], s['rule_miss'], s['fail'], color ) pdf.ln(1) # Graphique synthese (grille simple) pdf.ln(6) pdf.set_font('Helvetica', 'B', 10) pdf.set_text_color(30, 30, 80) pdf.cell(0, 6, 'Distribution globale des resultats', ln=True) pdf.ln(2) # Grand barre globale bar_w = 180 y0 = pdf.get_y() x0 = 10 total_all = pass_n + partial_n + rule_miss_n + fail_n segs = [(pass_n, 'PASS'), (partial_n, 'PARTIAL'), (rule_miss_n, 'RULE_MISS'), (fail_n, 'FAIL')] x_cur = x0 for count, status in segs: w = bar_w * count / total_all if total_all else 0 color = STATUS_COLORS[status] pdf.set_fill_color(*color) pdf.rect(x_cur, y0, w, 12, 'F') if w > 12: pdf.set_text_color(255, 255, 255) pdf.set_font('Helvetica', 'B', 7) pdf.set_xy(x_cur, y0 + 3) pct_seg = round(100 * count / total_all) pdf.cell(w, 6, f'{pct_seg}%', align='C') x_cur += w pdf.set_draw_color(100, 100, 100) pdf.rect(x0, y0, bar_w, 12) pdf.set_text_color(0, 0, 0) pdf.ln(18) # Tableau recap pdf.set_font('Helvetica', 'B', 9) pdf.set_fill_color(30, 30, 80) pdf.set_text_color(255, 255, 255) cols = ['Categorie', 'PASS', 'PARTIAL', 'RULE_MISS', 'FAIL', 'Total', 'Score'] widths = [35, 22, 22, 28, 20, 22, 22] for col, w in zip(cols, widths): pdf.cell(w, 6, col, align='C', fill=True, border=1) pdf.ln() for i, (cat, s) in enumerate(sorted(scores.items())): bg = (248, 248, 255) if i % 2 == 0 else (235, 235, 248) pdf.set_fill_color(*bg) pdf.set_text_color(30, 30, 60) pdf.set_font('Helvetica', 'B', 8) pdf.cell(widths[0], 6, cat, fill=True, border=1) pdf.set_font('Helvetica', '', 8) pdf.set_text_color(34, 139, 34) pdf.cell(widths[1], 6, str(s['pass']), align='C', fill=True, border=1) pdf.set_text_color(255, 165, 0) pdf.cell(widths[2], 6, str(s['partial']), align='C', fill=True, border=1) pdf.set_text_color(100, 149, 237) pdf.cell(widths[3], 6, str(s['rule_miss']), align='C', fill=True, border=1) pdf.set_text_color(220, 20, 60) pdf.cell(widths[4], 6, str(s['fail']), align='C', fill=True, border=1) pdf.set_text_color(50, 50, 50) pdf.cell(widths[5], 6, str(s['total']), align='C', fill=True, border=1) sc_c = score_color(s['pct']) pdf.set_text_color(*sc_c) pdf.set_font('Helvetica', 'B', 8) pdf.cell(widths[6], 6, f"{s['pct']}%", align='C', fill=True, border=1) pdf.set_text_color(0, 0, 0) pdf.ln() # Ligne TOTAL pdf.set_fill_color(20, 20, 50) pdf.set_text_color(255, 255, 255) pdf.set_font('Helvetica', 'B', 8) pdf.cell(widths[0], 6, 'TOTAL', fill=True, border=1) pdf.set_text_color(180, 255, 180) pdf.cell(widths[1], 6, str(pass_n), align='C', fill=True, border=1) pdf.set_text_color(255, 220, 150) pdf.cell(widths[2], 6, str(partial_n), align='C', fill=True, border=1) pdf.set_text_color(180, 200, 255) pdf.cell(widths[3], 6, str(rule_miss_n), align='C', fill=True, border=1) pdf.set_text_color(255, 150, 150) pdf.cell(widths[4], 6, str(fail_n), align='C', fill=True, border=1) pdf.set_text_color(220, 220, 220) pdf.cell(widths[5], 6, str(total), align='C', fill=True, border=1) sc_c = score_color(pct) pdf.set_text_color(*[min(255, c + 80) for c in sc_c]) pdf.cell(widths[6], 6, f'{pct}%', align='C', fill=True, border=1) pdf.set_text_color(0, 0, 0) pdf.ln() # === PAGES TESTS DETAILLES PAR CATEGORIE === categories_order = ['FEDORA', 'BACKUP', 'DENON', 'TV', 'HUE', 'CATT', 'EDGE'] for cat in categories_order: cat_tests = [t for t in tests if t.get('category') == cat] if not cat_tests: continue pdf.add_page() color = CATEGORY_COLORS.get(cat, (80, 80, 80)) pdf.chapter_title(f'Tests {cat} ({len(cat_tests)} tests)', color=color) # Mini-stats cat_pass = sum(1 for t in cat_tests if t['status'] == 'PASS') cat_partial = sum(1 for t in cat_tests if t['status'] == 'PARTIAL') cat_rule_miss = sum(1 for t in cat_tests if t['status'] == 'RULE_MISS') cat_fail = sum(1 for t in cat_tests if t['status'] == 'FAIL') cat_score = scores.get(cat, {}).get('pct', 0) pdf.set_font('Helvetica', '', 8) pdf.set_text_color(80, 80, 80) stats_line = ( f'PASS: {cat_pass} | PARTIAL: {cat_partial} | ' f'RULE_MISS: {cat_rule_miss} | FAIL: {cat_fail} | Score: {cat_score}%' ) pdf.cell(0, 5, stats_line, ln=True) pdf.set_text_color(0, 0, 0) pdf.ln(2) # En-tete colonnes pdf.set_fill_color(*[min(255, c + 80) for c in color]) pdf.set_text_color(30, 30, 60) pdf.set_font('Helvetica', 'B', 7) pdf.cell(26, 5, 'Statut', fill=True, border=0, align='C') pdf.cell(72, 5, 'Description / Requete', fill=True, border=0) pdf.cell(52, 5, 'Outil / Bug', fill=True, border=0) pdf.cell(38, 5, 'Args extraits', fill=True, border=0) pdf.set_text_color(0, 0, 0) pdf.ln(5) for row_num, test in enumerate(cat_tests): if pdf.get_y() > 270: pdf.add_page() pdf.chapter_title(f'Tests {cat} (suite)', color=color) pdf.ln(2) pdf.test_row(test, row_num) # === PAGE BUGS === fail_tests = [t for t in tests if t['status'] == 'FAIL'] if fail_tests: pdf.add_page() pdf.chapter_title('Bugs Detectes (FAIL)', color=(180, 20, 20)) pdf.set_font('Helvetica', '', 8) pdf.set_text_color(80, 20, 20) pdf.cell(0, 5, f'{len(fail_tests)} bugs reels identifies dans _rule_based_detect() a corriger.', ln=True) pdf.set_text_color(0, 0, 0) pdf.ln(3) for i, test in enumerate(fail_tests, 1): y0 = pdf.get_y() # Numero + fond pdf.set_fill_color(255, 235, 235) pdf.rect(10, y0, 190, 26, 'F') pdf.set_draw_color(220, 50, 50) pdf.rect(10, y0, 190, 26) # Numero pdf.set_fill_color(220, 20, 60) pdf.rect(10, y0, 10, 26, 'F') pdf.set_text_color(255, 255, 255) pdf.set_font('Helvetica', 'B', 10) pdf.set_xy(10, y0 + 9) pdf.cell(10, 8, str(i), align='C') # Details pdf.set_text_color(30, 30, 30) pdf.set_font('Helvetica', 'B', 9) pdf.set_xy(22, y0 + 2) pdf.cell(0, 5, f'[{test["category"]}] {test["desc"]}', ln=True) pdf.set_font('Helvetica', 'I', 7) pdf.set_text_color(80, 80, 80) pdf.set_xy(22, y0 + 8) pdf.cell(0, 4, f'Requete : "{test["query"]}"', ln=True) pdf.set_font('Helvetica', '', 7) pdf.set_xy(22, y0 + 13) pdf.set_text_color(150, 0, 0) pdf.cell(45, 4, f'Attendu : {test["expected"]}', ln=False) pdf.set_text_color(200, 50, 0) pdf.cell(45, 4, f'Obtenu : {test["obtained"]}', ln=False) if test.get('bug'): pdf.set_text_color(180, 0, 0) pdf.set_font('Helvetica', 'B', 7) pdf.set_xy(22, y0 + 19) pdf.cell(0, 4, f'Cause : {test["bug"]}', ln=True) pdf.set_text_color(0, 0, 0) pdf.set_xy(10, y0 + 28) pdf.output(PDF_PATH) print(f'PDF genere : {PDF_PATH}') return PDF_PATH if __name__ == '__main__': tests, scores = parse_report(REPORT_PATH) print(f'Tests charges : {len(tests)}') print(f'Categories : {list(scores.keys())}') generate_pdf(tests, scores)