""" Linters determinísticos, travas Fail-Fast e auditoria integral de Pré-Voo. """ from __future__ import annotations import re from typing import Any, Dict, List, Tuple from core.contrast_wcag import contrast_ratio from core.geometry import required_gutter_mm PT_STOPWORDS = { "para", "como", "pelos", "pelas", "pelo", "pela", "nos", "nas", "dos", "das", "com", "sem", "mais", "menos", "entre", "sobre", "após", "antes", "seus", "suas", "guia", "visual", } def count_words(text: str) -> int: tokens = [w for w in text.strip().split() if w] return len(tokens) def utf8_byte_length(text: str) -> int: return len(text.encode("utf-8")) def extract_significant_words(text: str) -> set[str]: words = re.findall(r"[a-zA-ZÀ-ÿ0-9]+", text.lower()) return {w for w in words if len(w) >= 4 and w not in PT_STOPWORDS} def validate_clinical_safety( spec_data: Dict[str, Any], clinical_rules: Dict[str, Any], allowed_evidence_levels: set[str] | None = None, ) -> List[str]: """ Linter Clínico Determinístico baseado em specs/rules/clinical_ground_truth.json. Bloqueia telefones estrangeiros alucinados, recomendações afirmativas de condutas fatais (Lista Vermelha) e classificações de evidência fora do padrão oficial. """ errors: List[str] = [] if not clinical_rules: return errors # Coleta segmentos textuais de copywriting, checklists, títulos e briefing segments: List[str] = [] bicolor = spec_data.get("bicolor_title", {}) if isinstance(bicolor, dict): for k in ("context_text", "hook_text"): if bicolor.get(k): segments.append(str(bicolor[k])) if spec_data.get("immediate_action_5s"): segments.append(str(spec_data["immediate_action_5s"])) for block in spec_data.get("text_blocks", []): if isinstance(block, dict) and block.get("text"): segments.append(str(block["text"])) for chk in spec_data.get("checklists", []): if isinstance(chk, dict): for item in chk.get("items", []): if item: segments.append(str(item)) # Subdivide blocos em sentenças para avaliação contextual de negação sentences: List[str] = [] for seg in segments: parts = [p.strip() for p in re.split(r"[.!?;]\s+", seg) if p.strip()] sentences.extend(parts if parts else [seg.strip()]) # 1. Bloqueio de números de emergência estrangeiros (ex.: 911, 112, 999) foreign_nums = clinical_rules.get("emergency_contacts_brazil", {}).get( "forbidden_foreign_numbers", [] ) if foreign_nums: nums_pattern = "|".join(re.escape(str(n)) for n in foreign_nums) call_re = re.compile( rf"\b(?:ligue|disque|chame|acion[ea]|emerg[êe]ncia|telefone|socorro|samu)\s+(?:para\s+|o\s+)?({nums_pattern})\b", re.IGNORECASE, ) for sent in sentences: match = call_re.search(sent) if match or re.search(r"\b911\b", sent): bad_num = match.group(1) if match else "911" errors.append( f"[CLINICAL_FOREIGN_EMERGENCY_NUMBER] Número estrangeiro '{bad_num}' detectado " "em sentença instrucional (usar 192 SAMU, 193 Bombeiros ou 0800 722 6001 ANVISA)." ) # 2. Bloqueio de contraindicações fatais recomendadas afirmativamente negations = [ tok.lower() for tok in clinical_rules.get("negation_guard_tokens", []) ] for rule in clinical_rules.get("fatal_contraindications_rules", []): rule_id = rule.get("id", "UNKNOWN_RULE") hazards = [h.lower() for h in rule.get("affirmative_hazard_terms", [])] for sent in sentences: sent_lower = sent.lower() for hazard in hazards: if hazard in sent_lower: has_negation = any( ( neg in sent_lower if not neg.isalpha() else re.search(rf"\b{re.escape(neg)}\b", sent_lower) is not None ) for neg in negations ) if not has_negation: errors.append( f"[CLINICAL_FATAL_CONTRAINDICATION] Regra {rule_id} violada por recomendação " f"afirmativa proibida ('{hazard}'): \"{sent}\"" ) break # 3. Validação dos tokens oficiais de nível de evidência (se presentes) valid_levels = allowed_evidence_levels or { "CONSENSO", "EVIDÊNCIA", "ORIENTAÇÃO_PRÁTICA", "INCERTEZA", } for claim in spec_data.get("claims_classification", []): if isinstance(claim, dict): lvl = str(claim.get("evidence_level", "")).strip() if lvl not in valid_levels: errors.append( f"[CLINICAL_INVALID_EVIDENCE_LEVEL] Nível '{lvl}' inválido em claims_classification. " f"Permitidos: {sorted(valid_levels)}." ) return errors def validate_copy_fail_fast( copy_script: Dict[str, Any], style_tokens: Dict[str, Any], ) -> List[str]: """Trava Fail-Fast da Etapa 3: impede avanço ao Wireframe/Render se o texto violar regras.""" errors: List[str] = [] density = style_tokens["editorial_density_and_wcag"] min_words = int(density["min_words_per_block"]) max_words = int(density["max_words_per_block"]) chk_min = int(density["checklist_steps_min"]) chk_max = int(density["checklist_steps_max"]) palette_cfg = style_tokens["palette"] bicolor = copy_script.get("bicolor_title", {}) if bicolor.get("context_color", "").upper() != palette_cfg["primary_green"].upper(): errors.append( f"[FAILFAST_COPY_TITLE] Contexto do título deve usar {palette_cfg['primary_green']}." ) if bicolor.get("hook_color", "").upper() != palette_cfg["accent_terracotta"].upper(): errors.append( f"[FAILFAST_COPY_TITLE] Gancho do título deve usar {palette_cfg['accent_terracotta']}." ) if not bicolor.get("context_text") or not bicolor.get("hook_text"): errors.append("[FAILFAST_COPY_TITLE] Título bicolor incompleto (contexto + gancho exigidos).") for idx, block in enumerate(copy_script.get("text_blocks", []), start=1): wc = count_words(block.get("text", "")) if wc < min_words or wc > max_words: errors.append( f"[FAILFAST_WORD_COUNT] Bloco #{idx} possui {wc} palavras " f"(janela editorial permitida: {min_words} a {max_words} palavras)." ) for idx, chk in enumerate(copy_script.get("checklists", []), start=1): items_count = len(chk.get("items", [])) if items_count < chk_min or items_count > chk_max: errors.append( f"[FAILFAST_CHECKLIST] Checklist #{idx} possui {items_count} itens " f"(permitido: {chk_min} a {chk_max})." ) return errors def validate_grid_fail_fast( wireframe: Dict[str, Any], kdp_rules: Dict[str, Any], style_tokens: Dict[str, Any], ) -> List[str]: """Trava Fail-Fast da Etapa 4: impede geração de arte se a geometria A4/KDP estiver incorreta.""" errors: List[str] = [] models = style_tokens["layout_models"] model_name = wireframe.get("layout_model", "Model_A") if model_name not in models: errors.append(f"[FAILFAST_GRID_MODEL] Modelo de layout desconhecido: {model_name}") return errors expected_model = models[model_name] partition = wireframe.get("area_partition_percent", {}) total_pct = ( int(partition.get("visual", 0)) + int(partition.get("text", 0)) + int(partition.get("negative_space", 0)) ) if total_pct != 100: errors.append(f"[FAILFAST_GRID_SUM] Partição da mancha gráfica soma {total_pct}% (exigido 100%).") if int(partition.get("negative_space", 0)) < int(expected_model["negative_space_percent"]): errors.append( f"[FAILFAST_NEGATIVE_SPACE] Espaço de respiro ({partition.get('negative_space')}%) " f"abaixo do mínimo do {model_name} ({expected_model['negative_space_percent']}%)." ) geom = wireframe.get("geometry", {}) min_safe = float(kdp_rules["safe_zone"]["with_bleed"]["min_margin_mm"]) if float(geom.get("safe_zone_mm", 0.0)) < min_safe: errors.append( f"[FAILFAST_SAFE_ZONE] Safe zone ({geom.get('safe_zone_mm')} mm) < {min_safe} mm." ) if float(geom.get("inside_bleed_mm", 0.0)) != 0.0: errors.append( f"[FAILFAST_INSIDE_BLEED] Sangria interna deve ser 0.0 mm (recebido {geom.get('inside_bleed_mm')} mm)." ) expected_out_bleed = float(kdp_rules["bleed"]["outside"]["mm"]) if round(float(geom.get("outside_bleed_mm", 0.0)), 2) != expected_out_bleed: errors.append( f"[FAILFAST_OUTSIDE_BLEED] Sangria externa ({geom.get('outside_bleed_mm')} mm) != {expected_out_bleed} mm." ) return errors def validate_preflight( page_spec: Dict[str, Any], briefing_spec: Dict[str, Any], kdp_rules: Dict[str, Any], style_tokens: Dict[str, Any], clinical_rules: Dict[str, Any] | None = None, research_spec: Dict[str, Any] | None = None, ) -> Tuple[bool, List[str]]: """ Etapa 8 — Auditoria Pré-Voo Integral (Agente 5: Pre-Flight Inspector & Compiler). Valida regras mecânicas KDP [Nível 1], segurança/paleta [Nível 2] e Copy/SEO/WCAG [Nível 3]. """ errors: List[str] = [] # 1. Fidelidade ao Briefing Funcional & SEO (Etapas 1 e 2) if not briefing_spec.get("problem_statement") or not briefing_spec.get("immediate_action_5s"): errors.append("[BRIEFING_INCOMPLETE] Briefing funcional sem problema real ou ação em <= 5s.") if not briefing_spec.get("fatal_risks_to_mitigate"): errors.append("[BRIEFING_SAFETY] Riscos fatais a mitigar não especificados no briefing.") if not briefing_spec.get("seo_target_keyword"): errors.append("[SEO_MISSING] Keyword SEO alvo ausente no briefing da prancha.") allowed_templates = set(style_tokens["briefing_and_copy_constraints"]["allowed_template_types"]) if briefing_spec.get("template_type") not in allowed_templates: errors.append( f"[BRIEFING_TEMPLATE] Gabarito '{briefing_spec.get('template_type')}' inválido. " f"Permitidos: {sorted(allowed_templates)}." ) if page_spec.get("layout_model") != briefing_spec.get("layout_model"): errors.append( f"[BRIEFING_MISMATCH] Modelo renderizado ({page_spec.get('layout_model')}) " f"diverge do briefing ({briefing_spec.get('layout_model')})." ) # 2. Tipografia Mínima KDP e Camada Vetorial min_font_pt = max( float(kdp_rules["resolution_and_typography"]["typography"]["min_font_size_pt"]), float(style_tokens["typography"]["min_font_size_pt"]), ) for element in page_spec.get("typography_elements", []): size_pt = float(element.get("size_pt", 0.0)) if size_pt < min_font_pt: errors.append( f"[FONT_TOO_SMALL] '{element.get('role', 'unknown')}' usa {size_pt} pt " f"(mínimo obrigatório KDP: {min_font_pt} pt)." ) min_line_pt = float(style_tokens["typography"]["min_callout_line_weight_pt"]) if float(page_spec.get("callout_line_weight_pt", 0.75)) < min_line_pt: errors.append( f"[LINE_TOO_THIN] Linha de chamada ({page_spec.get('callout_line_weight_pt')} pt) < {min_line_pt} pt." ) if not page_spec.get("vector_text_layer", False): errors.append("[RASTER_TEXT_FORBIDDEN] Camada tipográfica deve ser 100% vetorial (pesquisável).") # 3. Densidade de Copy, Título Bicolor e Checklists (Nível 3) density = style_tokens["editorial_density_and_wcag"] min_words = int(density["min_words_per_block"]) max_words = int(density["max_words_per_block"]) for idx, block in enumerate(page_spec.get("text_blocks", []), start=1): wc = count_words(block.get("text", "")) if wc < min_words or wc > max_words: errors.append( f"[WORD_COUNT_OUT_OF_RANGE] Bloco #{idx} tem {wc} palavras " f"(exigido entre {min_words} e {max_words} palavras)." ) chk_min = int(density["checklist_steps_min"]) chk_max = int(density["checklist_steps_max"]) for idx, checklist in enumerate(page_spec.get("checklists", []), start=1): steps = len(checklist.get("items", [])) if steps < chk_min or steps > chk_max: errors.append( f"[CHECKLIST_BOUNDS] Checklist #{idx} tem {steps} itens " f"(permitido entre {chk_min} e {chk_max})." ) bicolor = page_spec.get("bicolor_title", {}) if not bicolor.get("context_text") or not bicolor.get("hook_text"): errors.append("[COPY_BICOLOR_MISSING] Prancha sem título bicolor estruturado (contexto + gancho).") # 4. Segurança do Domínio & Paleta Oficial (Nível 2) if not page_spec.get("domain_safety_verified", False): errors.append("[SAFETY_VIOLATION] Verificação de segurança física/neonatal ausente ou reprovada.") official_palette = {hex_val.upper() for hex_val in style_tokens["palette"].values()} for used_hex in page_spec.get("colors_used", []): if used_hex.upper() not in official_palette: errors.append(f"[UNAUTHORIZED_COLOR] Cor '{used_hex}' fora dos tokens oficiais da coleção.") # 5. Contraste WCAG 2.1 AA (4.5:1 texto normal / 3.0:1 títulos grandes >= 18 pt) min_normal_contrast = float(density["wcag_contrast"]["min_normal_text_ratio"]) min_large_contrast = float(density["wcag_contrast"]["min_large_title_ratio"]) for pair in page_spec.get("color_pairs", []): fg = pair["foreground"] bg = pair["background"] is_large = bool(pair.get("is_large_title", False)) or float(pair.get("size_pt", 10.5)) >= 18.0 threshold = min_large_contrast if is_large else min_normal_contrast ratio = contrast_ratio(fg, bg) if ratio < threshold: errors.append( f"[WCAG_CONTRAST_FAIL] Par {fg} sobre {bg} atingiu {ratio}:1 " f"(mínimo WCAG 2.1 AA exigido: {threshold}:1)." ) # 6. Mecânica KDP & Formato A4 (Nível 1) geom = page_spec.get("geometry", {}) dpi = int(geom.get("dpi", 0)) min_dpi = int(kdp_rules["resolution_and_typography"]["print_min_dpi"]) if dpi < min_dpi: errors.append(f"[LOW_DPI] Resolução {dpi} DPI inferior ao mínimo KDP de {min_dpi} DPI.") expected_ratio = float(style_tokens["a4_specification"]["aspect_ratio"]) actual_ratio = round(float(geom.get("aspect_ratio", 0.0)), 4) if abs(actual_ratio - expected_ratio) > 0.002: errors.append( f"[ASPECT_RATIO_MISMATCH] Proporção {actual_ratio} diverge do A4 vertical ({expected_ratio})." ) if float(geom.get("inside_bleed_mm", 0.0)) != 0.0: errors.append( f"[INVALID_INSIDE_BLEED] Sangria interna deve ser 0.0 mm (atual: {geom.get('inside_bleed_mm')} mm)." ) if geom.get("has_bleed", True): expected_bleed = float(kdp_rules["bleed"]["outside"]["mm"]) if round(float(geom.get("outside_bleed_mm", 0.0)), 2) != expected_bleed: errors.append( f"[INVALID_OUTSIDE_BLEED] Sangria externa ({geom.get('outside_bleed_mm')} mm) != {expected_bleed} mm." ) min_safe_mm = float(kdp_rules["safe_zone"]["with_bleed"]["min_margin_mm"]) else: min_safe_mm = float(kdp_rules["safe_zone"]["without_bleed"]["min_margin_mm"]) if float(geom.get("safe_zone_mm", 0.0)) < min_safe_mm: errors.append( f"[SAFE_ZONE_VIOLATION] Margem segura ({geom.get('safe_zone_mm')} mm) < {min_safe_mm} mm." ) kdp_total_pages = int(geom.get("total_book_pages", 24)) min_gutter = required_gutter_mm(kdp_total_pages, kdp_rules) if float(geom.get("gutter_mm", 0.0)) < min_gutter: errors.append( f"[GUTTER_VIOLATION] Gutter ({geom.get('gutter_mm')} mm) < mínimo KDP ({min_gutter} mm) " f"para {kdp_total_pages} páginas." ) min_spine_pages = int(kdp_rules["spine_rules"]["min_pages_for_spine_text"]) if kdp_total_pages < min_spine_pages and bool(geom.get("spine_has_text", False)): errors.append( f"[SPINE_TEXT_PROHIBITED] Livro com {kdp_total_pages} páginas (< {min_spine_pages}) " f"não pode conter texto na lombada." ) # 7. Conformidade com a Fonte da Verdade Clínica (quando carregada) if clinical_rules: allowed_ev = set( style_tokens.get("briefing_and_copy_constraints", {}).get( "allowed_evidence_levels", ["CONSENSO", "EVIDÊNCIA", "ORIENTAÇÃO_PRÁTICA", "INCERTEZA"], ) ) errors.extend(validate_clinical_safety(page_spec, clinical_rules, allowed_ev)) if research_spec: errors.extend(validate_clinical_safety(research_spec, clinical_rules, allowed_ev)) return len(errors) == 0, errors def identify_responsible_agent(errors: List[str], cron_cfg: Dict[str, Any]) -> str: """Etapa 9 — Roteamento cirúrgico para o agente exato responsável pelo desvio.""" routing = cron_cfg["retry_policy"]["routing_by_deviation"] joined = " ".join(errors) if any( tag in joined for tag in ( "SEO_", "BRIEFING_INCOMPLETE", "BRIEFING_SAFETY", "CLINICAL_INVALID_EVIDENCE", ) ): return routing["scientific_or_seo"] if any( tag in joined for tag in ( "WORD_COUNT", "CHECKLIST_BOUNDS", "COPY_BICOLOR", "FAILFAST_COPY", "CLINICAL_FATAL_CONTRAINDICATION", "CLINICAL_FOREIGN_EMERGENCY_NUMBER", ) ): return routing["copywriting_or_word_count"] if any( tag in joined for tag in ( "SAFE_ZONE", "GUTTER", "BLEED", "ASPECT_RATIO", "SPINE_TEXT", "BRIEFING_MISMATCH", "BRIEFING_TEMPLATE", ) ): return routing["wireframe_or_margins"] if any( tag in joined for tag in ( "UNAUTHORIZED_COLOR", "WCAG_CONTRAST", "FONT_TOO_SMALL", "LOW_DPI", "SAFETY_VIOLATION", "RASTER_TEXT", "LINE_TOO_THIN", ) ): return routing["visual_palette_or_contrast"] return cron_cfg["retry_policy"]["fallback_agent_on_preflight_fail"]