jb-marker / app /core /visual_rules.py
Doss-8b-instruct's picture
Upload folder using huggingface_hub
f02e0c3 verified
Raw History Blame Contribute Delete
9.83 kB
"""์‹œ๊ฐ ์ ๋ฒ•์„ฑ ๊ฒฐ์ •๋ก  ๋ฃฐ โ€” layout.spec์˜ ์‹œ๊ฐ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ํ•œ๊ตญ ๊ธˆ์œต๊ด‘๊ณ  ์‹œ๊ฐ๊ทœ์ œ๋กœ ๊ฒ€์‚ฌ.
ํ•œ๊ตญ ๊ธˆ์œต๊ด‘๊ณ  ๊ทœ์ œ๋Š” '์‹œ๊ฐ์  ํ‘œํ˜„'์„ ๋ช…์‹œ์ ์œผ๋กœ ๊ทœ์œจํ•œ๋‹ค(๊ฒ€์ฆ๋œ ์ถœ์ฒ˜):
- ๊ธˆ์œต์†Œ๋น„์ž๋ณดํ˜ธ๋ฒ• ์ œ22์กฐ โ€” ๊ด‘๊ณ  ํ•„์ˆ˜ ํ‘œ์‹œ์‚ฌํ•ญยท๊ท ํ˜• ์ „๋‹ฌ(๊ธ€์ž ์ƒ‰ยทํฌ๊ธฐ).
- ๊ธˆ์œตํˆฌ์žํ˜‘ํšŒ ใ€Œ๊ด‘๊ณ ์„ ์ „์—๊ด€ํ•œ์ง€์นจใ€ ์ œ5์กฐโ‘ฃ โ€” ๊ฒฝ๊ณ ๋ฌธ์–ธ ๊ธ€์žํฌ๊ธฐ โ‰ฅ ๊ด‘๊ณ  ์ตœ๋Œ€๊ธ€์ž์˜ 30%,
์ œ5์กฐโ‘ข ์ƒ‰์ƒ ๋Œ€๋น„, ์ œ5์กฐ์˜2 ์•ˆ๋‚ด๋ฌธ์–ธ โ‰ฅ ๋ณธ๋ฌธ ๊ธ€์žํฌ๊ธฐ.
- ๊ธˆ์œต์œ„ ใ€Œ๊ธˆ์œต๊ด‘๊ณ ๊ทœ์ œ ๊ฐ€์ด๋“œ๋ผ์ธใ€ โ€” ๊ธ€์ž ์ƒ‰๊น”ยทํฌ๊ธฐ๋กœ ํ˜œํƒ/๋ถˆ์ด์ต ๊ท ํ˜•ยท์‹œ์ธ์„ฑ.
MVP 3๋ฃฐ(๊ฒฐ์ •๋ก  โ€” ๋น„์ „ LLM ๋ฌด๊ด€, ํ”ฝ์…€/์น˜์ˆ˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๊ธฐ๋ฐ˜):
R-VIS-1 ๊ณ ์ง€ ๊ธ€์žํฌ๊ธฐ โ€” disclosure.font_px โ‰ฅ ์ตœ๋Œ€ ํ…์ŠคํŠธ ๊ธ€์žํฌ๊ธฐ ร— DISCLOSURE_MIN_FONT_RATIO
R-VIS-2 ๊ณ ์ง€ ๋Œ€๋น„ โ€” disclosure ํ…์ŠคํŠธ/๋ฐฐ๊ฒฝ ๋ช…๋„๋Œ€๋น„ โ‰ฅ MIN_CONTRAST_RATIO (WCAG AA)
R-VIS-3 ๊ณ ์ง€ ์กด์žฌ โ€” disclosure ์Šฌ๋กฏ + ์นดํ”ผ๊ฐ€ ์กด์žฌ(๋ˆ„๋ฝ=critical)
โš  ์ž„๊ณ„ ์ˆ˜์น˜๋Š” config ๊ธฐ๋ณธ๊ฐ’์ด๋‹ค. ํ˜‘ํšŒ(์€ํ–‰์—ฐํ•ฉํšŒ/๊ธˆํˆฌํ˜‘ ๋“ฑ) ์‹ฌ์˜๊ธฐ์ค€์˜ ์ •ํ™•ํ•œ ํฌ์ธํŠธ
์ˆ˜์น˜๋Š” ๋น„๊ณต๊ฐœ๋ผ verbatim ํ™•์ธ ๋ถˆ๊ฐ€(unverified) โ†’ ํ•ฉ๋ฆฌ์  ๊ทผ์‚ฌ์น˜ + ๊ณต์‹ ์ถœ์ฒ˜ ์ฒจ๋ถ€.
๋ฐฐ๊ฒฝ์ด ์ด๋ฏธ์ง€์ธ ๊ฒฝ์šฐ contrast๋Š” spec์˜ bg_color(๋Œ€ํ‘œ ๋ฐฐ๊ฒฝ ํ†ค) ์„ ์–ธ๊ฐ’์œผ๋กœ ๊ทผ์‚ฌํ•œ๋‹ค.
๋ฉ”ํƒ€๋ฐ์ดํ„ฐ(font_px/color/bg_color)๊ฐ€ ์—†์œผ๋ฉด ํ•ด๋‹น ๋ฃฐ์€ graceful skip(์˜คํƒ ๋ฐฉ์ง€).
"""
from __future__ import annotations
# ์ž„๊ณ„๊ฐ’ โ€” config ๊ธฐ๋ณธ๊ฐ’(ํ˜‘ํšŒ ์‹ฌ์˜๊ธฐ์ค€ ์‹ค์ˆ˜์น˜ ๋น„๊ณต๊ฐœ โ†’ ๊ทผ์‚ฌ์น˜ + ์ถœ์ฒ˜. ํ™•์ธ ํ•„์š”).
DISCLOSURE_MIN_FONT_RATIO = 0.3 # ๊ธˆํˆฌํ˜‘ ๊ด‘๊ณ ์„ ์ „์ง€์นจ ยง5โ‘ฃ ๊ฒฝ๊ณ ๋ฌธ์–ธ โ‰ฅ ์ตœ๋Œ€๊ธ€์ž 30%
MIN_CONTRAST_RATIO = 4.5 # WCAG 2.1 AA(๋ณธ๋ฌธ) โ€” ํ˜‘ํšŒ '์‹œ์ธ์„ฑ' ์š”๊ฑด ๊ทผ์‚ฌ
_SRC_KOFIA = "https://law.kofia.or.kr/service/law/lawFullScreenContent.do?seq=216&historySeq=591"
_SRC_FSC = "https://www.fsc.go.kr/po010101/76045"
_SRC_LAW_CONSUMER = "https://www.law.go.kr/๋ฒ•๋ น/๊ธˆ์œต์†Œ๋น„์ž๋ณดํ˜ธ์—๊ด€ํ•œ๋ฒ•๋ฅ /์ œ22์กฐ"
_TEXT_ROLES = ("headline", "body", "cta")
def _hex_to_rgb(c: str | None) -> tuple[int, int, int] | None:
c = (c or "").strip().lstrip("#")
if len(c) == 3:
c = "".join(ch * 2 for ch in c)
if len(c) != 6:
return None
try:
return (int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16))
except ValueError:
return None
def _rel_luminance(rgb: tuple[int, int, int]) -> float:
def chan(v: int) -> float:
s = v / 255.0
return s / 12.92 if s <= 0.03928 else ((s + 0.055) / 1.055) ** 2.4
r, g, b = (chan(x) for x in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast_ratio(fg_hex: str | None, bg_hex: str | None) -> float | None:
"""WCAG ๋ช…๋„๋Œ€๋น„๋น„(1.0~21.0). ์ƒ‰ ํŒŒ์‹ฑ ์‹คํŒจ ์‹œ None(๋ฃฐ graceful skip)."""
fg, bg = _hex_to_rgb(fg_hex), _hex_to_rgb(bg_hex)
if fg is None or bg is None:
return None
l1, l2 = _rel_luminance(fg), _rel_luminance(bg)
hi, lo = max(l1, l2), min(l1, l2)
return round((hi + 0.05) / (lo + 0.05), 2)
def _slots_by_role(spec: dict) -> dict:
out: dict = {}
for s in spec.get("slots", []) or []:
r = s.get("role")
if r:
out[r] = s
return out
def _max_text_font(slots: dict) -> int | None:
fonts = [slots[r].get("font_px") for r in _TEXT_ROLES
if r in slots and isinstance(slots[r].get("font_px"), (int, float))]
return max(fonts) if fonts else None
def _bbox_height(slot: dict) -> float | None:
"""์Šฌ๋กฏ bbox์—์„œ ๋†’์ด ์ถ”์ถœ โ€” dict({h}/{height}) ๋˜๋Š” list([x,y,w,h]) ํ˜•์‹ ์ง€์›."""
bb = slot.get("bbox")
if isinstance(bb, dict):
for k in ("h", "height"):
v = bb.get(k)
if isinstance(v, (int, float)) and v > 0:
return float(v)
if isinstance(bb, (list, tuple)) and len(bb) >= 4:
v = bb[3]
if isinstance(v, (int, float)) and v > 0:
return float(v)
return None
_ENRICH_ROLES = ("headline", "body", "cta", "disclosure")
def enrich_visual_metadata(spec: dict) -> dict:
"""layout.spec ํ…์ŠคํŠธ ์Šฌ๋กฏ์— font_px๊ฐ€ ์—†์œผ๋ฉด bbox ๋†’์ด๋กœ ๋ณด๊ฐ•(๊ฒฐ์ •๋ก ยทin-place).
์‹ค LLM์ด font_px๋ฅผ ์ƒ๋žตํ•ด๋„ R-VIS-1(๊ณ ์ง€ ๊ธ€์žํฌ๊ธฐ ๋น„์œจ)์ด '๋™์ž‘์€ ํ•˜๋„๋ก' ํ•˜๋Š” best-effort
๋ณด๊ฐ•์ด๋‹ค. ๋ณด๊ฐ•๊ฐ’์—๋Š” ``font_px_estimated=True``๋ฅผ ๋‹ฌ์•„ ์ถ”์ •์น˜์ž„์„ ํ‘œ์‹œํ•œ๋‹ค.
โš  ํ•œ๊ณ„(์ค‘์š”): bbox ๋†’์ด๋Š” '์—ฌ๋Ÿฌ ์ค„๋กœ ์ค„๋ฐ”๊ฟˆ๋œ ํ…์ŠคํŠธ ๋ธ”๋ก ์ „์ฒด ๋†’์ด'๋ผ ๋‹จ์ผํ–‰ ๊ธ€์žํฌ๊ธฐ๋ฅผ
๊ณผ๋Œ€์ถ”์ •ํ•œ๋‹ค. ํŠนํžˆ ๋‹คํ–‰ ๊ณ ์ง€(fine-print)๋Š” ๊ธ€์žํฌ๊ธฐ๊ฐ€ ์‹ค์ œ๋ณด๋‹ค ํฌ๊ฒŒ ์ถ”์ •๋ผ R-VIS-1์ด
์ž‘์€ ๊ณ ์ง€๋ฅผ '๋†“์น ' ์ˆ˜ ์žˆ๋‹ค(false negative, under-flag). ๋”ฐ๋ผ์„œ ์ด ๊ฒฐ์ •๋ก  ๋ฃฐ์€ ๋น„์ฐจ๋‹จ
warning ์ˆ˜์ค€์˜ 1์ฐจ ์‹ ํ˜ธ์ด๋ฉฐ, ์ •ํ™•ํ•œ ์‹œ๊ฐ ํŒ์ •์€ ๋น„์ „ LLM ๊ฒ€ํ† (R1 vision)๊ฐ€ ๋ณด์™„ํ•œ๋‹ค.
์ƒ‰/๋Œ€๋น„(R-VIS-2)๋Š” ์„ ์–ธ๊ฐ’์ด ์—†์œผ๋ฉด fabricateํ•˜์ง€ ์•Š๊ณ  graceful skipํ•œ๋‹ค(์˜คํƒ ๋ฐฉ์ง€).
"""
if not isinstance(spec, dict):
return spec
for s in spec.get("slots", []) or []:
if not isinstance(s, dict):
continue
if s.get("role") in _ENRICH_ROLES and not isinstance(s.get("font_px"), (int, float)):
h = _bbox_height(s)
if h:
s["font_px"] = int(round(h))
s["font_px_estimated"] = True # ์ถ”์ •์น˜ ํ‘œ์‹(metadata๊ฐ€ ์ธก์ •๊ฐ’๊ณผ ๊ตฌ๋ถ„)
return spec
def evaluate_visual_compliance(spec: dict) -> list[dict]:
"""layout.spec(slots + copy + bg_color)๋ฅผ 3๋ฃฐ๋กœ ๊ฒ€์‚ฌ โ†’ ์œ„๋ฐ˜ finding ๋ฆฌ์ŠคํŠธ.
๊ฐ finding: {rule, severity, slot, clause, official_source_url, evidence, metrics}.
๋ฉ”ํƒ€๋ฐ์ดํ„ฐ(font_px/color)๊ฐ€ ์—†์œผ๋ฉด ํ•ด๋‹น ๋ฃฐ skip(์˜คํƒ ๋ฐฉ์ง€). ์œ„๋ฐ˜ ์—†์œผ๋ฉด ๋นˆ ๋ฆฌ์ŠคํŠธ.
"""
if not isinstance(spec, dict):
return []
findings: list[dict] = []
slots = _slots_by_role(spec)
disc = slots.get("disclosure")
copy = spec.get("copy") or {}
# R-VIS-3 ๊ณ ์ง€ ์กด์žฌ: disclosure ์Šฌ๋กฏ + (copy๊ฐ€ ์žˆ์œผ๋ฉด) ๊ณ ์ง€ ์นดํ”ผ๊ฐ€ ๋น„์–ด์žˆ์ง€ ์•Š์•„์•ผ ํ•จ.
has_disc_copy = any((copy.get(l) or {}).get("disclosure") for l in copy)
if disc is None or (copy and not has_disc_copy):
findings.append({
"rule": "R-VIS-3", "severity": "critical", "slot": "disclosure",
"clause": "๊ธˆ์œต์†Œ๋น„์ž๋ณดํ˜ธ๋ฒ• ์ œ22์กฐ(๊ด‘๊ณ  ์‹œ ํ•„์ˆ˜ ํ‘œ์‹œ์‚ฌํ•ญ)",
"official_source_url": _SRC_LAW_CONSUMER,
"evidence": "ํ•„์ˆ˜ ๊ณ ์ง€(disclosure) ์š”์†Œ๊ฐ€ ๋ ˆ์ด์•„์›ƒ์— ์—†๊ฑฐ๋‚˜ ์นดํ”ผ๊ฐ€ ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.",
"metrics": {},
})
return findings # ๊ณ ์ง€ ์ž์ฒด๊ฐ€ ์—†์œผ๋ฉด ๊ธ€์žํฌ๊ธฐยท๋Œ€๋น„๋Š” ํ‰๊ฐ€ ๋ถˆ๊ฐ€ โ†’ ์กฐ๊ธฐ ๋ฐ˜ํ™˜.
# R-VIS-1 ๊ณ ์ง€ ๊ธ€์žํฌ๊ธฐ โ‰ฅ ์ตœ๋Œ€ ํ…์ŠคํŠธ ๊ธ€์žํฌ๊ธฐ ร— ๋น„์œจ
disc_font = disc.get("font_px")
max_font = _max_text_font(slots)
if isinstance(disc_font, (int, float)) and max_font:
ratio = disc_font / max_font
if ratio < DISCLOSURE_MIN_FONT_RATIO:
findings.append({
"rule": "R-VIS-1", "severity": "warning", "slot": "disclosure",
"clause": "๊ธˆ์œตํˆฌ์žํ˜‘ํšŒ ๊ด‘๊ณ ์„ ์ „์—๊ด€ํ•œ์ง€์นจ ์ œ5์กฐโ‘ฃ(๊ฒฝ๊ณ ๋ฌธ์–ธ โ‰ฅ ์ตœ๋Œ€๊ธ€์ž 30%)",
"official_source_url": _SRC_KOFIA,
"evidence": (f"๊ณ ์ง€ ๊ธ€์žํฌ๊ธฐ {disc_font}px๊ฐ€ ์ตœ๋Œ€ ํ…์ŠคํŠธ {max_font}px์˜ "
f"{ratio:.0%}๋กœ ๊ธฐ์ค€({DISCLOSURE_MIN_FONT_RATIO:.0%}) ๋ฏธ๋งŒ์ž…๋‹ˆ๋‹ค."),
"metrics": {"disclosure_font_px": disc_font,
"max_text_font_px": max_font, "ratio": round(ratio, 2)},
})
# R-VIS-2 ๊ณ ์ง€ ๋Œ€๋น„ โ‰ฅ 4.5:1 (WCAG AA)
cr = contrast_ratio(disc.get("color"), spec.get("bg_color"))
if cr is not None and cr < MIN_CONTRAST_RATIO:
findings.append({
"rule": "R-VIS-2", "severity": "warning", "slot": "disclosure",
"clause": "๊ธˆ์œต์œ„ ๊ธˆ์œต๊ด‘๊ณ ๊ทœ์ œ ๊ฐ€์ด๋“œ๋ผ์ธ(๊ธ€์ž ์ƒ‰ยทํฌ๊ธฐ ๊ท ํ˜•ยท์‹œ์ธ์„ฑ)",
"official_source_url": _SRC_FSC,
"evidence": (f"๊ณ ์ง€ ๋ช…๋„๋Œ€๋น„ {cr}:1๊ฐ€ ๊ธฐ์ค€({MIN_CONTRAST_RATIO}:1) ๋ฏธ๋งŒ์œผ๋กœ "
f"์‹œ์ธ์„ฑ์ด ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."),
"metrics": {"contrast_ratio": cr, "min_required": MIN_CONTRAST_RATIO},
})
return findings
def visual_compliance_summary(spec: dict) -> dict:
"""metadata.md ๊ธฐ๋ก์šฉ ์‹œ๊ฐ ์ปดํ”Œ๋ผ์ด์–ธ์Šค ๋ฉ”ํŠธ๋ฆญ + ์œ„๋ฐ˜ ์š”์•ฝ(๊ฒฐ์ •๋ก ).
Design ์‚ฐ์ถœ๋ฌผ metadata์— '์‹œ๊ฐ์  ๋ฌ˜์‚ฌ'๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ํ•ต์‹ฌ โ€” ๊ธ€์žํฌ๊ธฐยท๋Œ€๋น„ยท๊ณ ์ง€ ์œ„์น˜ ๋“ฑ
์ธก์ • ๊ฐ€๋Šฅํ•œ ์‹œ๊ฐ ์†์„ฑ์„ ๊ธฐ๋กํ•ด Review๊ฐ€ ๋น„์ „ LLM ์—†์ด๋„ 1์ฐจ ํŒ๋‹จํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•œ๋‹ค.
"""
if not isinstance(spec, dict):
spec = {}
slots = _slots_by_role(spec)
disc = slots.get("disclosure") or {}
findings = evaluate_visual_compliance(spec)
# ๊ธ€์žํฌ๊ธฐ๊ฐ€ bbox ๋†’์ด ๊ธฐ๋ฐ˜ ์ถ”์ •์น˜์ธ์ง€(LLM ์„ ์–ธ๊ฐ’ ์•„๋‹˜) โ€” metadata์—์„œ ์ธก์ •๊ฐ’๊ณผ ๊ตฌ๋ถ„.
estimated = any(bool(slots[r].get("font_px_estimated"))
for r in (*_TEXT_ROLES, "disclosure") if r in slots)
return {
"disclosure_font_px": disc.get("font_px"),
"max_text_font_px": _max_text_font(slots),
"disclosure_color": disc.get("color"),
"bg_color": spec.get("bg_color"),
"disclosure_contrast": contrast_ratio(disc.get("color"), spec.get("bg_color")),
"font_px_estimated": estimated,
"violations": [{"rule": f["rule"], "severity": f["severity"],
"evidence": f["evidence"]} for f in findings],
"passed": not findings,
}