Files
our-claude-skills/custom-skills/46-jamie-journal-editor/code/scripts/journal_validator.py

643 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""journal_validator.py — Jamie Journal Editor validation tool.
Usage:
python journal_validator.py --input article.md
python journal_validator.py --input article.md --check structure,compliance
python journal_validator.py --input article.md --output report.json --verbose
Checks available: structure, compliance, spelling, links (default: all)
Exit codes:
0 — validation passed (no critical/high issues)
1 — validation failed (critical or high issues found)
2 — input error (file not found, bad arguments, etc.)
"""
import re
import json
import sys
from typing import Dict, List, Tuple, Optional
from dataclasses import dataclass, asdict, field
import argparse
# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
@dataclass
class ValidationIssue:
checker: str
issue_type: str
severity: str # critical | high | medium | info
line_number: int
matched_text: str
message: str
suggestion: str
# ---------------------------------------------------------------------------
# Stub checker classes
# ---------------------------------------------------------------------------
class StructureValidator:
"""Validates document structure (headings, sections, word count, etc.)."""
OPENING = "안녕하세요. 제이미성형외과 정기호 원장입니다."
STEP_KEYWORDS = {
'problem': ['고민', '증상', '불편', '걱정', '힘드', '어려움'],
'cause': ['원인', '발생', '때문에', '이유', '구조적', '해부학적'],
'solution': ['시술', '수술', '방법', '제이미', '교정', '개선법'],
'advantages': ['장점', '회복', '흉터', '통증', '마취', '보장', 'AS'],
'results': ['결과', '효과', '기대', '개선', '자연스러', '만족'],
}
STEP_NAMES = {
'problem': '문제 제기 (공감)',
'cause': '원인 설명 (교육)',
'solution': '해결책 제시 (제이미의 방법)',
'advantages': '장점 나열 (차별점)',
'results': '기대 효과 (비전)',
}
MEDICAL_NOUNS = ['안검하수', '내시경', '거상술', '눈매교정', '매몰법', '절개법']
MEDICAL_TERM_PATTERN = re.compile(r'[가-힣]+\([一-龥々〇]+,\s*[A-Za-z\s\-]+\)')
CTA_PATTERNS = [
re.compile(r'상담을?\s*(추천|받아|예약)'),
re.compile(r'제이미성형외과.{0,20}(상담|문의)'),
re.compile(r'편안한\s*마음으로\s*상담'),
]
DISCLAIMER_FRAGMENTS = ['부작용', '출혈', '감염', '의료진과 상담']
def check(self, content: str) -> List[ValidationIssue]:
issues: List[ValidationIssue] = []
# 1. Opening format
if self.OPENING not in content:
issues.append(ValidationIssue(
checker='StructureValidator',
issue_type='missing_opening',
severity='high',
line_number=1,
matched_text='',
message=f'표준 인사말이 없습니다: "{self.OPENING}"',
suggestion=f'글 첫 줄을 "{self.OPENING}"으로 시작하세요.',
))
# 2. 5-step body structure
detected_steps = []
for step_key, keywords in self.STEP_KEYWORDS.items():
for kw in keywords:
if kw in content:
detected_steps.append(step_key)
break
missing_steps = [s for s in self.STEP_KEYWORDS if s not in detected_steps]
num_missing = len(missing_steps)
if num_missing >= 2:
missing_names = ', '.join(self.STEP_NAMES[s] for s in missing_steps)
issues.append(ValidationIssue(
checker='StructureValidator',
issue_type='incomplete_body_structure',
severity='high',
line_number=1,
matched_text='',
message=f'5단계 본문 구조에서 {num_missing}개 단계가 감지되지 않았습니다: {missing_names}',
suggestion='본문에 문제 제기, 원인 설명, 해결책 제시, 장점 나열, 기대 효과 섹션을 모두 포함하세요.',
))
elif num_missing == 1:
missing_names = self.STEP_NAMES[missing_steps[0]]
issues.append(ValidationIssue(
checker='StructureValidator',
issue_type='incomplete_body_structure',
severity='medium',
line_number=1,
matched_text='',
message=f'5단계 본문 구조에서 1개 단계가 감지되지 않았습니다: {missing_names}',
suggestion=f'"{missing_names}" 섹션에 해당하는 내용을 추가하세요.',
))
# 3. Medical term format
has_medical_noun = any(noun in content for noun in self.MEDICAL_NOUNS)
if has_medical_noun and not self.MEDICAL_TERM_PATTERN.search(content):
issues.append(ValidationIssue(
checker='StructureValidator',
issue_type='missing_medical_term_format',
severity='medium',
line_number=1,
matched_text='',
message='의학 용어가 포함되어 있으나 한자/영문 병기 형식이 없습니다.',
suggestion='예: "안검하수(眼瞼下垂, Ptosis)는 ..." 형식으로 첫 의학 용어를 병기하세요.',
))
# 4. Closing CTA (last 20% of content)
tail_start = max(0, int(len(content) * 0.8))
tail_content = content[tail_start:]
has_cta = any(p.search(tail_content) for p in self.CTA_PATTERNS)
if not has_cta:
total_lines = content.count('\n') + 1
issues.append(ValidationIssue(
checker='StructureValidator',
issue_type='missing_closing_cta',
severity='medium',
line_number=total_lines,
matched_text='',
message='글 마지막 20% 구간에 상담 유도(CTA) 문구가 없습니다.',
suggestion='"지금 바로 제이미성형외과의 [시술명] 상담을 추천드립니다." 형식의 CTA를 추가하세요.',
))
# 5. Required disclaimer (last 30% of content)
disclaimer_start = max(0, int(len(content) * 0.7))
disclaimer_content = content[disclaimer_start:]
found_fragments = [f for f in self.DISCLAIMER_FRAGMENTS if f in disclaimer_content]
if len(found_fragments) < 2:
total_lines = content.count('\n') + 1
issues.append(ValidationIssue(
checker='StructureValidator',
issue_type='missing_disclaimer',
severity='high',
line_number=total_lines,
matched_text='',
message='글 하단에 부작용 고지 면책 문구가 불충분합니다 (부작용, 출혈, 감염, 의료진과 상담 중 2개 이상 필요).',
suggestion='"개인에 따라 부작용(출혈, 감염, 염증 등)이 있을 수 있으니 사전에 의료진과 상담 후 결정하시기 바랍니다." 문구를 추가하세요.',
))
return issues
class ComplianceChecker:
"""Checks brand compliance rules for Jamie Clinic journal content."""
PROHIBITED_PATTERNS = {
'effect_guarantee': {
'severity': 'critical',
'patterns': [
r'100[%]\s*(?:만족|효과|성공)',
r'반드시\s+(?:효과|개선|만족)',
r'완벽한?\s+결과',
r'보장합니다',
r'확실한?\s+효과',
r'틀림없이?\s+(?:효과|개선)',
],
'message': '효과를 보장하는 표현은 의료법 제56조 제2항 제2호 위반입니다.',
'suggestion': '"개선에 도움을 줄 수 있습니다" 또는 "개인에 따라 결과가 다를 수 있습니다"로 변경하세요.',
},
'comparative_superiority': {
'severity': 'critical',
'patterns': [
r'최고의?',
r'(?:국내|강남|서울|압구정)\s*(?:최고|1위|최초)',
r'1위',
r'\s*병원보다',
r'다른\s*(?:병원|의원)보다\s*(?:우수|뛰어|좋)',
r'업계\s*최초',
r'독보적',
],
'message': '타 의료기관과의 비교 우위 주장은 의료법 제56조 제2항 제4호 위반입니다.',
'suggestion': '"풍부한 경험을 보유한" 또는 "저희만의 방법으로"와 같은 객관적 표현으로 변경하세요.',
},
'safety_guarantee': {
'severity': 'critical',
'patterns': [
r'부작용\s*(?:없|無|제로|zero)',
r'(?:100[%]|완전히?|절대)\s*안전',
r'위험\s*(?:없|無)',
r'흉터\s*(?:없|無|제로|zero)',
r'무통(?:증)?(?:\s|$|,)',
],
'message': '안전성을 보장하는 표현은 의료법 제56조 제2항 제7호 위반입니다.',
'suggestion': '부작용 가능성을 명시하고 "안전한 수술을 위해 최선을 다합니다"로 변경하세요.',
},
'patient_testimonial': {
'severity': 'critical',
'patterns': [
r'(?:환자|고객)\s*[가-힣A-Z]+\s*(?:씨|님)의?\s*(?:후기|경험)',
r'실제\s*(?:환자|고객)\s*(?:후기|리뷰|경험담)',
r'[""]\s*(?:정말|너무|진짜)\s+(?:만족|좋아요|감사)',
r'수술\s*후\s*\d+\s*(?:개월|주일|년)\s*(?:만족|경과)',
],
'message': '환자 치료경험담은 의료법 제56조 제2항 제2호 위반입니다.',
'suggestion': '환자 후기를 제거하고, 일반적인 수술 과정 설명으로 대체하세요.',
},
'exaggeration': {
'severity': 'high',
'patterns': [
r'(?:놀라운|대박|극적인)\s*(?:변화|효과|결과)',
r'마법같은?',
r'기적적인?',
r'다시\s*태어나',
r'완벽\s*변신',
],
'message': '과장된 표현은 의료법 제56조 제2항 제3호 위반 가능성이 있습니다.',
'suggestion': '"자연스러운 개선", "점진적인 효과"와 같은 절제된 표현으로 변경하세요.',
},
'specialty_claim': {
'severity': 'high',
'patterns': [
r'전문\s*병원',
r'특화\s*(?:병원|클리닉|센터)',
r'노하우',
],
'message': '전문병원 지정 없이 "전문", "특화" 표현 사용은 과장 광고에 해당합니다.',
'suggestion': '"중점진료", "집중진료", "풍부한 경험"으로 변경하세요.',
},
}
PROCEDURE_KEYWORDS = [
'수술', '시술', '이마거상', '쌍꺼풀', '리프팅', '보톡스', '필러',
'눈매교정', '안검하수', '지방이식', '절개',
]
def check(self, content: str) -> List[ValidationIssue]:
issues: List[ValidationIssue] = []
lines = content.split('\n')
# Check prohibited patterns
for category, rule in self.PROHIBITED_PATTERNS.items():
for raw_pat in rule['patterns']:
compiled = re.compile(raw_pat)
for m in compiled.finditer(content):
line_number = content[:m.start()].count('\n') + 1
issues.append(ValidationIssue(
checker='ComplianceChecker',
issue_type=category,
severity=rule['severity'],
line_number=line_number,
matched_text=m.group(),
message=rule['message'],
suggestion=rule['suggestion'],
))
# Check for missing disclaimers when procedure keywords present
has_procedure = any(kw in content for kw in self.PROCEDURE_KEYWORDS)
if has_procedure:
side_effect_terms = ['부작용', '합병증', '붓기', '', '염증']
if not any(t in content for t in side_effect_terms):
total_lines = len(lines)
issues.append(ValidationIssue(
checker='ComplianceChecker',
issue_type='missing_side_effect_notice',
severity='high',
line_number=total_lines,
matched_text='',
message='시술/수술 내용이 있으나 부작용 안내가 없습니다.',
suggestion='"부작용(출혈, 감염, 염증 등)이 있을 수 있습니다" 형태의 안내를 추가하세요.',
))
individual_variation_pattern = re.compile(r'개인(?:에 따라|마다|별|차이|.{0,30}다를 수|.{0,30}다릅니다)')
if not individual_variation_pattern.search(content):
total_lines = len(lines)
issues.append(ValidationIssue(
checker='ComplianceChecker',
issue_type='missing_individual_variation_notice',
severity='medium',
line_number=total_lines,
matched_text='',
message='시술/수술 내용이 있으나 개인차 고지가 없습니다.',
suggestion='"개인에 따라 결과가 다를 수 있습니다" 문구를 추가하세요.',
))
return issues
class SpellingChecker:
"""Checks for common spelling/terminology issues in medical/beauty content."""
# Format: wrong_term -> (correct_term, category)
MEDICAL_DICT: Dict[str, Tuple[str, str]] = {
'쌍커풀': ('쌍꺼풀', '눈 성형'),
'쌍거풀': ('쌍꺼풀', '눈 성형'),
'안겸하수': ('안검하수', '눈 성형'),
'눈꺼플': ('눈꺼풀', '눈 성형'),
'눈커풀': ('눈꺼풀', '눈 성형'),
'네시경': ('내시경', '이마 성형'),
'거상 술': ('거상술', '이마 성형'),
'앤도타인': ('엔도타인', '이마 성형'),
'봉합 사': ('봉합사', '이마 성형'),
'히알루론 산': ('히알루론산', '필러'),
'히알류론산': ('히알루론산', '필러'),
'지방 이식': ('지방이식', '동안 성형'),
'눈밑지방': ('눈밑 지방', '눈 성형'),
'다크 서클': ('다크서클', '눈 성형'),
'팔자주름': ('팔자 주름', '동안 성형'),
'절게법': ('절개법', '눈 성형'),
'매몰 법': ('매몰법', '눈 성형'),
'눈썹밑절개': ('눈썹밑 절개', '이마 성형'),
}
def check(self, content: str) -> List[ValidationIssue]:
issues: List[ValidationIssue] = []
lines = content.split('\n')
# Tier 1: built-in medical dictionary scan
for line_idx, line in enumerate(lines, start=1):
for wrong, (correct, category) in self.MEDICAL_DICT.items():
if wrong == correct:
continue
if wrong in line:
issues.append(ValidationIssue(
checker='SpellingChecker',
issue_type='medical_misspelling',
severity='medium',
line_number=line_idx,
matched_text=wrong,
message=f'[{category}] 잘못된 표기: "{wrong}""{correct}"',
suggestion=f'"{wrong}""{correct}"로 수정하세요.',
))
# Tier 2: optional py-hanspell
try:
from hanspell import spell_checker # type: ignore
for line_idx, line in enumerate(lines, start=1):
stripped = line.strip()
# Skip code blocks, short lines, headings
if stripped.startswith('```') or stripped.startswith('#') or len(stripped) < 5:
continue
try:
result = spell_checker.check(stripped)
if result.checked != stripped:
issues.append(ValidationIssue(
checker='SpellingChecker',
issue_type='hanspell_correction',
severity='medium',
line_number=line_idx,
matched_text=stripped[:80],
message=f'맞춤법 교정 제안: {result.result}',
suggestion=result.checked[:200],
))
except Exception:
pass # Network or parse error — skip this line
except ImportError:
issues.append(ValidationIssue(
checker='SpellingChecker',
issue_type='hanspell_unavailable',
severity='info',
line_number=0,
matched_text='',
message='py-hanspell이 설치되어 있지 않아 맞춤법 자동 검사를 건너뜁니다.',
suggestion='pip install py-hanspell 을 실행하면 한국어 맞춤법 자동 검사가 활성화됩니다.',
))
return issues
class LinkRecommender:
"""Recommends internal/external links and detects broken or missing ones."""
PROCEDURE_MAP: Dict[str, Tuple[str, List[str]]] = {
'double_eyelid': ('쌍꺼풀 수술', ['쌍꺼풀', '매몰법', '절개법', '퀵매몰']),
'ptosis': ('안검하수 눈매교정', ['안검하수', '눈매교정', '졸린 눈', '눈매 교정']),
'under_eye_fat': ('눈밑 지방 재배치', ['눈밑 지방', '다크서클', '눈밑지방', '지방 재배치']),
'dual_epicanthoplasty': ('듀얼 트임', ['듀얼 트임', '앞트임', '뒤트임', '눈꼬리']),
'forehead_lift': ('내시경 이마거상술', ['이마거상', '이마리프팅', '이마 거상', '이마 리프팅']),
'brow_lift': ('내시경 눈썹거상술', ['눈썹거상', '눈썹리프팅', '눈썹 거상', '눈썹 리프팅']),
'sub_brow': ('눈썹밑 피부절개술', ['눈썹밑', '상안검', '눈꺼풀 처짐']),
'smas_lifting': ('스마스 리프팅', ['스마스', 'SMAS', '안면거상', '페이스리프트']),
'fat_graft': ('자가지방 이식', ['자가지방', '지방이식', '지방 이식', '지방 생착']),
'hifu': ('하이푸 리프팅', ['하이푸', 'HIFU', '초음파 리프팅']),
'cheek_lift': ('앞광대 리프팅', ['앞광대', '팔자 주름', '팔자주름', '광대 리프팅']),
'endotine': ('엔도타인 고정', ['엔도타인', 'endotine', '3점 고정']),
}
def _detect_main_topic(self, lines: List[str]) -> Optional[str]:
"""Scan first 10 lines to find the main procedure topic."""
opening_text = '\n'.join(lines[:10])
for topic_key, (display_name, keywords) in self.PROCEDURE_MAP.items():
for kw in keywords:
if kw in opening_text:
return topic_key
return None
def check(self, content: str) -> List[ValidationIssue]:
issues: List[ValidationIssue] = []
lines = content.split('\n')
main_topic = self._detect_main_topic(lines)
for topic_key, (display_name, keywords) in self.PROCEDURE_MAP.items():
if topic_key == main_topic:
continue
for line_idx, line in enumerate(lines, start=1):
matched_kw = None
for kw in keywords:
if kw in line:
matched_kw = kw
break
if matched_kw:
issues.append(ValidationIssue(
checker='LinkRecommender',
issue_type='internal_link_opportunity',
severity='info',
line_number=line_idx,
matched_text=matched_kw,
message=f'"{matched_kw}" 언급 발견 — "{display_name}" 글로의 내부 링크를 고려하세요.',
suggestion=f'"{display_name}" 시술 페이지로 연결되는 앵커 텍스트 링크를 추가하세요.',
))
break # one recommendation per topic
return issues
# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------
VALID_CHECKS = {"structure", "compliance", "spelling", "links"}
_CHECKER_MAP = {
"structure": StructureValidator,
"compliance": ComplianceChecker,
"spelling": SpellingChecker,
"links": LinkRecommender,
}
class JournalValidator:
"""Orchestrates all validation checks and returns a consolidated report.
Args:
checks: List of check names to run. Defaults to all valid checks.
Valid values: 'structure', 'compliance', 'spelling', 'links'.
"""
def __init__(self, checks: Optional[List[str]] = None):
if checks is None:
self.checks = list(VALID_CHECKS)
else:
unknown = set(checks) - VALID_CHECKS
if unknown:
raise ValueError(
f"Unknown check(s): {', '.join(sorted(unknown))}. "
f"Valid options: {', '.join(sorted(VALID_CHECKS))}"
)
self.checks = checks
def validate(self, content: str) -> Dict:
"""Run all enabled checkers against content and return a report dict.
Returns:
{
"is_valid": bool, # False if any critical or high issue exists
"total_issues": int,
"issues_by_severity": {
"critical": int,
"high": int,
"medium": int,
"info": int,
},
"issues": [dict, ...], # serialised ValidationIssue dicts
"summary": str,
}
"""
all_issues: List[ValidationIssue] = []
for check_name in self.checks:
checker_cls = _CHECKER_MAP[check_name]
checker = checker_cls()
issues = checker.check(content)
all_issues.extend(issues)
counts: Dict[str, int] = {"critical": 0, "high": 0, "medium": 0, "info": 0}
for issue in all_issues:
severity = issue.severity if issue.severity in counts else "info"
counts[severity] += 1
is_valid = counts["critical"] == 0 and counts["high"] == 0
if not all_issues:
summary = "All checks passed."
else:
parts = []
if counts["critical"]:
parts.append(f"{counts['critical']} critical")
if counts["high"]:
parts.append(f"{counts['high']} high")
if counts["medium"]:
parts.append(f"{counts['medium']} medium")
if counts["info"]:
parts.append(f"{counts['info']} info")
summary = f"Found {len(all_issues)} issue(s): {', '.join(parts)}."
return {
"is_valid": is_valid,
"total_issues": len(all_issues),
"issues_by_severity": counts,
"issues": [asdict(i) for i in all_issues],
"summary": summary,
}
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="journal_validator",
description="Validate a Jamie Journal article against editorial rules.",
)
parser.add_argument(
"--input", "-i",
required=True,
metavar="FILE",
help="Path to the markdown article to validate.",
)
parser.add_argument(
"--check", "-c",
default="all",
metavar="CHECKS",
help=(
"Comma-separated list of checks to run "
"(structure, compliance, spelling, links). "
"Default: all"
),
)
parser.add_argument(
"--output", "-o",
metavar="FILE",
help="Optional path to write the JSON report.",
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Print detailed issue list.",
)
return parser
def _print_summary_table(report: Dict, verbose: bool = False) -> None:
sev = report["issues_by_severity"]
print("\n=== Journal Validator Report ===")
print(f" Status : {'PASS' if report['is_valid'] else 'FAIL'}")
print(f" Total : {report['total_issues']}")
print(f" Critical: {sev['critical']}")
print(f" High : {sev['high']}")
print(f" Medium : {sev['medium']}")
print(f" Info : {sev['info']}")
print(f"\n {report['summary']}")
if verbose and report["issues"]:
print("\n--- Issues ---")
for i, issue in enumerate(report["issues"], start=1):
print(
f" [{i}] [{issue['severity'].upper()}] "
f"Line {issue['line_number']} | {issue['checker']} | "
f"{issue['message']}"
)
if issue.get("suggestion"):
print(f" Suggestion: {issue['suggestion']}")
print()
def main() -> None:
parser = _build_parser()
args = parser.parse_args()
# --- Resolve checks ---
if args.check.strip().lower() == "all":
checks = list(VALID_CHECKS)
else:
checks = [c.strip() for c in args.check.split(",") if c.strip()]
# --- Read input file ---
try:
with open(args.input, "r", encoding="utf-8") as fh:
content = fh.read()
except FileNotFoundError:
print(f"Error: file not found: {args.input}", file=sys.stderr)
sys.exit(2)
except OSError as exc:
print(f"Error reading file: {exc}", file=sys.stderr)
sys.exit(2)
# --- Run validation ---
try:
validator = JournalValidator(checks=checks)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(2)
report = validator.validate(content)
# --- Output ---
_print_summary_table(report, verbose=args.verbose)
if args.output:
try:
with open(args.output, "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
print(f"Report written to: {args.output}")
except OSError as exc:
print(f"Error writing report: {exc}", file=sys.stderr)
sys.exit(2)
sys.exit(0 if report["is_valid"] else 1)
if __name__ == "__main__":
main()