@caddis/cli 0.4.14 → 0.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1170 @@
1
+ """Convert High-Level Architecture (HLA) Markdown documents into Confluence Storage Format XHTML.
2
+
3
+ This script parses an HLA Markdown document, strips YAML frontmatter, metadata notices,
4
+ and reference sections, converts Markdown structure and tables to native Confluence XHTML,
5
+ and embeds self-contained PlantUML macros with corporate Material Design styling.
6
+
7
+ Usage:
8
+ python scripts/convert_hla_to_confluence.py <path-to-hla.md> [--output <path-to-output.html>]
9
+ """
10
+
11
+ import argparse
12
+ import re
13
+ import sys
14
+ import xml.etree.ElementTree as ET
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional
17
+
18
+
19
+ def strip_frontmatter(content: str) -> str:
20
+ """Remove YAML frontmatter from the beginning of markdown content."""
21
+ pattern = r"^---\s*\n(.*?)\n---\s*\n"
22
+ return re.sub(pattern, "", content, flags=re.DOTALL)
23
+
24
+
25
+ def strip_references_and_ai_metadata(content: str) -> str:
26
+ """Remove references section, document notices, and any AI/agent-specific metadata."""
27
+ # 1. Remove Reference Documents subsection up to next header or end
28
+ pattern_ref = r"###\s*\d+\.\d+\s+Reference Documents.*?(?=(^##|\Z))"
29
+ content = re.sub(pattern_ref, "", content, flags=re.DOTALL | re.MULTILINE)
30
+
31
+ # 2. Filter out document notices, STE statements, and AI tool/model metadata
32
+ banned_keywords = [
33
+ "document notice",
34
+ "simplified technical english",
35
+ "asd-ste100",
36
+ "active voice is used",
37
+ "sentences are short",
38
+ "explanations are direct",
39
+ "caddis",
40
+ "creating model",
41
+ "creation-agent",
42
+ "claude code",
43
+ "codex",
44
+ "antigravity",
45
+ ]
46
+
47
+ lines = []
48
+ for line in content.splitlines():
49
+ lower = line.lower()
50
+ if any(keyword in lower for keyword in banned_keywords):
51
+ continue
52
+ lines.append(line)
53
+ return "\n".join(lines)
54
+
55
+
56
+ def sanitize_ai_slop_and_punctuation(content: str) -> str:
57
+ """Normalize punctuation, eliminate em-dashes/en-dashes, and purge AI slop.
58
+
59
+ 1. Replaces em-dashes (—) and en-dashes (–) with colons, parentheses, or ASCII hyphens.
60
+ 2. Converts smart/curly quotes (“ ” ‘ ’) to straight ASCII quotes.
61
+ 3. Replaces unicode bullet characters (•) with ASCII dashes (-).
62
+ 4. Eliminates AI buzzwords, purple prose, and meta-commentary framing.
63
+ """
64
+ # 1. Normalize em-dashes and en-dashes in titles and bullet definitions
65
+ content = re.sub(r'^(#+\s+[^—\n]+?)\s*[—–]\s*(.+)$', r'\1: \2', content, flags=re.MULTILINE)
66
+ content = re.sub(r'\*\*([^*—–\n]+?)\s*[—–]\s*([^*—–\n]+?)\*\*', r'**\1 (\2)**', content)
67
+ content = content.replace("—", " - ").replace("–", "-")
68
+ content = re.sub(r'[ \t]+-[ \t]+', ' - ', content)
69
+
70
+ # 2. Smart quotes to straight ASCII
71
+ content = content.replace("“", '"').replace("”", '"').replace("‘", "'").replace("’", "'")
72
+
73
+ # 3. Unicode bullet characters to ASCII hyphen
74
+ content = content.replace("•", "-")
75
+
76
+ # 4. Filter AI slop buzzwords and purple prose
77
+ ai_buzzword_replacements = [
78
+ (r'\bdelve into\b', 'inspect'),
79
+ (r'\bdelve\b', 'investigate'),
80
+ (r'\bpivotal\b', 'primary'),
81
+ (r'\btestament to\b', 'evidence of'),
82
+ (r'\btapestry\b', 'architecture'),
83
+ (r'\bbeacon of\b', 'model for'),
84
+ (r'\bgame-changer\b', 'significant improvement'),
85
+ (r'\brevolutionize\b', 'transform'),
86
+ (r'\bcornerstone of\b', 'core element of'),
87
+ (r'\bseamlessly\b', 'directly'),
88
+ (r'\bseamless\b', 'direct'),
89
+ (r'\bholistic\b', 'end-to-end'),
90
+ (r'\bfoster\b', 'support'),
91
+ (r'\bharnessing\b', 'using'),
92
+ (r'\bleverage\b', 'use'),
93
+ (r'\bleveraging\b', 'using'),
94
+ (r'\bleverages\b', 'uses'),
95
+ (r'\bleveraged\b', 'used'),
96
+ (r'\bmeticulous\b', 'detailed'),
97
+ (r'\bcrucial\b', 'critical'),
98
+ (r'\bcutting-edge\b', 'modern'),
99
+ (r'\brobust\b', 'reliable'),
100
+ ]
101
+ for pattern, repl in ai_buzzword_replacements:
102
+ content = re.sub(pattern, repl, content, flags=re.IGNORECASE)
103
+
104
+ return content
105
+
106
+
107
+ def format_inline_markdown(text: str) -> str:
108
+ """Safely escape XML characters and convert inline markdown (bold, italic, code)."""
109
+ # 0. Sanitize AI slop punctuation (em-dashes, en-dashes, smart quotes, bullets)
110
+ text = text.replace("—", " - ").replace("–", "-")
111
+ text = text.replace("“", '"').replace("”", '"').replace("‘", "'").replace("’", "'")
112
+ text = text.replace("•", "-")
113
+
114
+ # 1. Escape XML characters
115
+ text = re.sub(r"&(?!(amp|lt|gt|quot|apos);)", "&amp;", text)
116
+ text = text.replace("<", "&lt;").replace(">", "&gt;")
117
+
118
+ # 2. Extract and protect code spans with placeholders so wildcards (*) are not parsed as italics
119
+ code_spans = []
120
+
121
+ def save_code(m):
122
+ code_spans.append(f"<code>{m.group(1)}</code>")
123
+ return f"__INLINE_CODE_SPAN_{len(code_spans)-1}__"
124
+
125
+ text = re.sub(r"`([^`]+)`", save_code, text)
126
+
127
+ # 3. Bold **bold** -> <strong>bold</strong>
128
+ text = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", text)
129
+
130
+ # 4. Italic *italic* -> <em>italic</em>
131
+ text = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<em>\1</em>", text)
132
+
133
+ # 5. Restore code spans
134
+ for idx, span in enumerate(code_spans):
135
+ text = text.replace(f"__INLINE_CODE_SPAN_{idx}__", span)
136
+
137
+ return text
138
+
139
+
140
+ def convert_table(md_table_lines: list[str]) -> str:
141
+ """Convert Markdown table lines to Confluence wrapped table."""
142
+ if len(md_table_lines) < 2:
143
+ return ""
144
+
145
+ header_line = md_table_lines[0].strip()
146
+ data_lines = md_table_lines[2:]
147
+
148
+ headers = [format_inline_markdown(c.strip()) for c in header_line.split("|")[1:-1]]
149
+ num_cols = len(headers)
150
+
151
+ cols_html = "".join("<col/>" for _ in range(num_cols))
152
+ colgroup = f"<colgroup>{cols_html}</colgroup>"
153
+
154
+ thead_cells = "".join(f"<th><p>{h}</p></th>" for h in headers)
155
+ thead = f"<thead><tr>{thead_cells}</tr></thead>"
156
+
157
+ rows_html = []
158
+ for row in data_lines:
159
+ row_str = row.strip()
160
+ if not row_str or not row_str.startswith("|"):
161
+ continue
162
+ cells = [format_inline_markdown(c.strip()) for c in row_str.split("|")[1:-1]]
163
+ while len(cells) < num_cols:
164
+ cells.append("")
165
+ cell_elements = [f"<td><p>{c}</p></td>" for c in cells]
166
+ row_html = "".join(cell_elements)
167
+ rows_html.append(f"<tr>{row_html}</tr>")
168
+
169
+ tbody = f"<tbody>{''.join(rows_html)}</tbody>"
170
+ return f'<table class="wrapped">{colgroup}{thead}{tbody}</table>'
171
+
172
+
173
+ def _parse_mermaid_flowchart(code: str) -> Optional[str]:
174
+ """Dynamically converts any Mermaid flowchart into Confluence PlantUML with Material palette."""
175
+ lines = code.strip().splitlines()
176
+ subgraphs = []
177
+ sg_stack = []
178
+ top_connections = []
179
+ top_nodes = {}
180
+
181
+ subgraph_start_re = re.compile(r'^\s*subgraph\s+([A-Za-z0-9_]+)(?:\["([^"]+)"\]|\[([^\]]+)\])?', re.IGNORECASE)
182
+ direction_re = re.compile(r'^\s*direction\s+(LR|RL|TB|TD)', re.IGNORECASE)
183
+ node_def_re = re.compile(
184
+ r'([A-Za-z0-9_]+)(?:'
185
+ r'\["([^"]+)"\]|\[([^\]]+)\]|'
186
+ r'\{"([^"]+)"\}|\{([^\}]+)\}|'
187
+ r'\("([^"]+)"\)|\(([^\)]+)\)'
188
+ r')(?::::([A-Za-z0-9_]+))?'
189
+ )
190
+
191
+ has_flowchart = False
192
+ for line in lines:
193
+ stripped = line.strip()
194
+ if not stripped or stripped.startswith("%%") or stripped.startswith("classDef") or stripped.startswith("style "):
195
+ continue
196
+ if stripped.lower().startswith("flowchart") or stripped.lower().startswith("graph"):
197
+ has_flowchart = True
198
+ continue
199
+
200
+ sg_match = subgraph_start_re.match(stripped)
201
+ if sg_match:
202
+ sg_id = sg_match.group(1)
203
+ sg_title = sg_match.group(2) or sg_match.group(3) or sg_id
204
+ new_sg = {
205
+ "id": sg_id,
206
+ "title": sg_title,
207
+ "direction": "TB",
208
+ "nodes": {},
209
+ "edges": [],
210
+ "children": []
211
+ }
212
+ if sg_stack:
213
+ sg_stack[-1]["children"].append(new_sg)
214
+ else:
215
+ subgraphs.append(new_sg)
216
+ sg_stack.append(new_sg)
217
+ continue
218
+
219
+ if stripped.lower() == "end":
220
+ if sg_stack:
221
+ sg_stack.pop()
222
+ continue
223
+
224
+ dir_match = direction_re.match(stripped)
225
+ if dir_match and sg_stack:
226
+ sg_stack[-1]["direction"] = dir_match.group(1).upper()
227
+ continue
228
+
229
+ # 1. Extract node definitions
230
+ for match in node_def_re.finditer(stripped):
231
+ n_id = match.group(1)
232
+ n_lbl = next(g for g in match.groups()[1:7] if g is not None)
233
+ n_cls = match.group(8) or ""
234
+ node_info = {"id": n_id, "label": n_lbl, "class": n_cls}
235
+ if sg_stack:
236
+ sg_stack[-1]["nodes"][n_id] = node_info
237
+ else:
238
+ top_nodes[n_id] = node_info
239
+
240
+ # 2. Clean line for edge extraction
241
+ def repl_node(m):
242
+ return m.group(1)
243
+ clean_edge_line = node_def_re.sub(repl_node, stripped)
244
+
245
+ # 3. Extract edges
246
+ edge_tokens = re.split(r'(\s*(?:<-->|-->|---|->)\s*(?:\|"[^"]*"\|\s*|\|[^\|]*\|\s*)?)', clean_edge_line)
247
+ if len(edge_tokens) >= 3:
248
+ for i in range(0, len(edge_tokens) - 2, 2):
249
+ src = edge_tokens[i].strip()
250
+ arrow_token = edge_tokens[i+1].strip()
251
+ dst = edge_tokens[i+2].strip()
252
+
253
+ lbl_match = re.search(r'\|"?([^"\|]*)"?\|', arrow_token)
254
+ lbl = lbl_match.group(1).strip() if lbl_match else ""
255
+
256
+ if src and dst and re.match(r'^[A-Za-z0-9_]+$', src) and re.match(r'^[A-Za-z0-9_]+$', dst):
257
+ edge_obj = {"src": src, "dst": dst, "label": lbl}
258
+ if sg_stack:
259
+ sg_stack[-1]["edges"].append(edge_obj)
260
+ else:
261
+ top_connections.append(edge_obj)
262
+
263
+ # If no subgraphs or nodes discovered, return None to trigger fallback
264
+ if not subgraphs and not top_nodes:
265
+ return None
266
+
267
+ def get_node_type(node_id: str, label: str, cls: str) -> tuple:
268
+ id_low = node_id.lower()
269
+ lbl_low = label.lower()
270
+ cls_low = cls.lower()
271
+
272
+ # Specific classDef and archetype hints
273
+ if "rlhf" in cls_low or "feedback" in id_low:
274
+ return "database", "rlhf_db"
275
+ if "src" in cls_low or "source" in id_low or "derby" in lbl_low or "case_fault" in lbl_low:
276
+ return "database", "source_db"
277
+ if id_low.endswith("db") or "_db" in id_low:
278
+ return "database", "staging_db"
279
+ if "model" in cls_low or "synth" in cls_low or "llm" in id_low or "ai model" in lbl_low or "gpt" in lbl_low or "gemma" in lbl_low:
280
+ return "rectangle", "ai_model"
281
+ if "ui" in cls_low or "browser" in id_low or "dashboard" in lbl_low or "screener" in id_low or "client" in id_low or "user" in id_low:
282
+ return "rectangle", "user_ui"
283
+ if "gateway" in id_low or "apigee" in id_low or "gateway" in cls_low:
284
+ return "queue", "gateway"
285
+ if "ext" in cls_low or "telemetry" in id_low or any(k in lbl_low for k in ["telemetry", "nxt", "fcaps", "cdn"]):
286
+ return "rectangle", "telemetry"
287
+ if any(k in id_low for k in ["recoutput", "decision", "action"]) or "outstyle" in cls_low:
288
+ return "rectangle", "outcome"
289
+
290
+ # Check if explicitly a database/table
291
+ is_db = any(k in lbl_low for k in ["db:", "dbo.", "database", "table", "store", "sqlite", "sql server", "[sp_"]) or \
292
+ any(k in id_low for k in ["db", "table", "store"]) or \
293
+ any(k in cls_low for k in ["stage", "datastyle"])
294
+ if is_db:
295
+ return "database", "staging_db"
296
+
297
+ return "rectangle", "service"
298
+
299
+ out = []
300
+ out.append("@startuml")
301
+ out.append("skinparam shadowing false")
302
+ out.append('skinparam defaultFontName "Segoe UI"')
303
+ out.append("skinparam defaultFontSize 11")
304
+ out.append("skinparam roundCorner 6")
305
+ out.append("skinparam linetype ortho")
306
+ out.append("skinparam nodesep 80")
307
+ out.append("skinparam ranksep 45\n")
308
+
309
+ out.append("skinparam package {")
310
+ out.append(" BorderColor #546E7A")
311
+ out.append(" BackgroundColor #F8FAFC")
312
+ out.append(" FontColor #263238")
313
+ out.append(" FontStyle bold")
314
+ out.append("}\n")
315
+
316
+ out.append("skinparam database<<source_db>> {")
317
+ out.append(" BorderColor #3949AB")
318
+ out.append(" BackgroundColor #E8EAF6")
319
+ out.append(" FontColor #1A237E")
320
+ out.append("}")
321
+ out.append("skinparam database<<staging_db>> {")
322
+ out.append(" BorderColor #0277BD")
323
+ out.append(" BackgroundColor #E1F5FE")
324
+ out.append(" FontColor #01579B")
325
+ out.append("}")
326
+ out.append("skinparam rectangle<<ai_model>> {")
327
+ out.append(" BorderColor #6A1B9A")
328
+ out.append(" BackgroundColor #F3E5F5")
329
+ out.append(" FontColor #4A148C")
330
+ out.append("}")
331
+ out.append("skinparam rectangle<<user_ui>> {")
332
+ out.append(" BorderColor #E65100")
333
+ out.append(" BackgroundColor #FFF3E0")
334
+ out.append(" FontColor #BF360C")
335
+ out.append("}")
336
+ out.append("skinparam database<<rlhf_db>> {")
337
+ out.append(" BorderColor #2E7D32")
338
+ out.append(" BackgroundColor #E8F5E9")
339
+ out.append(" FontColor #1B5E20")
340
+ out.append("}")
341
+ out.append("skinparam rectangle<<service>> {")
342
+ out.append(" BorderColor #00695C")
343
+ out.append(" BackgroundColor #E0F2F1")
344
+ out.append(" FontColor #004D40")
345
+ out.append("}")
346
+ out.append("skinparam queue<<gateway>> {")
347
+ out.append(" BorderColor #E65100")
348
+ out.append(" BackgroundColor #FFF3E0")
349
+ out.append(" FontColor #BF360C")
350
+ out.append("}")
351
+ out.append("skinparam rectangle<<telemetry>> {")
352
+ out.append(" BorderColor #C2185B")
353
+ out.append(" BackgroundColor #FCE4EC")
354
+ out.append(" FontColor #880E4F")
355
+ out.append("}")
356
+ out.append("skinparam rectangle<<outcome>> {")
357
+ out.append(" BorderColor #2E7D32")
358
+ out.append(" BackgroundColor #E8F5E9")
359
+ out.append(" FontColor #1B5E20")
360
+ out.append("}\n")
361
+
362
+ def render_subgraph(sg, indent=""):
363
+ res = [f'{indent}package "{sg["title"]}" as {sg["id"]} {{']
364
+ for child in sg.get("children", []):
365
+ res.extend(render_subgraph(child, indent + " "))
366
+ for n_id, n_info in sg["nodes"].items():
367
+ formatted_lbl = (
368
+ n_info["label"]
369
+ .replace("<br/>", "\\n")
370
+ .replace("<br>", "\\n")
371
+ .replace("•", "-")
372
+ .replace('"', "'")
373
+ )
374
+ elem_type, stereotype = get_node_type(n_id, n_info["label"], n_info["class"])
375
+ stereo_suffix = f" <<{stereotype}>>" if stereotype else ""
376
+ res.append(f'{indent} {elem_type} "{formatted_lbl}" as {n_id}{stereo_suffix}')
377
+
378
+ if sg["edges"]:
379
+ res.append("")
380
+ arrow = "-right->" if sg["direction"] == "LR" else "-down->"
381
+ for e in sg["edges"]:
382
+ lbl_clean = (
383
+ e["label"]
384
+ .replace("<br/>", "\\n")
385
+ .replace("<br>", "\\n")
386
+ .replace("•", "-")
387
+ .replace('"', "'")
388
+ if e["label"]
389
+ else ""
390
+ )
391
+ lbl_suffix = f' : {lbl_clean}' if lbl_clean else ""
392
+ res.append(f'{indent} {e["src"]} {arrow} {e["dst"]}{lbl_suffix}')
393
+ res.append(f"{indent}}}\n")
394
+ return res
395
+
396
+ for sg in subgraphs:
397
+ out.extend(render_subgraph(sg))
398
+
399
+ for n_id, n_info in top_nodes.items():
400
+ formatted_lbl = (
401
+ n_info["label"]
402
+ .replace("<br/>", "\\n")
403
+ .replace("<br>", "\\n")
404
+ .replace("•", "-")
405
+ .replace('"', "'")
406
+ )
407
+ elem_type, stereotype = get_node_type(n_id, n_info["label"], n_info["class"])
408
+ stereo_suffix = f" <<{stereotype}>>" if stereotype else ""
409
+ out.append(f'{elem_type} "{formatted_lbl}" as {n_id}{stereo_suffix}')
410
+ if top_nodes:
411
+ out.append("")
412
+
413
+ for i in range(len(subgraphs) - 1):
414
+ out.append(f'{subgraphs[i]["id"]} -[hidden]down- {subgraphs[i+1]["id"]}')
415
+ if subgraphs:
416
+ out.append("")
417
+
418
+ for e in top_connections:
419
+ lbl_clean = (
420
+ e["label"]
421
+ .replace("<br/>", "\\n")
422
+ .replace("<br>", "\\n")
423
+ .replace("•", "-")
424
+ .replace('"', "'")
425
+ if e["label"]
426
+ else ""
427
+ )
428
+ lbl_suffix = f' : {lbl_clean}' if lbl_clean else ""
429
+ out.append(f'{e["src"]} -down-> {e["dst"]}{lbl_suffix}')
430
+
431
+ out.append("\n@enduml")
432
+ return "\n".join(out)
433
+
434
+
435
+ def _parse_mermaid_sequence_diagram(code: str) -> Optional[str]:
436
+ """Dynamically parse a Mermaid sequenceDiagram into Corporate PlantUML."""
437
+ lines = [line.strip() for line in code.splitlines() if line.strip()]
438
+ if not any("sequenceDiagram" in l for l in lines):
439
+ return None
440
+
441
+ out = []
442
+ out.append("@startuml")
443
+ out.append("autonumber")
444
+ out.append("skinparam shadowing false")
445
+ out.append('skinparam defaultFontName "Segoe UI"')
446
+ out.append("skinparam defaultFontSize 11")
447
+ out.append("skinparam roundCorner 6\n")
448
+ out.append("skinparam actor {")
449
+ out.append(" BorderColor #3949AB")
450
+ out.append(" BackgroundColor #E8EAF6")
451
+ out.append(" FontColor #1A237E")
452
+ out.append("}")
453
+ out.append("skinparam participant {")
454
+ out.append(" BorderColor #455A64")
455
+ out.append(" BackgroundColor #F8FAFC")
456
+ out.append(" FontColor #263238")
457
+ out.append("}")
458
+ out.append("skinparam database {")
459
+ out.append(" BorderColor #0277BD")
460
+ out.append(" BackgroundColor #E1F5FE")
461
+ out.append(" FontColor #01579B")
462
+ out.append("}")
463
+ out.append("skinparam queue {")
464
+ out.append(" BorderColor #E65100")
465
+ out.append(" BackgroundColor #FFF3E0")
466
+ out.append(" FontColor #BF360C")
467
+ out.append("}")
468
+ out.append("skinparam sequence {")
469
+ out.append(" ArrowColor #1E88E5")
470
+ out.append(" LifeLineBorderColor #90A4AE")
471
+ out.append(" LifeLineBackgroundColor #ECEFF1")
472
+ out.append(" DividerBorderColor #B0BEC5")
473
+ out.append(" DividerBackgroundColor #ECEFF1")
474
+ out.append(" DividerFontColor #263238")
475
+ out.append("}\n")
476
+
477
+ for line in lines:
478
+ if line.startswith("%%") or line == "sequenceDiagram" or line == "autonumber":
479
+ continue
480
+
481
+ # Check for actor / participant / database / queue
482
+ part_match = re.match(
483
+ r'^(actor|participant|database|queue)\s+([A-Za-z0-9_]+)(?:\s+as\s+(.*))?$',
484
+ line,
485
+ re.IGNORECASE,
486
+ )
487
+ if part_match:
488
+ kind = part_match.group(1).lower()
489
+ p_id = part_match.group(2)
490
+ label = part_match.group(3) or p_id
491
+ label_clean = (
492
+ label.strip('"')
493
+ .strip("'")
494
+ .replace("<br/>", "\\n")
495
+ .replace("<br>", "\\n")
496
+ )
497
+ # Smart element type inference
498
+ if kind == "participant":
499
+ if (
500
+ p_id.lower() == "db"
501
+ or "database" in label_clean.lower()
502
+ or "sql server" in label_clean.lower()
503
+ ):
504
+ kind = "database"
505
+ elif "queue" in label_clean.lower() or "gateway" in label_clean.lower():
506
+ kind = "queue"
507
+ out.append(f'{kind} "{label_clean}" as {p_id}')
508
+ continue
509
+
510
+ # Check for Note over A,B: Text OR Note over A: Text
511
+ note_match = re.match(
512
+ r'^[Nn]ote\s+(over|left of|right of)\s+([A-Za-z0-9_,\s]+):\s*(.*)$',
513
+ line,
514
+ )
515
+ if note_match:
516
+ placement = note_match.group(1).lower()
517
+ targets = note_match.group(2).strip()
518
+ text = note_match.group(3).strip()
519
+ text_clean = text.replace("<br/>", "\\n").replace("<br>", "\\n")
520
+
521
+ # Major phase dividers vs in-place notes
522
+ if re.match(r'^\d+\.\s+', text_clean):
523
+ out.append(f"\n== {text_clean} ==")
524
+ else:
525
+ out.append(f"note {placement} {targets} : {text_clean}")
526
+ continue
527
+
528
+ # Check for message arrows
529
+ msg_match = re.match(
530
+ r'^([A-Za-z0-9_]+)\s*(-->>|->>|-->|->)\s*([A-Za-z0-9_]+)\s*:\s*(.*)$',
531
+ line,
532
+ )
533
+ if msg_match:
534
+ src = msg_match.group(1)
535
+ arrow_token = msg_match.group(2)
536
+ dst = msg_match.group(3)
537
+ msg_text = msg_match.group(4).strip()
538
+ msg_clean = msg_text.replace("<br/>", "\\n").replace("<br>", "\\n")
539
+ arrow = "-->" if "--" in arrow_token else "->"
540
+ out.append(f"{src} {arrow} {dst} : {msg_clean}")
541
+ continue
542
+
543
+ out.append("\n@enduml")
544
+ return "\n".join(out)
545
+
546
+
547
+ def get_plantuml_code(code_clean: str, feature_slug: str) -> str:
548
+ """Return matching clean PlantUML code with corporate Material Design styling."""
549
+ # If the markdown block already contains valid PlantUML, pass it through directly
550
+ if code_clean.startswith("@startuml"):
551
+ return code_clean
552
+
553
+ # 1. Dynamic Mermaid Sequence Diagram parsing
554
+ if "sequenceDiagram" in code_clean:
555
+ parsed_seq = _parse_mermaid_sequence_diagram(code_clean)
556
+ if parsed_seq:
557
+ return parsed_seq
558
+ # Fallback to legacy static sequence diagram
559
+ return """@startuml
560
+ autonumber
561
+ skinparam shadowing false
562
+ skinparam defaultFontName "Segoe UI"
563
+ skinparam defaultFontSize 12
564
+ skinparam roundCorner 6
565
+
566
+ skinparam actor {
567
+ BorderColor #3949AB
568
+ BackgroundColor #E8EAF6
569
+ FontColor #1A237E
570
+ }
571
+ skinparam participant {
572
+ BorderColor #455A64
573
+ BackgroundColor #F8FAFC
574
+ FontColor #263238
575
+ }
576
+ skinparam sequence {
577
+ ArrowColor #1E88E5
578
+ LifeLineBorderColor #90A4AE
579
+ LifeLineBackgroundColor #ECEFF1
580
+ DividerBorderColor #B0BEC5
581
+ DividerBackgroundColor #ECEFF1
582
+ DividerFontColor #263238
583
+ }
584
+
585
+ actor "Screener / Operations User" as User
586
+ participant "User Web Browser\\n(React)" as UI
587
+ participant "Backend Service\\n(FastAPI)" as API
588
+ participant "Diagnostics Worker" as Worker
589
+ database "SQL Server Database" as DB
590
+ queue "Apigee API Gateway" as Apigee
591
+ participant "Network & Outage APIs" as Telemetry
592
+ participant "Language Model Service" as Model
593
+
594
+ == 1. Automated Telemetry Ingestion & Analysis ==
595
+ Worker -> DB: Query scheduled appointments & customer history
596
+ DB --> Worker: Return pending appointment records
597
+ Worker -> Apigee: Request device metrics & outage data (OAuth2)
598
+ Apigee -> Telemetry: Call SAA NXT, FCAPS, CDN, Outage APIs
599
+ Telemetry --> Apigee: Return raw JSON telemetry
600
+ Apigee --> Worker: Return authenticated telemetry payloads
601
+ Worker -> Worker: Evaluate physical threshold rules
602
+ Worker -> Worker: Compile Network, Case & Account Summaries
603
+ Worker -> Model: Send combined summaries for recommendation
604
+ Model --> Worker: Return structured triage recommendation
605
+ Worker -> DB: Store diagnostics, summaries & recommendations
606
+
607
+ == 2. Screener Review & Queue Management ==
608
+ User -> UI: Open appointment screening queue
609
+ UI -> API: GET /api/cases (with date & filter criteria)
610
+ API -> DB: Query screened cases, health indicators & summaries
611
+ DB --> API: Return case datasets and diagnostics
612
+ API --> UI: Return cases, health scores, and summaries to queue view
613
+
614
+ == 3. Decision Capture via Feedback Service ==
615
+ User -> UI: Select case, review recommendation & decide action
616
+ User -> UI: Submit decision (Keep, Cancel, Override, Defer) with notes
617
+ UI -> API: POST /api/cases/{id}/decision (Feedback Service)
618
+ API -> DB: Record screener decision, notes & audit metadata into AppScreen_Case_Feedback
619
+ DB --> API: Confirm write operation
620
+ API --> UI: Return updated case state and confirmation
621
+ UI --> User: Display updated status and audit record
622
+
623
+ @enduml"""
624
+
625
+ # 2. Dynamic Mermaid Flowchart parsing with Orthogonal Routing & Material Design
626
+ parsed_uml = _parse_mermaid_flowchart(code_clean)
627
+ if parsed_uml:
628
+ return parsed_uml
629
+
630
+ # 3. End-to-End Database and Data Processing Pipeline Diagram (Static Fallback)
631
+ elif "CASE_FAULTS" in code_clean or "Pipeline" in code_clean or "rlhfStyle" in code_clean:
632
+ return """@startuml
633
+ skinparam shadowing false
634
+ skinparam defaultFontName "Segoe UI"
635
+ skinparam defaultFontSize 11
636
+ skinparam roundCorner 6
637
+ skinparam linetype ortho
638
+ skinparam nodesep 80
639
+ skinparam ranksep 45
640
+
641
+ skinparam package {
642
+ BorderColor #546E7A
643
+ BackgroundColor #F8FAFC
644
+ FontColor #263238
645
+ FontStyle bold
646
+ }
647
+
648
+ skinparam database<<source_db>> {
649
+ BorderColor #3949AB
650
+ BackgroundColor #E8EAF6
651
+ FontColor #1A237E
652
+ }
653
+
654
+ skinparam database<<staging_db>> {
655
+ BorderColor #0277BD
656
+ BackgroundColor #E1F5FE
657
+ FontColor #01579B
658
+ }
659
+
660
+ skinparam rectangle<<ai_model>> {
661
+ BorderColor #6A1B9A
662
+ BackgroundColor #F3E5F5
663
+ FontColor #4A148C
664
+ }
665
+
666
+ skinparam rectangle<<user_ui>> {
667
+ BorderColor #E65100
668
+ BackgroundColor #FFF3E0
669
+ FontColor #BF360C
670
+ }
671
+
672
+ skinparam database<<rlhf_db>> {
673
+ BorderColor #2E7D32
674
+ BackgroundColor #E8F5E9
675
+ FontColor #1B5E20
676
+ }
677
+
678
+ package "Layer 1: Upstream Ingestion & Telemetry Enrichment" as L1 {
679
+ database "**Host: IEROXAPP2**\\n**DB: DERBY**\\n`dbo.CASE_FAULTS & CFY_CASES`\\nClarify Trouble Tickets\\n[sp_CASE_FAULTS on IEROXAPP2]" as SourceDB <<source_db>>
680
+ database "**Host: DBUATL01**\\n**DB: Customer_FeedBack_JIT**\\n`dbo.AppScreen_Pending_Calls`\\nPending Appointments Queue\\n[Ingestion ETL via Linked Server]" as PendingDB <<staging_db>>
681
+ database "**Host: DBUATL01**\\n**DB: Customer_FeedBack_JIT**\\n`dbo.AppScreen_Case_Diagnostics`\\nEnriched Telemetry & RAG Status\\n[Diagnostics Worker on App Host]" as DiagDB <<staging_db>>
682
+
683
+ SourceDB -right-> PendingDB : 1. Ingest
684
+ PendingDB -right-> DiagDB : 2. Enrich
685
+ }
686
+
687
+ package "Layer 2: AI Multi-Track Inference & Recommendation" as L2 {
688
+ rectangle "**Host: App Server / Azure**\\n**AI Model Engine (GPT-4o)**\\n3-Track Summarization & Triage\\n[LLM Service on App Host]" as LLM <<ai_model>>
689
+ database "**Host: DBUATL01**\\n**DB: Customer_FeedBack_JIT**\\n`dbo.AppScreen_Case_Recommendation`\\nRecommendations & Confidence\\n[save_recommendations on App Host]" as RecDB <<staging_db>>
690
+
691
+ LLM -right-> RecDB : 4. Predict
692
+ }
693
+
694
+ package "Layer 3: Screener Review, Locking & RLHF Feedback" as L3 {
695
+ rectangle "**Host: Browser / App Server**\\n**Dispatcher Web Dashboard**\\nScreener Review & Advisory Locks\\n[`dbo.AppScreen_Case_Assignment`]" as UI <<user_ui>>
696
+ database "**Host: DBUATL01**\\n**DB: Customer_FeedBack_JIT**\\n`dbo.AppScreen_Case_Feedback`\\nScreener Decisions & RLHF Ground Truth\\n[FastAPI /api/cases/{id}/decision]" as FeedbackDB <<rlhf_db>>
697
+
698
+ UI -right-> FeedbackDB : 6. Feedback
699
+ }
700
+
701
+ L1 -[hidden]down- L2
702
+ L2 -[hidden]down- L3
703
+
704
+ DiagDB -down-> LLM : 3. Prompt Features
705
+ RecDB -down-> UI : 5. Present to Queue
706
+
707
+ @enduml"""
708
+
709
+ # 3. Summarization and Recommendation Pipeline Diagram
710
+ elif "Summarization" in code_clean or "Synthesis" in code_clean:
711
+ return """@startuml
712
+ skinparam shadowing false
713
+ skinparam defaultFontName "Segoe UI"
714
+ skinparam defaultFontSize 11
715
+ skinparam roundCorner 6
716
+ skinparam linetype ortho
717
+ skinparam nodesep 50
718
+ skinparam ranksep 35
719
+
720
+ skinparam package {
721
+ BorderColor #546E7A
722
+ BackgroundColor #F8FAFC
723
+ FontColor #263238
724
+ FontStyle bold
725
+ }
726
+
727
+ skinparam rectangle<<track1>> {
728
+ BorderColor #00695C
729
+ BackgroundColor #E0F2F1
730
+ FontColor #004D40
731
+ }
732
+
733
+ skinparam rectangle<<track2>> {
734
+ BorderColor #E65100
735
+ BackgroundColor #FFF3E0
736
+ FontColor #BF360C
737
+ }
738
+
739
+ skinparam rectangle<<track3>> {
740
+ BorderColor #3949AB
741
+ BackgroundColor #E8EAF6
742
+ FontColor #1A237E
743
+ }
744
+
745
+ skinparam rectangle<<synthesis>> {
746
+ BorderColor #6A1B9A
747
+ BackgroundColor #F3E5F5
748
+ FontColor #4A148C
749
+ }
750
+
751
+ skinparam rectangle<<outcome>> {
752
+ BorderColor #2E7D32
753
+ BackgroundColor #E8F5E9
754
+ FontColor #1B5E20
755
+ }
756
+
757
+ package "Stage 1: Multi-Track Data Analysis" as Inputs {
758
+ package "Track A: Network Health" as TrackA {
759
+ rectangle "1. SAA NXT, FCAPS, CDN Telemetry" as N1 <<track1>>
760
+ rectangle "2. Threshold Rules & Outage Check" as N2 <<track1>>
761
+ rectangle "**Network Health Summary**" as NSum <<track1>>
762
+ N1 -right-> N2
763
+ N2 -right-> NSum
764
+ }
765
+
766
+ package "Track B: Case History" as TrackB {
767
+ rectangle "1. 30-Day CRM Interactions" as C1 <<track2>>
768
+ rectangle "2. Repeat Fault Clustering" as C2 <<track2>>
769
+ rectangle "**Customer Case Summary**" as CSum <<track2>>
770
+ C1 -right-> C2
771
+ C2 -right-> CSum
772
+ }
773
+
774
+ package "Track C: Account Profile" as TrackC {
775
+ rectangle "1. Equipment Inventory (CPE)" as A1 <<track3>>
776
+ rectangle "2. Subscribed Services & Plans" as A2 <<track3>>
777
+ rectangle "**Account Profile Summary**" as ASum <<track3>>
778
+ A1 -right-> A2
779
+ A2 -right-> ASum
780
+ }
781
+ }
782
+
783
+ package "Stage 2: AI Synthesis" as Synthesis {
784
+ rectangle "**Language Model Synthesis Engine**\\nEvaluates Network, Case, and Account Summaries" as LLMEngine <<synthesis>>
785
+ }
786
+
787
+ package "Stage 3: Decision Support" as Decision {
788
+ rectangle "**Action Recommendation & Justification**\\nKeep / Cancel / Defer / Tech Visit" as RecOutput <<outcome>>
789
+ }
790
+
791
+ TrackA -[hidden]down- TrackB
792
+ TrackB -[hidden]down- TrackC
793
+
794
+ NSum -right-> LLMEngine : Line Quality
795
+ CSum -right-> LLMEngine : Case History
796
+ ASum -right-> LLMEngine : Equipment
797
+
798
+ LLMEngine -right-> RecOutput : Recommended Action
799
+
800
+ @enduml"""
801
+
802
+ # 4. Architecture Topology Diagram
803
+ else:
804
+ return """@startuml
805
+ skinparam shadowing false
806
+ skinparam defaultFontName "Segoe UI"
807
+ skinparam defaultFontSize 11
808
+ skinparam roundCorner 6
809
+ skinparam linetype ortho
810
+ skinparam nodesep 60
811
+ skinparam ranksep 50
812
+
813
+ skinparam package {
814
+ BorderColor #455A64
815
+ BackgroundColor #F8FAFC
816
+ FontColor #263238
817
+ FontStyle bold
818
+ }
819
+
820
+ skinparam rectangle<<user>> {
821
+ BorderColor #3949AB
822
+ BackgroundColor #E8EAF6
823
+ FontColor #1A237E
824
+ }
825
+ skinparam rectangle<<proxy>> {
826
+ BorderColor #455A64
827
+ BackgroundColor #ECEFF1
828
+ FontColor #263238
829
+ }
830
+ skinparam rectangle<<service>> {
831
+ BorderColor #00695C
832
+ BackgroundColor #E0F2F1
833
+ FontColor #004D40
834
+ }
835
+ skinparam queue<<gateway>> {
836
+ BorderColor #E65100
837
+ BackgroundColor #FFF3E0
838
+ FontColor #BF360C
839
+ }
840
+ skinparam database<<db>> {
841
+ BorderColor #0277BD
842
+ BackgroundColor #E1F5FE
843
+ FontColor #01579B
844
+ }
845
+ skinparam rectangle<<telemetry>> {
846
+ BorderColor #C2185B
847
+ BackgroundColor #FCE4EC
848
+ FontColor #880E4F
849
+ }
850
+ skinparam rectangle<<model>> {
851
+ BorderColor #6A1B9A
852
+ BackgroundColor #F3E5F5
853
+ FontColor #4A148C
854
+ }
855
+
856
+ package "User Access Layer" as UserLayer {
857
+ rectangle "**User Web Browser**\\nDispatch & Screening Operations" as Browser <<user>>
858
+ }
859
+
860
+ package "Application Server (Internal Windows Server Host)" as HostLayer {
861
+ rectangle "**IIS Web Server**\\nReverse Proxy & Auth Gate" as IIS <<proxy>>
862
+ rectangle "**Static Web Assets**\\nCompiled React / Vite SPA" as UI <<service>>
863
+ rectangle "**FastAPI Backend Service**\\nREST APIs & Feedback Service (:8203)" as API <<service>>
864
+ rectangle "**Diagnostics Worker Engine**\\nScheduled Polling & Pipeline Rules" as Worker <<service>>
865
+ }
866
+
867
+ package "Artificial Intelligence Services" as ModelLayer {
868
+ rectangle "**Language Model Service**\\nSummarization & Triage Inference" as LLM <<model>>
869
+ }
870
+
871
+ package "Integration & Middleware Layer" as MidLayer {
872
+ database "**Configuration Store**\\nSQLite" as SQLite <<db>>
873
+ database "**SQL Server Database**\\nCases & Feedback Tables" as SQL <<db>>
874
+ queue "**Apigee API Gateway**\\nOAuth2 Token Management & Routing" as Apigee <<gateway>>
875
+ }
876
+
877
+ package "Core Telemetry APIs (via Apigee Gateway)" as TelemetryLayer {
878
+ rectangle "**SAA NXT APIs**\\nCable Modem HFC Diagnostics" as SAA <<telemetry>>
879
+ rectangle "**FCAPS Health API**\\nFiber ONT & IPTV Metrics" as FCAPS <<telemetry>>
880
+ rectangle "**Device Mgmt v3 API**\\nSet-Top Box Diagnostics" as CDN <<telemetry>>
881
+ rectangle "**Outage Checker APIs**\\nIE MSP & ServiceNow Incidents" as Outage <<telemetry>>
882
+ }
883
+
884
+ Browser -down-> IIS : HTTPS / Win Auth
885
+ IIS -right-> UI
886
+ IIS -down-> API : /api/*
887
+
888
+ API -down-> SQLite : Config DB
889
+ API -down-> SQL : Read / Feedback
890
+
891
+ Worker -down-> SQL : Poll Pending & Store
892
+ Worker -right-> LLM : HTTPS
893
+ Worker -down-> Apigee : Telemetry Auth
894
+
895
+ Apigee -down-> SAA : HFC Metrics
896
+ Apigee -down-> FCAPS : Fiber Metrics
897
+ Apigee -down-> CDN : CDM Metrics
898
+ Apigee -down-> Outage : Incident Records
899
+
900
+ @enduml"""
901
+
902
+
903
+ def convert_diagram_to_plantuml_macro(code: str, feature_slug: str) -> str:
904
+ """Wrap PlantUML diagram in native Confluence storage format macro."""
905
+ code = sanitize_ai_slop_and_punctuation(code)
906
+ puml = get_plantuml_code(code.strip(), feature_slug)
907
+ puml = sanitize_ai_slop_and_punctuation(puml)
908
+ return (
909
+ f'<p><ac:structured-macro ac:name="plantuml" ac:schema-version="1">\n'
910
+ f'<ac:parameter ac:name="atlassian-macro-output-type">INLINE</ac:parameter>\n'
911
+ f'<ac:plain-text-body><![CDATA[{puml}]]></ac:plain-text-body>\n'
912
+ f'</ac:structured-macro></p>'
913
+ )
914
+
915
+
916
+ def markdown_to_confluence_xhtml(
917
+ md_text: str,
918
+ feature_slug: str = "system",
919
+ ) -> str:
920
+ """Convert full Markdown text to Confluence Storage Format XHTML."""
921
+ cleaned = strip_frontmatter(md_text)
922
+ cleaned = strip_references_and_ai_metadata(cleaned)
923
+ cleaned = sanitize_ai_slop_and_punctuation(cleaned)
924
+
925
+ lines = cleaned.splitlines()
926
+ output = []
927
+ i = 0
928
+ in_diagram = False
929
+ diagram_lines = []
930
+
931
+ in_table = False
932
+ table_lines = []
933
+
934
+ list_stack = [] # stack of (indent_level, list_tag) e.g. (0, 'ul')
935
+
936
+ def close_lists_to_indent(target_indent: int):
937
+ while list_stack and list_stack[-1][0] >= target_indent:
938
+ _, tag = list_stack.pop()
939
+ output.append(f"</{tag}>")
940
+
941
+ def close_all_lists():
942
+ while list_stack:
943
+ _, tag = list_stack.pop()
944
+ output.append(f"</{tag}>")
945
+
946
+ while i < len(lines):
947
+ line = lines[i]
948
+ stripped = line.strip()
949
+
950
+ # Handle Diagram code fence (mermaid or plantuml)
951
+ if stripped.startswith("```mermaid") or stripped.startswith("```plantuml"):
952
+ close_all_lists()
953
+ in_diagram = True
954
+ diagram_lines = []
955
+ i += 1
956
+ continue
957
+ elif in_diagram:
958
+ if stripped.startswith("```"):
959
+ in_diagram = False
960
+ diagram_code = "\n".join(diagram_lines)
961
+ converted_macro = convert_diagram_to_plantuml_macro(diagram_code, feature_slug)
962
+ output.append(converted_macro)
963
+ else:
964
+ diagram_lines.append(line)
965
+ i += 1
966
+ continue
967
+
968
+ # Handle regular code block
969
+ if stripped.startswith("```"):
970
+ close_all_lists()
971
+ code_block = []
972
+ i += 1
973
+ while i < len(lines) and not lines[i].strip().startswith("```"):
974
+ code_block.append(lines[i])
975
+ i += 1
976
+ i += 1
977
+ code_content = "\n".join(code_block)
978
+ output.append(
979
+ f'<ac:structured-macro ac:name="code" ac:schema-version="1">'
980
+ f"<ac:plain-text-body><![CDATA[{code_content}]]></ac:plain-text-body>"
981
+ f"</ac:structured-macro>"
982
+ )
983
+ continue
984
+
985
+ # Handle Markdown table
986
+ if stripped.startswith("|") and stripped.endswith("|"):
987
+ close_all_lists()
988
+ in_table = True
989
+ table_lines.append(stripped)
990
+ i += 1
991
+ continue
992
+ elif in_table:
993
+ in_table = False
994
+ output.append(convert_table(table_lines))
995
+ table_lines = []
996
+
997
+ # Empty lines
998
+ if not stripped:
999
+ i += 1
1000
+ continue
1001
+
1002
+ # Horizontal rule
1003
+ if stripped in ("---", "___", "***"):
1004
+ close_all_lists()
1005
+ output.append("<hr/>")
1006
+ i += 1
1007
+ continue
1008
+
1009
+ # Table of contents placeholder
1010
+ if re.match(r"^#{1,3}\s+Table of Contents\b", stripped, re.IGNORECASE):
1011
+ close_all_lists()
1012
+ output.append("<h2>Table of Contents</h2>")
1013
+ output.append(
1014
+ '<p><ac:structured-macro ac:name="toc" ac:schema-version="1"/></p>'
1015
+ )
1016
+ output.append("<hr/>")
1017
+ # Skip subsequent lines until next header
1018
+ i += 1
1019
+ while i < len(lines) and not re.match(r"^(#{1,6})\s+", lines[i].strip()):
1020
+ i += 1
1021
+ continue
1022
+
1023
+ # Headers
1024
+ header_match = re.match(r"^(#{1,6})\s+(.*)$", stripped)
1025
+ if header_match:
1026
+ close_all_lists()
1027
+ level = len(header_match.group(1))
1028
+ htext = format_inline_markdown(header_match.group(2).strip())
1029
+ if level == 1:
1030
+ output.append(f"<h1>{htext}</h1>")
1031
+ else:
1032
+ output.append(f"<h{level}>{htext}</h{level}>")
1033
+ i += 1
1034
+ continue
1035
+
1036
+ # Blockquote / Notice (check for banned notices)
1037
+ if stripped.startswith(">"):
1038
+ close_all_lists()
1039
+ raw_notice = stripped.lstrip(">").strip()
1040
+ lower_notice = raw_notice.lower()
1041
+ if any(k in lower_notice for k in ["document notice", "simplified technical english", "asd-ste100", "active voice"]):
1042
+ i += 1
1043
+ continue
1044
+ notice_text = format_inline_markdown(raw_notice)
1045
+ output.append(f"<p><em>{notice_text}</em></p>")
1046
+ i += 1
1047
+ continue
1048
+
1049
+ # List items (unordered or ordered)
1050
+ indent = len(line) - len(line.lstrip(" "))
1051
+ ul_match = re.match(r"^[-*]\s+(.*)$", stripped)
1052
+ ol_match = re.match(r"^\d+\.\s+(.*)$", stripped)
1053
+
1054
+ if ul_match or ol_match:
1055
+ list_tag = "ul" if ul_match else "ol"
1056
+ item_text = format_inline_markdown((ul_match or ol_match).group(1).strip())
1057
+
1058
+ # Manage list nesting
1059
+ if not list_stack or indent > list_stack[-1][0]:
1060
+ list_stack.append((indent, list_tag))
1061
+ output.append(f"<{list_tag}>")
1062
+ elif indent < list_stack[-1][0]:
1063
+ close_lists_to_indent(indent)
1064
+ if not list_stack or list_stack[-1][0] < indent:
1065
+ list_stack.append((indent, list_tag))
1066
+ output.append(f"<{list_tag}>")
1067
+
1068
+ output.append(f" <li>{item_text}</li>")
1069
+ i += 1
1070
+ continue
1071
+ else:
1072
+ close_all_lists()
1073
+
1074
+ # Regular paragraph
1075
+ p_text = format_inline_markdown(stripped)
1076
+ output.append(f"<p>{p_text}</p>")
1077
+ i += 1
1078
+
1079
+ close_all_lists()
1080
+ if in_table:
1081
+ output.append(convert_table(table_lines))
1082
+
1083
+ return "\n".join(output)
1084
+
1085
+
1086
+ def validate_confluence_xhtml(xhtml_content: str) -> bool:
1087
+ """Validate that the XHTML is well-formed XML compatible with Confluence."""
1088
+ wrapped = (
1089
+ f'<root xmlns:ac="http://www.atlassian.com/schema/confluence/4/ac/" '
1090
+ f'xmlns:ri="http://www.atlassian.com/schema/confluence/4/ri/">'
1091
+ f"{xhtml_content}"
1092
+ f"</root>"
1093
+ )
1094
+ try:
1095
+ ET.fromstring(wrapped)
1096
+ return True
1097
+ except ET.ParseError as e:
1098
+ print(f"XHTML validation error: {e}", file=sys.stderr)
1099
+ return False
1100
+
1101
+
1102
+ def main():
1103
+ parser = argparse.ArgumentParser(
1104
+ description="Convert HLA Markdown to Confluence Storage Format XHTML with PlantUML macros"
1105
+ )
1106
+ parser.add_argument("input_file", help="Path to input HLA Markdown file")
1107
+ parser.add_argument("--output", "-o", help="Path to output HTML file (optional)")
1108
+ parser.add_argument(
1109
+ "--slug",
1110
+ "-s",
1111
+ default="system",
1112
+ help="Feature slug for diagram names (e.g. appointment-service)",
1113
+ )
1114
+ parser.add_argument(
1115
+ "--lineage-only",
1116
+ action="store_true",
1117
+ help="Extract and convert only Section 4.5 (Database and Data Processing Pipeline Lineage)",
1118
+ )
1119
+ args = parser.parse_args()
1120
+
1121
+ input_path = Path(args.input_file)
1122
+ if not input_path.exists():
1123
+ print(f"Error: File not found: {input_path}", file=sys.stderr)
1124
+ sys.exit(1)
1125
+
1126
+ content = input_path.read_text(encoding="utf-8")
1127
+
1128
+ if args.lineage_only:
1129
+ # Match Section 4.5 (or any heading matching Pipeline Lineage) up to next section or end
1130
+ match = re.search(
1131
+ r"(###\s*4\.5\s+.*?)(?=(^###\s*4\.6|^##\s*5|\Z))",
1132
+ content,
1133
+ flags=re.DOTALL | re.MULTILINE,
1134
+ )
1135
+ if not match:
1136
+ print(
1137
+ "Error: Section 4.5 (Database Pipeline Lineage) not found in input document.",
1138
+ file=sys.stderr,
1139
+ )
1140
+ sys.exit(1)
1141
+ content = match.group(1)
1142
+
1143
+ slug = (
1144
+ args.slug if args.slug != "system" else input_path.stem.replace("-hla", "")
1145
+ )
1146
+
1147
+ xhtml = markdown_to_confluence_xhtml(
1148
+ content,
1149
+ feature_slug=slug,
1150
+ )
1151
+
1152
+ # Validate XML
1153
+ if not validate_confluence_xhtml(xhtml):
1154
+ print("Warning: Generated XHTML has XML parsing issues.", file=sys.stderr)
1155
+ else:
1156
+ print("XHTML validation successful: Content is well-formed XML.")
1157
+
1158
+ if args.output:
1159
+ output_path = Path(args.output)
1160
+ elif args.lineage_only:
1161
+ output_path = input_path.with_name(f"{input_path.stem}-lineage.html")
1162
+ else:
1163
+ output_path = input_path.with_suffix(".html")
1164
+
1165
+ output_path.write_text(xhtml, encoding="utf-8")
1166
+ print(f"Confluence XHTML successfully written to: {output_path}")
1167
+
1168
+
1169
+ if __name__ == "__main__":
1170
+ main()