"""사람 교정 보존 병합 (계획서 v4 §5.7). 대상은 git 에 커밋되는 program/package/concept 문서. units/ 는 재생성 산출물이라 merge 없이 항상 덮어쓴다(호출부에서 결정). 사람 교정 여부는 OKF v0.2 표준 필드로 판별한다 (docs/okf-version.md): verified: { by: "human:", at: } ← 사람이 검토·수정 후 저장할 때 추가 (구 계획서의 x-human-edited: true 규약도 하위호환으로 인식한다.) 규칙: - verified(human) 없는 문서 → 새 내용으로 덮어쓴다. - verified(human) 있는 문서 → 본문을 보존하고 관리 대상 사실 필드만 갱신한다. 코드 해시가 바뀐 경우에는 새 요약을 "## (자동 생성) 변경 후 요약" 섹션으로 덧붙이고 status: draft 로 내린 뒤 wiki/_review.md 에 재검토 항목을 올린다. """ from __future__ import annotations import re from datetime import date from pathlib import Path FM_RE = re.compile(r"\A---\r?\n(.*?)\r?\n---\r?\n?", re.S) AUTO_SECTION = "## (자동 생성) 변경 후 요약" # 배치가 항상 갱신하는 사실 필드 (모두 한 줄로 emit 됨 — okf_writer 참조) MANAGED_KEYS = ( "resource", "tags", "generated", "x-lines", "x-tables-read", "x-tables-write", "x-calls", "x-outputs", "x-code-hash", "x-prompt-version", "x-unit-count", "x-chunk-count", ) def split_frontmatter(text: str) -> tuple[str | None, str]: """(frontmatter 내용, 본문) — frontmatter 가 없으면 (None, 전체).""" m = FM_RE.match(text) if not m: return None, text return m.group(1), text[m.end():] def is_human_verified(frontmatter: str | None) -> bool: if not frontmatter: return False if re.search(r"""by:\s*['"]?human:""", frontmatter): return True return bool(re.search(r"^x-human-edited:\s*true\s*$", frontmatter, re.M)) # 하위호환 def get_scalar(frontmatter: str, key: str) -> str | None: m = re.search(rf"""^{re.escape(key)}:\s*['"]?(.*?)['"]?\s*$""", frontmatter, re.M) return m.group(1) if m else None def _replace_or_append_line(fm: str, key: str, line: str) -> str: pattern = re.compile(rf"^{re.escape(key)}:.*$", re.M) if pattern.search(fm): return pattern.sub(line.replace("\\", "\\\\"), fm, count=1) return fm.rstrip("\n") + "\n" + line def _strip_auto_section(body: str) -> str: idx = body.find(AUTO_SECTION) return body if idx < 0 else body[:idx].rstrip("\n") + "\n" def add_review_entry(wiki_dir: Path, doc_rel: str, reason: str) -> None: review = wiki_dir / "_review.md" lines = review.read_text(encoding="utf-8").splitlines() if review.exists() else [ "# 재검토 필요 문서", "", "코드가 바뀌어 사람 교정본과 어긋날 수 있는 문서 목록 (배치가 자동 추가).", "", ] entry = f"- [ ] {doc_rel} — {reason} ({date.today().isoformat()})" if not any(doc_rel in ln for ln in lines): lines.append(entry) review.write_text("\n".join(lines) + "\n", encoding="utf-8") def merge_write(path: Path, new_text: str, *, code_hash: str | None, wiki_dir: Path, doc_rel: str) -> str: """새로 생성한 문서(new_text)를 사람 교정 보존 규칙에 따라 path 에 쓴다. 반환: created | overwritten | facts_updated | draft_appended """ path.parent.mkdir(parents=True, exist_ok=True) if not path.exists(): path.write_text(new_text, encoding="utf-8") return "created" old = path.read_text(encoding="utf-8") fm_old, body_old = split_frontmatter(old) if not is_human_verified(fm_old): path.write_text(new_text, encoding="utf-8") return "overwritten" # --- 사람 검토 문서: 본문 보존, 사실 필드만 갱신 --- fm_new, body_new = split_frontmatter(new_text) assert fm_new is not None fm = fm_old or "" for key in MANAGED_KEYS: m = re.search(rf"^{re.escape(key)}:.*$", fm_new, re.M) if m: fm = _replace_or_append_line(fm, key, m.group(0)) old_hash = get_scalar(fm_old or "", "x-code-hash") changed = bool(code_hash and old_hash and old_hash != code_hash) if changed: fm = _replace_or_append_line(fm, "status", "status: draft") body = (_strip_auto_section(body_old).rstrip("\n") + f"\n\n{AUTO_SECTION}\n\n" + "> 코드가 변경되어 아래 요약을 새로 생성했습니다. 위 본문(사람 검토됨)과 대조해 반영해 주세요.\n\n" + body_new.lstrip("\n")) result = "draft_appended" add_review_entry(wiki_dir, doc_rel, "코드 변경 — 사람 교정본과 대조 필요") else: body = body_old result = "facts_updated" path.write_text(f"---\n{fm.strip()}\n---\n{body}", encoding="utf-8") return result