@caddis/cli 0.4.13 → 0.4.15

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