"""Pipeline Step 12 — templates_v2 draft 생성 (C.2-b AI 일괄 라벨링). 사용자 지침: 1. 원본 structure_ontology.yaml 는 건드리지 않음. structure_ontology_v2.yaml 별도 파일. 2. 각 프레임에 content_affinity / structure_intent / alternative_patterns + evidence/reason. 3. 32개 전부 자동 확정하지 말고 우선 검토 5개 (12, 20, 29, 3, 11) 별도 리포트. 4. 결과 3개 산출물: (a) structure_ontology_v2.yaml (b) PRIORITY_5_REVIEW.md/html (우선 5개 상세) (c) TEMPLATES_V2_DIFF.md (v1 대비 무엇이 달라졌나) 라벨링 전략: - Layout-default heuristic (fig_layout → 기본 affinity/intent) - 내용 설명 키워드 refinement (override primary) - suits/not_suits 에서 교차 검증 신호 추출 - alternative_patterns: _COMPAT 에서 파생 + layout family 유사도 - 각 라벨에 evidence: {field, quote, confidence} 구조 AI 라벨링이지만 결정론적 (재현 가능) — LLM 호출 없이 규칙+키워드로 생성. """ import datetime import re import sys from collections import Counter, defaultdict from pathlib import Path import markdown import yaml HERE = Path(__file__).parent ROOT = HERE.parent.parent BLOCKS_DIR = ROOT / 'figma_to_html_agent' / 'blocks' sys.path.insert(0, str(HERE)) from phase_common import _COMPAT ONTOLOGY_V1_PATH = HERE / 'structure_ontology.yaml' OUT_V2_PATH = HERE / 'structure_ontology_v2.yaml' PRIORITY_MD = HERE / 'PRIORITY_5_REVIEW.md' PRIORITY_HTML = HERE / 'PRIORITY_5_REVIEW.html' DIFF_MD = HERE / 'TEMPLATES_V2_DIFF.md' PRIORITY_FRAME_NUMBERS = {3, 11, 12, 20, 29} # ============================================================ # Vocabularies (schema v2) # ============================================================ CONTENT_AFFINITY_ENUM = [ 'concept_definition', 'concept_comparison', 'goal_axes', 'persona_benefit', 'process_steps', 'before_after_change', 'capability_requirements', 'comparative_matrix', 'stakeholder_roles', 'policy_requirements', 'tool_ecosystem', 'interrelation', ] STRUCTURE_INTENT_ENUM = [ 'binary_compare', 'multi_parallel', 'hierarchy', 'sequence', 'state_transition', 'cycle_interrelation', 'matrix_coverage', 'persona_mapping', 'singleton_emphasis', ] # ============================================================ # Layout → default content_affinity (primary 먼저) # ============================================================ # Key 는 original_layout (visual_pattern.layout 또는 source.original_layout) LAYOUT_DEFAULT_AFFINITY = { # Compare family 'compare-rows': ['concept_comparison', 'comparative_matrix'], 'compare-2banner-top-2col-bottom': ['before_after_change', 'concept_comparison'], 'compare-2col': ['concept_comparison'], 'compare-2banner': ['before_after_change', 'concept_comparison'], 'banner-top-2col-bottom': ['before_after_change', 'concept_comparison'], # Table 'table-2col': ['concept_comparison', 'comparative_matrix'], 'table-3col': ['comparative_matrix'], # Persona / 3-column 'persona-3col': ['persona_benefit', 'stakeholder_roles'], '3col-parallel': ['goal_axes'], '3col-cards': ['capability_requirements'], '3col-compare': ['concept_comparison', 'comparative_matrix'], '3-column': ['goal_axes'], '3-category': ['concept_definition'], 'cards-3-category': ['concept_definition'], 'cards-3-compare': ['concept_comparison'], 'cards-3-header': ['capability_requirements'], # Cards 4+ 'cards-4': ['capability_requirements'], 'cards-4-grid': ['capability_requirements'], 'policy-4card-plus-list': ['policy_requirements'], # Cycle / interrelation 'cycle-3way': ['interrelation', 'goal_axes'], 'cycle-3way-intersection': ['interrelation', 'goal_axes'], 'circular-nodes': ['interrelation'], 'circular-nodes-6': ['interrelation'], 'quadrilateral-relations': ['interrelation'], # Lists 'list-numbered': ['policy_requirements', 'capability_requirements'], 'list-numbered-4': ['policy_requirements', 'capability_requirements'], 'list-stacked': ['policy_requirements'], 'list-stacked-vertical': ['policy_requirements'], 'bullet-cards': ['capability_requirements'], 'bullet-cards-4-plus-center': ['capability_requirements', 'goal_axes'], # Paired / Quadrant 'paired-rows': ['comparative_matrix'], 'paired-rows-2x2': ['comparative_matrix'], '2col-paired': ['persona_benefit'], '2col-paired-list': ['persona_benefit', 'stakeholder_roles'], '2-boxes': ['concept_comparison'], 'quadrant-issues': ['policy_requirements', 'singleton_emphasis'], 'quadrant-4': ['comparative_matrix'], # Diagram / radial 'diagram-5': ['goal_axes'], 'radial-diagram-5': ['goal_axes'], 'diagram-labels': ['concept_definition'], 'central-5-goals': ['goal_axes'], 'central-split': ['concept_comparison'], 'central-split-synthesis': ['concept_comparison'], # Split panel 'split-panel-diagram': ['concept_comparison'], 'split-panel-numbered': ['process_steps'], # Side / Sections 'side-card': ['concept_definition'], 'side-card-with-list': ['concept_definition', 'capability_requirements'], '3-section': ['goal_axes'], '3-section-framework': ['goal_axes', 'policy_requirements'], '3-emphasis': ['goal_axes', 'singleton_emphasis'], 'title-plus-3-emphasis': ['goal_axes', 'singleton_emphasis'], # Full page 'full-page-map': ['policy_requirements', 'stakeholder_roles'], 'full-page-map-banner': ['policy_requirements'], } LAYOUT_DEFAULT_INTENT = { 'compare-rows': ['matrix_coverage', 'binary_compare'], 'compare-2banner-top-2col-bottom': ['state_transition', 'binary_compare'], 'compare-2col': ['binary_compare'], 'compare-2banner': ['state_transition', 'binary_compare'], 'banner-top-2col-bottom': ['state_transition', 'binary_compare'], 'table-2col': ['matrix_coverage'], 'table-3col': ['matrix_coverage'], 'persona-3col': ['persona_mapping', 'multi_parallel'], '3col-parallel': ['multi_parallel'], '3col-cards': ['multi_parallel'], '3col-compare': ['binary_compare', 'matrix_coverage'], '3-column': ['multi_parallel'], '3-category': ['multi_parallel'], 'cards-3-category': ['multi_parallel'], 'cards-3-compare': ['binary_compare'], 'cards-3-header': ['multi_parallel'], 'cards-4': ['multi_parallel'], 'cards-4-grid': ['multi_parallel'], 'policy-4card-plus-list': ['multi_parallel'], 'cycle-3way': ['cycle_interrelation', 'multi_parallel'], 'cycle-3way-intersection': ['cycle_interrelation', 'multi_parallel'], 'circular-nodes': ['cycle_interrelation'], 'circular-nodes-6': ['cycle_interrelation'], 'quadrilateral-relations': ['cycle_interrelation'], 'list-numbered': ['sequence'], 'list-numbered-4': ['sequence', 'multi_parallel'], 'list-stacked': ['multi_parallel'], 'list-stacked-vertical': ['multi_parallel'], 'bullet-cards': ['multi_parallel'], 'bullet-cards-4-plus-center': ['multi_parallel', 'hierarchy'], 'paired-rows': ['matrix_coverage'], 'paired-rows-2x2': ['matrix_coverage'], '2col-paired': ['persona_mapping', 'binary_compare'], '2col-paired-list': ['persona_mapping'], '2-boxes': ['binary_compare'], 'quadrant-issues': ['matrix_coverage', 'multi_parallel'], 'quadrant-4': ['matrix_coverage'], 'diagram-5': ['hierarchy'], 'radial-diagram-5': ['hierarchy', 'multi_parallel'], 'diagram-labels': ['singleton_emphasis'], 'central-5-goals': ['hierarchy', 'multi_parallel'], 'central-split': ['binary_compare'], 'central-split-synthesis': ['binary_compare'], 'split-panel-diagram': ['binary_compare'], 'split-panel-numbered': ['sequence'], 'side-card': ['singleton_emphasis'], 'side-card-with-list': ['singleton_emphasis', 'multi_parallel'], '3-section': ['multi_parallel'], '3-section-framework': ['multi_parallel', 'hierarchy'], '3-emphasis': ['multi_parallel', 'singleton_emphasis'], 'title-plus-3-emphasis': ['multi_parallel', 'singleton_emphasis'], 'full-page-map': ['singleton_emphasis', 'matrix_coverage'], 'full-page-map-banner': ['singleton_emphasis'], } # ============================================================ # 키워드 → affinity 보강 (내용 설명에서 발견 시 primary override) # ============================================================ AFFINITY_KEYWORDS = { 'concept_definition': ['정의', '이란', '개념', '분류', '구분'], 'concept_comparison': ['비교', '대조', '차이', 'vs', 'VS', '상호관계'], 'goal_axes': ['목표', '궁극적', '비전', '목적', '지향'], 'persona_benefit': ['발주자', '설계자', '시공자', '기대효과', '혜택', '이익'], 'process_steps': ['단계', '순서', 'Step', '1단계', '2단계', '흐름'], 'before_after_change': ['AS-IS', 'TO-BE', '전환', '혁신', '변화', '이중 변환', '과정'], 'capability_requirements': ['필수', '요건', '필요', '역량', 'S/W', '도구'], 'comparative_matrix': ['다면', '다축', '관점별', '여러 관점', '축'], 'stakeholder_roles': ['역할', '책임', '주도', '수행'], 'policy_requirements': ['정책', '제도', '도입', '거버넌스', '전면 도입', '국외'], 'tool_ecosystem': ['Revit', 'Navisworks', 'SketchUp', '소프트웨어', 'S/W 생태'], 'interrelation': ['상호관계', '순환', '조화', '교차', '수렴', '3원'], } # ============================================================ # 키워드 → intent 보강 # ============================================================ INTENT_KEYWORDS = { 'binary_compare': ['2개 비교', '2개 개념', '대조', '양분'], 'state_transition': ['AS-IS', 'TO-BE', '전환', '혁신', '이중 Transformation', '과정'], 'cycle_interrelation': ['상호관계', '순환', '조화', '교차', '3원'], 'multi_parallel': ['3개 병렬', '4개 병렬', '카드 3열', '3관점'], 'hierarchy': ['중앙', '상위', '하위', '포함'], 'sequence': ['단계', '순서', '흐름', 'step'], 'matrix_coverage': ['다면', '관점별', '여러 관점', '축별'], 'persona_mapping': ['주체별', '발주자/설계자/시공자', '역할별'], 'singleton_emphasis': ['강조', '문제', '진단', '약점'], } # ============================================================ # 핵심 함수 # ============================================================ def parse_analysis_md(path: Path) -> dict: """analysis.md 의 주요 섹션 추출.""" text = path.read_text(encoding='utf-8') # 내용 설명 m = re.search(r'##\s*내용 설명\s*\n+([\s\S]+?)(?=\n##\s|\Z)', text) content = m.group(1).strip() if m else '' # suits m_s = re.search(r'###\s*suits\s*\n([\s\S]+?)(?=\n###|\n##\s|\Z)', text) suits = [] if m_s: for ln in m_s.group(1).strip().split('\n'): ln = ln.strip() if ln.startswith('-'): suits.append(ln.lstrip('-').strip()) # not_suits m_ns = re.search(r'###\s*not_suits\s*\n([\s\S]+?)(?=\n###|\n##\s|\Z)', text) not_suits = [] if m_ns: for ln in m_ns.group(1).strip().split('\n'): ln = ln.strip() if ln.startswith('-'): not_suits.append(ln.lstrip('-').strip()) return {'content': content, 'suits': suits, 'not_suits': not_suits} def find_keyword_hits(text: str, keyword_map: dict) -> list[tuple[str, list[str]]]: """text 안에서 각 label 의 키워드 찾기.""" hits = [] for label, kws in keyword_map.items(): found = [kw for kw in kws if kw in text] if found: hits.append((label, found)) return hits def label_content_affinity(layout: str, original_layout: str, content: str, suits: list, not_suits: list) -> dict: """content_affinity primary + secondary + evidence.""" # 1. default defaults = (LAYOUT_DEFAULT_AFFINITY.get(original_layout) or LAYOUT_DEFAULT_AFFINITY.get(layout) or ['concept_definition']) default_primary = defaults[0] default_secondary = defaults[1:] # 2. 키워드 hit all_text = content + ' ' + ' '.join(suits) kw_hits = find_keyword_hits(all_text, AFFINITY_KEYWORDS) # 3. primary 결정: 키워드 가장 강한 것 (개수 기준), 없으면 default if kw_hits: kw_hits.sort(key=lambda x: -len(x[1])) primary = kw_hits[0][0] primary_kws = kw_hits[0][1] primary_source = 'keyword_match' else: primary = default_primary primary_kws = [] primary_source = 'layout_default' # 4. secondary: primary 제외 keyword hit + default secondary sec_candidates = [l for l, kws in kw_hits[1:] if l != primary] for d in default_secondary: if d not in sec_candidates and d != primary: sec_candidates.append(d) secondary = sec_candidates[:2] # 5. evidence 생성 evidence = {} if primary_source == 'keyword_match': evidence['primary'] = { 'source': 'keyword_match', 'field': '내용 설명 / suits', 'keywords': primary_kws, 'rule': f"'{primary_kws[0]}' 등 키워드가 {primary} 를 가리킴", 'confidence': min(0.95, 0.6 + 0.1 * len(primary_kws)), } else: evidence['primary'] = { 'source': 'layout_default', 'field': f'original_layout = {original_layout or layout}', 'rule': f'layout → 기본 affinity 매핑', 'confidence': 0.6, } for i, s in enumerate(secondary, start=1): hits_for_s = next((kws for l, kws in kw_hits if l == s), None) if hits_for_s: evidence[f'secondary_{i}'] = { 'source': 'keyword_match', 'keywords': hits_for_s, 'confidence': 0.65, } else: evidence[f'secondary_{i}'] = { 'source': 'layout_default', 'confidence': 0.5, } return { 'primary': primary, 'secondary': secondary, 'evidence': evidence, } def label_structure_intent(layout: str, original_layout: str, content: str, relation_type: str, cardinality: dict) -> dict: """structure_intent primary + secondary + evidence.""" defaults = (LAYOUT_DEFAULT_INTENT.get(original_layout) or LAYOUT_DEFAULT_INTENT.get(layout) or ['multi_parallel']) default_primary = defaults[0] default_secondary = defaults[1:] kw_hits = find_keyword_hits(content, INTENT_KEYWORDS) # relation_type + cardinality 보강 ideal = cardinality.get('ideal') if cardinality else None if relation_type == 'compare' and ideal == 2: # state_transition 우선 (내용에 전환/AS-IS 가 있으면) if 'AS-IS' in content or 'TO-BE' in content or '혁신' in content or '전환' in content: kw_hits.append(('state_transition', ['AS-IS/TO-BE/혁신/전환'])) else: kw_hits.append(('binary_compare', ['compare + cardinality.ideal=2'])) elif relation_type == 'parallel' and ideal and ideal >= 3: kw_hits.append(('multi_parallel', [f'parallel + cardinality.ideal={ideal}'])) elif relation_type == 'sequence': kw_hits.append(('sequence', ['relation_type=sequence'])) if kw_hits: kw_hits.sort(key=lambda x: -len(x[1])) primary = kw_hits[0][0] primary_kws = kw_hits[0][1] primary_source = 'keyword_or_structure_match' else: primary = default_primary primary_kws = [] primary_source = 'layout_default' sec_candidates = [] for l, kws in kw_hits[1:]: if l != primary and l not in sec_candidates: sec_candidates.append(l) for d in default_secondary: if d not in sec_candidates and d != primary: sec_candidates.append(d) secondary = sec_candidates[:2] evidence = { 'primary': { 'source': primary_source, 'rule': primary_kws[0] if primary_kws else f'layout default → {primary}', 'confidence': 0.7 if primary_source == 'keyword_or_structure_match' else 0.6, } } for i, s in enumerate(secondary, start=1): evidence[f'secondary_{i}'] = { 'source': 'layout_default' if s in default_secondary else 'keyword_match', 'confidence': 0.55, } return { 'primary': primary, 'secondary': secondary, 'evidence': evidence, } def derive_alternative_patterns(fig_layout: str) -> list[dict]: """_COMPAT 에서 파생 — 이 fig_layout 과 의미적으로 호환 가능한 다른 layout.""" alternatives = {} # 이 fig_layout 을 높게 평가하는 mdx_layout 들 for mdx_l, fig_dict in _COMPAT.items(): my_compat = fig_dict.get(fig_layout, 0) if my_compat >= 0.6: # 같은 mdx_l 에서 compat >= 0.7 인 다른 fig_layout 들 → 대안 for other_fig, c in fig_dict.items(): if other_fig == fig_layout: continue if c >= 0.7: # 가중 합산: 공통으로 호환되는 mdx_l 이 많을수록 강한 대안 alternatives[other_fig] = alternatives.get(other_fig, 0) + my_compat * c # 정규화 (0~1) if not alternatives: return [] max_v = max(alternatives.values()) alts_list = [] for fig_l, v in sorted(alternatives.items(), key=lambda x: -x[1])[:5]: conf = round(v / max_v, 2) alts_list.append({ 'pattern': fig_l, 'reason': f'_COMPAT 공통 호환 mdx_layout 집합 기반 파생 (정규화 {conf})', 'confidence': conf, }) return alts_list def generate_v2_entry(fid: str, tpl_v1: dict) -> dict: """v1 엔트리에 v2 필드 추가.""" layout = tpl_v1['visual_pattern']['layout'] original_layout = tpl_v1['source'].get('original_layout', layout) relation_type = tpl_v1['visual_pattern'].get('relation_type') cardinality = tpl_v1['visual_pattern'].get('cardinality', {}) # analysis.md 읽기 analysis = parse_analysis_md(BLOCKS_DIR / fid / 'analysis.md') # v2 필드 생성 affinity = label_content_affinity( layout, original_layout, analysis['content'], analysis['suits'], analysis['not_suits'], ) intent = label_structure_intent( layout, original_layout, analysis['content'], relation_type, cardinality, ) alternatives = derive_alternative_patterns(original_layout) # v1 엔트리 그대로 복제 + v2 필드 추가 entry = dict(tpl_v1) entry['content_affinity'] = affinity entry['structure_intent_v2'] = intent # v1 의 structure_intent 와 구분 위해 이름 변경 entry['alternative_patterns'] = alternatives entry['v2_meta'] = { 'generated_at': datetime.datetime.now().isoformat(timespec='seconds'), 'source_analysis': f'{fid}/analysis.md', 'needs_review': tpl_v1.get('short_id') in {'03', '11', '12', '20', '29'}, } return entry # ============================================================ # 메인 # ============================================================ def main(): v1 = yaml.safe_load(ONTOLOGY_V1_PATH.read_text(encoding='utf-8')) templates_v1 = v1['templates_v1'] templates_v2 = {} for fid, tpl in templates_v1.items(): templates_v2[fid] = generate_v2_entry(fid, tpl) # 산출물 1: structure_ontology_v2.yaml out = { 'meta': { 'schema_version': 'template-fit-v2-draft', 'generated_from': 'structure_ontology.yaml (templates_v1)', 'generated_at': datetime.datetime.now().isoformat(timespec='seconds'), 'generator': 'pipeline_12_generate_templates_v2.py', 'status': 'draft_pending_user_review', 'priority_review_frames': sorted(PRIORITY_FRAME_NUMBERS), 'vocabularies': { 'content_affinity': CONTENT_AFFINITY_ENUM, 'structure_intent': STRUCTURE_INTENT_ENUM, }, 'matching_weights_initial': { 'layout_compat': 0.40, 'content_affinity': 0.35, 'structure_intent': 0.25, }, 'note': ( 'AI 초안. 원본 structure_ontology.yaml 는 유지. ' '사용자 검토 우선순위: Frame 12, 20, 29, 3, 11 → 2차 (3col/cycle/table/process 계열) → 3차 (나머지).' ), }, 'templates_v2': templates_v2, } OUT_V2_PATH.write_text( yaml.safe_dump(out, allow_unicode=True, sort_keys=False, width=1000), encoding='utf-8', ) # ============================================================ # 산출물 2: PRIORITY_5_REVIEW # ============================================================ pr_md = [] pr_md.append('# Priority 5 프레임 검토 리포트 (C.2-b AI 초안)') pr_md.append('') pr_md.append( '사용자 검토 우선순위 1순위 — Holdout 평가에서 V3 문제가 드러난 프레임.' ) pr_md.append('') pr_md.append( '각 프레임마다: (a) v1 대비 추가된 v2 필드, (b) 라벨 근거(evidence), ' '(c) 검토 포인트.' ) pr_md.append('') short_to_fid = {v['short_id']: k for k, v in templates_v1.items()} for fn in ['03', '11', '12', '20', '29']: fid = short_to_fid.get(fn) if not fid: continue v2_entry = templates_v2[fid] v1_entry = templates_v1[fid] title = v1_entry['source']['title'] original_layout = v1_entry['source'].get('original_layout') pr_md.append(f"## Frame {fn} — {title}") pr_md.append('') pr_md.append(f"- **frame_id**: `{fid}`") pr_md.append(f"- **layout**: `{v1_entry['visual_pattern']['layout']}` (original: `{original_layout}`)") pr_md.append(f"- **family / relation_type / cardinality**: " f"`{v1_entry['visual_pattern']['family']}` / " f"`{v1_entry['visual_pattern'].get('relation_type')}` / " f"ideal={v1_entry['visual_pattern'].get('cardinality', {}).get('ideal')}") pr_md.append('') pr_md.append('### content_affinity') aff = v2_entry['content_affinity'] pr_md.append(f"- **primary**: `{aff['primary']}`") if aff['secondary']: pr_md.append(f"- **secondary**: {', '.join(f'`{s}`' for s in aff['secondary'])}") pr_md.append('- **evidence**:') for k, v in aff['evidence'].items(): extras = [] if 'keywords' in v and v['keywords']: extras.append(f"keywords={v['keywords']}") if 'rule' in v: extras.append(f"rule={v['rule']}") extras.append(f"conf={v['confidence']}") pr_md.append(f" - `{k}` (source: {v['source']}) — {' / '.join(extras)}") pr_md.append('') pr_md.append('### structure_intent (v2)') si = v2_entry['structure_intent_v2'] pr_md.append(f"- **primary**: `{si['primary']}`") if si['secondary']: pr_md.append(f"- **secondary**: {', '.join(f'`{s}`' for s in si['secondary'])}") pr_md.append('- **evidence**:') for k, v in si['evidence'].items(): pr_md.append(f" - `{k}` (source: {v['source']}) — rule={v.get('rule', '—')} / conf={v['confidence']}") pr_md.append('') pr_md.append('### alternative_patterns (파생)') if v2_entry['alternative_patterns']: pr_md.append('| 대안 layout | confidence | 근거 |') pr_md.append('|---|---:|---|') for a in v2_entry['alternative_patterns']: pr_md.append(f"| `{a['pattern']}` | {a['confidence']} | {a['reason']} |") else: pr_md.append('(파생된 대안 없음 — _COMPAT 기반 공통 호환 부재)') pr_md.append('') pr_md.append('### 검토 포인트') pr_md.append(f"- primary content_affinity 가 Frame 의 실제 의도에 맞나?") pr_md.append(f"- structure_intent primary/secondary 가 layout 의 시각적 메시지를 정확히 기술하나?") pr_md.append(f"- alternative_patterns 에 **빠진** 의미적 대안이 있나? (예: Frame 12 라면 `3col-parallel` 이 포함되어 있나)") pr_md.append('') pr_md.append('---') pr_md.append('') pr_text = '\n'.join(pr_md) PRIORITY_MD.write_text(pr_text, encoding='utf-8') style = """ body { font-family: -apple-system, "Segoe UI", Pretendard, sans-serif; max-width: 1100px; margin: 2em auto; padding: 0 1.5em 4em; line-height: 1.65; color: #222; background: #f8fafc; } h1 { border-bottom: 3px solid #2563eb; padding-bottom: 0.25em; } h2 { margin-top: 2.5em; background: #e0e7ff; padding: 0.6em 0.9em; border-left: 4px solid #0a6; border-radius: 4px; } h3 { margin-top: 1.3em; color: #1a365d; } table { border-collapse: collapse; background: #fff; margin: 0.5em 0 1em; } th, td { border: 1px solid #e2e8f0; padding: 8px 10px; text-align: left; vertical-align: top; } th { background: #1e293b; color: #fff; } code { background: #f4f4f4; padding: 1px 6px; border-radius: 3px; font-size: 0.9em; color: #111; } strong { color: #0a6; } """ html_body = markdown.markdown(pr_text, extensions=['tables']) html = f"""