init: llm-wiki-haness 하네스 설계
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small, dependency-free parsers for the wiki's structured Markdown contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
FM_RE = re.compile(r"^([A-Za-z_][\w-]*):\s*(.*)$")
|
||||
HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
|
||||
TABLE_SEP_RE = re.compile(r"^:?-{3,}:?$")
|
||||
SECTION_ID_RE = re.compile(r"^\s*<!--\s*section-id:\s*([a-z0-9][a-z0-9-]*)\s*-->\s*$")
|
||||
|
||||
|
||||
# 셀 전체가 하나의 코드스팬/강조일 때만 벗긴다. 예전에는 strip("`* ") 로 양끝 문자를
|
||||
# 무조건 깎았는데, 그러면 코드스팬으로 *시작만* 하는 셀이 여는 백틱을 잃는다 —
|
||||
# "`domain <- application` 의존 방향과 …" 가 "domain <- application` 의존 방향과 …" 가 돼
|
||||
# 투영된 표 11곳(hub 자신의 생성 블록 포함)에서 코드스팬이 깨져 있었다.
|
||||
SINGLE_CODE_SPAN_RE = re.compile(r"^`([^`]*)`$")
|
||||
SINGLE_EMPHASIS_RE = re.compile(r"^(\*{1,2})([^*]*)\1$")
|
||||
|
||||
|
||||
def clean(value: str) -> str:
|
||||
value = value.strip()
|
||||
while True:
|
||||
match = SINGLE_CODE_SPAN_RE.match(value) or SINGLE_EMPHASIS_RE.match(value)
|
||||
if match is None:
|
||||
break
|
||||
value = match.group(match.lastindex).strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
|
||||
value = value[1:-1]
|
||||
return value.strip()
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> dict[str, object]:
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return {}
|
||||
values: dict[str, object] = {}
|
||||
current: str | None = None
|
||||
for line in lines[1:]:
|
||||
if line.strip() == "---":
|
||||
break
|
||||
match = FM_RE.match(line)
|
||||
if match:
|
||||
current = match.group(1)
|
||||
raw = match.group(2).strip()
|
||||
if raw.startswith("[") and raw.endswith("]"):
|
||||
values[current] = [clean(item) for item in raw[1:-1].split(",") if clean(item)]
|
||||
else:
|
||||
values[current] = clean(raw)
|
||||
continue
|
||||
item = re.match(r"^\s+-\s+(.+?)\s*$", line)
|
||||
if item and current:
|
||||
if not isinstance(values.get(current), list):
|
||||
values[current] = []
|
||||
assert isinstance(values[current], list)
|
||||
values[current].append(clean(item.group(1)))
|
||||
return values
|
||||
|
||||
|
||||
def as_list(value: object) -> list[str]:
|
||||
if isinstance(value, list):
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
if isinstance(value, str) and value.strip():
|
||||
return [value.strip()]
|
||||
return []
|
||||
|
||||
|
||||
def split_row(line: str) -> list[str]:
|
||||
token = "\x00PIPE\x00"
|
||||
return [
|
||||
cell.strip().replace(token, "|")
|
||||
for cell in line.strip().strip("|").replace("\\|", token).split("|")
|
||||
]
|
||||
|
||||
|
||||
def header_key(value: str) -> str:
|
||||
return re.sub(r"[^0-9a-zA-Z가-힣]+", "", value).lower()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MarkdownTable:
|
||||
headings: tuple[str, ...]
|
||||
section_ids: tuple[str, ...]
|
||||
headers: tuple[str, ...]
|
||||
header_line: int
|
||||
rows: tuple[tuple[int, dict[str, str]], ...]
|
||||
|
||||
|
||||
def parse_tables(text: str) -> list[MarkdownTable]:
|
||||
lines = text.splitlines()
|
||||
headings: dict[int, str] = {}
|
||||
section_ids: dict[int, str] = {}
|
||||
pending_section_id = ""
|
||||
tables: list[MarkdownTable] = []
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
section_id = SECTION_ID_RE.match(lines[index])
|
||||
if section_id:
|
||||
pending_section_id = section_id.group(1)
|
||||
index += 1
|
||||
continue
|
||||
heading = HEADING_RE.match(lines[index])
|
||||
if heading:
|
||||
level = len(heading.group(1))
|
||||
headings = {key: value for key, value in headings.items() if key < level}
|
||||
section_ids = {key: value for key, value in section_ids.items() if key < level}
|
||||
headings[level] = heading.group(2).strip()
|
||||
if pending_section_id:
|
||||
section_ids[level] = pending_section_id
|
||||
pending_section_id = ""
|
||||
index += 1
|
||||
continue
|
||||
if (
|
||||
lines[index].lstrip().startswith("|")
|
||||
and index + 1 < len(lines)
|
||||
and lines[index + 1].lstrip().startswith("|")
|
||||
):
|
||||
headers = split_row(lines[index])
|
||||
separators = split_row(lines[index + 1])
|
||||
if len(headers) == len(separators) and all(TABLE_SEP_RE.fullmatch(item) for item in separators):
|
||||
rows: list[tuple[int, dict[str, str]]] = []
|
||||
cursor = index + 2
|
||||
keys = [header_key(header) for header in headers]
|
||||
while cursor < len(lines) and lines[cursor].lstrip().startswith("|"):
|
||||
cells = split_row(lines[cursor])
|
||||
cells += [""] * (len(headers) - len(cells))
|
||||
rows.append((cursor + 1, dict(zip(keys, cells))))
|
||||
cursor += 1
|
||||
tables.append(
|
||||
MarkdownTable(
|
||||
headings=tuple(headings.values()),
|
||||
section_ids=tuple(section_ids.values()),
|
||||
headers=tuple(headers),
|
||||
header_line=index + 1,
|
||||
rows=tuple(rows),
|
||||
)
|
||||
)
|
||||
index = cursor
|
||||
continue
|
||||
index += 1
|
||||
return tables
|
||||
|
||||
|
||||
def table_for(tables: Iterable[MarkdownTable], *headings: str) -> MarkdownTable | None:
|
||||
"""Return the first table under any accepted localized heading.
|
||||
|
||||
Structured column names remain stable machine schema. Section headings are
|
||||
presentation text, so readers and migration tools accept both the current
|
||||
Korean title and the legacy English title during rollout.
|
||||
"""
|
||||
needles = tuple(heading.casefold() for heading in headings)
|
||||
return next(
|
||||
(
|
||||
table
|
||||
for table in tables
|
||||
if any(
|
||||
needle in item.casefold()
|
||||
for needle in needles
|
||||
for item in (*table.headings, *(f"section-id:{value}" for value in table.section_ids))
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def cell(row: dict[str, str], name: str) -> str:
|
||||
return row.get(header_key(name), "")
|
||||
|
||||
|
||||
def replace_table_cell(
|
||||
text: str,
|
||||
table: MarkdownTable,
|
||||
row_line: int,
|
||||
header_name: str,
|
||||
value: str,
|
||||
) -> str:
|
||||
keys = [header_key(header) for header in table.headers]
|
||||
target_key = header_key(header_name)
|
||||
if target_key not in keys:
|
||||
raise ValueError(f"table has no {header_name!r} column")
|
||||
lines = text.splitlines(keepends=True)
|
||||
original = lines[row_line - 1]
|
||||
newline = "\n" if original.endswith("\n") else ""
|
||||
cells = split_row(original.rstrip("\n"))
|
||||
cells += [""] * (len(keys) - len(cells))
|
||||
cells[keys.index(target_key)] = value
|
||||
rendered = [item.replace("|", "\\|") for item in cells[: len(keys)]]
|
||||
lines[row_line - 1] = "| " + " | ".join(rendered) + " |" + newline
|
||||
return "".join(lines)
|
||||
Reference in New Issue
Block a user