Spaces:
Sleeping
Sleeping
Download app/core/visual_rules.py from Doss-8b-instruct/jb-marker: direct link, hf CLI and curl.
- Browser
- Download file 9.83 kB
-
https://huggingface.co/spaces/Doss-8b-instruct/jb-marker/resolve/main/app/core/visual_rules.py
- Command line
-
hf download hf://spaces/Doss-8b-instruct/jb-marker/app/core/visual_rules.py
-
curl -L -o visual_rules.py https://huggingface.co/spaces/Doss-8b-instruct/jb-marker/resolve/main/app/core/visual_rules.py
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, | |
| } | |