pipeline: make tech-log-tree.json the one decomposition contract and enforce it
리뷰 두 건을 반영했다. 계약 - tech-log-tree.json 하나가 분해 계약이자 색인이다. 사람이 읽는 트리·Node Specification· 후보 대장은 없어졌고, 문서에 남아 있던 그 개념을 걷어냈다 - candidateScope — 후보를 찾는 SSOT 범위. 접어 넣은 제2부·제3부는 근거이지 후보가 아니다 - sourceRepository — 분석한 저장소의 경로·리비전·판단 근거. 리비전을 모르면 null 로 두고 지어내지 않는다. 갈래가 여럿이면 revisions - 검사기: 계약 미채택·PENDING·PROMOTE↔글감 양방향·candidateScope·sourceRepository 를 error/warn 으로 센다. 옛 스키마도 검사를 피하지 못한다. 테스트 22 → 31 기록 쓰기 - 템플릿 5종에 source·sourceRevision·topicName, Question 에 닫는 조건, 본문 없는 종류에서 assets 제거. 고정 절 개수 삭제 - check_evidence.mjs — 인용한 코드가 SSOT 에 있는지, 앵커가 SSOT 를 가리키는지, 제목이 계약과 같은지, 리비전이 저장소에 있는지. 게시된 기록에서 SSOT 와 다른 URL 을 잡았다 문체 - 문체 규칙의 정본을 ai-tells.md 로. explaining.md 의 질문체 제목·절 끝 대조 반복·그림 예고 규칙을 삭제해 충돌을 없앴다. 첫 절 「설명 뒤에 평가를 붙이지 않는다」에 지우는 사례 네 유형 - voice 스킬의 「독자 쪽을 본다」를 자료에 오독 기록이 있을 때로 좁히고, 평가만 더한 예시를 교체 - check_prose: 안내 문장을 요구하던 경고 제거, 문장이 끝나지 않은 채 문단이 끝나는 조각 검사 추가 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
73026cada6
commit
9d2a3725c5
+101
-72
@@ -1,113 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""tech-log-tree.json 을 다시 만든다.
|
||||
"""`tech-log-tree.json` 의 파생 칸을 다시 채운다.
|
||||
|
||||
노드 필드는 document-detail 의 root-tree 계약을 따른다 — readiness, source, code,
|
||||
evidence, classification, missing-verification, relations. 제목만 보고 기록을 만들지
|
||||
못하게 하려는 것이다.
|
||||
**이 파일이 정본이다.** 주제·글감·readiness·source·classification·relations 는 사람이
|
||||
적고, 이 스크립트는 손대지 않는다. 기록 파일을 읽어 채우는 것은 넷뿐이다.
|
||||
|
||||
SSOT(final/document.md)에서 뽑은 글감과 이미 쓴 기록을 한 파일에 모은다. 기록 파일이
|
||||
정본이므로 이 스크립트는 그것을 읽어 채우고, 아직 글이 없는 글감은 사람이 적은 항목을
|
||||
그대로 둔다.
|
||||
file 그 글감의 기록이 디스크에 있으면 상대 경로
|
||||
publication 게시됨 | 초안 | 미작성
|
||||
status 기록 frontmatter 의 status
|
||||
studioId · assets · evidenceFiles
|
||||
|
||||
**readiness 와 publication 은 다른 것이다.** 증거가 갖춰진 정도와 Studio 에 올렸는지를
|
||||
섞지 않는다. 계약에 없는 기록이 디스크에 있으면 `unlisted` 에 적는다 — 지우지도, 몰래
|
||||
주제로 만들지도 않는다.
|
||||
|
||||
python3 scripts/build-tech-log-tree.py [프로젝트 ...]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json, re, sys, glob, os, datetime, hashlib
|
||||
|
||||
KINDS = ["case", "concept", "reference", "question", "decision"]
|
||||
import datetime
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import techlog # noqa: E402
|
||||
from techlog import KINDS # noqa: E402
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DERIVED = ("file", "publication", "status", "studioId", "assets", "evidenceFiles")
|
||||
|
||||
|
||||
def front_matter(path: str) -> dict:
|
||||
def front_matter(path: str) -> tuple[dict, str]:
|
||||
text = open(path, encoding="utf-8").read()
|
||||
if not text.startswith("---"):
|
||||
return {}
|
||||
return {}, text
|
||||
end = text.find("\n---", 3)
|
||||
out = {}
|
||||
for line in text[3:end].splitlines():
|
||||
m = re.match(r"^([a-zA-Z_]+):\s*(.*)$", line)
|
||||
if m:
|
||||
out[m.group(1)] = m.group(2).strip().strip('"')
|
||||
return out
|
||||
return out, text
|
||||
|
||||
|
||||
def build(project: str) -> dict:
|
||||
def records_on_disk(studio: str) -> dict[tuple[str, str], str]:
|
||||
found = {}
|
||||
for path in sorted(glob.glob(os.path.join(studio, "*", "*", "*.md"))):
|
||||
parts = path.split(os.sep)
|
||||
topic_dir, kind_dir = parts[-3], parts[-2]
|
||||
if topic_dir.startswith("_") or kind_dir not in KINDS:
|
||||
continue
|
||||
fm, _ = front_matter(path)
|
||||
slug = fm.get("slug") or os.path.basename(path)[:-3]
|
||||
found[(kind_dir, slug)] = path
|
||||
return found
|
||||
|
||||
|
||||
def build(project: str) -> tuple[dict | None, list[str]]:
|
||||
base = os.path.join(ROOT, "docs", project)
|
||||
studio = os.path.join(base, "tech-log-studio")
|
||||
tree_path = os.path.join(studio, "tech-log-tree.json")
|
||||
previous = {}
|
||||
if os.path.exists(tree_path):
|
||||
previous = json.load(open(tree_path, encoding="utf-8"))
|
||||
index_path = os.path.join(studio, "tech-log-tree.json")
|
||||
index = techlog.load_index(index_path)
|
||||
if index is None:
|
||||
return None, [f"{project}: tech-log-tree.json 이 없다. 글감을 먼저 적는다"]
|
||||
|
||||
ssot = "final/document.md" if os.path.exists(os.path.join(base, "final/document.md")) else None
|
||||
topics = {}
|
||||
for topic_dir in sorted(d for d in glob.glob(os.path.join(studio, "*"))
|
||||
if os.path.isdir(d) and not os.path.basename(d).startswith("_")):
|
||||
topic = os.path.basename(topic_dir)
|
||||
entry = {"topic": topic, "kinds": {}}
|
||||
for kind in KINDS:
|
||||
items = []
|
||||
for f in sorted(glob.glob(os.path.join(topic_dir, kind, "*.md"))):
|
||||
fm = front_matter(f)
|
||||
text = open(f, encoding="utf-8").read()
|
||||
node = {
|
||||
"title": fm.get("title", os.path.basename(f)),
|
||||
"slug": fm.get("slug", ""),
|
||||
"file": os.path.relpath(f, studio),
|
||||
"readiness": "READY" if fm.get("id") else "NEEDS_EVIDENCE",
|
||||
"status": fm.get("status", "미작성"),
|
||||
"studioId": fm.get("id", ""),
|
||||
"assets": re.findall(r"^ - key: (\S+)", text, re.M),
|
||||
"evidence": re.findall(r"^ - (\.\./\S+)", text, re.M),
|
||||
"relations": re.findall(r"^- \*\*(.+?)\*\*$", text, re.M),
|
||||
}
|
||||
# 이미 쓴 글감은 지난 트리의 사람이 적은 칸을 잃지 않는다
|
||||
for old in previous.get("topics", {}).get(topic, {}).get("kinds", {}).get(kind, []):
|
||||
if old.get("slug") == node["slug"]:
|
||||
for key in ("classification", "missing-verification", "source", "code"):
|
||||
if old.get(key):
|
||||
node[key] = old[key]
|
||||
items.append(node)
|
||||
# 아직 글이 없는 글감은 지난 tree 에서 가져와 유지한다
|
||||
written = {i["slug"] for i in items if i["slug"]}
|
||||
for old in previous.get("topics", {}).get(topic, {}).get("kinds", {}).get(kind, []):
|
||||
if not old.get("file") and old.get("slug") not in written:
|
||||
items.append(old)
|
||||
entry["kinds"][kind] = items
|
||||
topics[topic] = entry
|
||||
disk = records_on_disk(studio)
|
||||
used: set[tuple[str, str]] = set()
|
||||
for _, kind, node in techlog.nodes(index):
|
||||
for key in DERIVED:
|
||||
node.pop(key, None)
|
||||
node["publication"] = "미작성"
|
||||
key = (kind, node.get("slug", ""))
|
||||
if not node.get("slug") or key not in disk:
|
||||
continue
|
||||
path = disk[key]
|
||||
fm, text = front_matter(path)
|
||||
studio_id = fm.get("id", "")
|
||||
node.update({
|
||||
"file": os.path.relpath(path, studio),
|
||||
"status": fm.get("status", ""),
|
||||
"studioId": studio_id,
|
||||
"publication": "게시됨" if studio_id else "초안",
|
||||
"assets": re.findall(r"^ - key: (\S+)", text, re.M),
|
||||
"evidenceFiles": re.findall(r"^ - (\.\./\S+)", text, re.M),
|
||||
})
|
||||
used.add(key)
|
||||
|
||||
ssot_path = os.path.join(base, ssot) if ssot else None
|
||||
digest = None
|
||||
if ssot_path and os.path.exists(ssot_path):
|
||||
digest = hashlib.sha256(open(ssot_path, "rb").read()).hexdigest()
|
||||
unlisted = [os.path.relpath(p, studio) for k, p in sorted(disk.items()) if k not in used]
|
||||
total = sum(1 for _ in techlog.nodes(index))
|
||||
written = sum(1 for _, _, n in techlog.nodes(index) if n.get("file"))
|
||||
ssot = index.get("ssot", "final/document.md")
|
||||
|
||||
return {
|
||||
"schemaVersion": 2,
|
||||
"project": project,
|
||||
"ssot": ssot,
|
||||
"ssotSha256": digest,
|
||||
"generatedAt": datetime.date.today().isoformat(),
|
||||
"note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다. "
|
||||
"ssotSha256 이 지금 final/document.md 와 다르면 SSOT 가 바뀐 뒤 트리를 다시 보지 않은 것이다.",
|
||||
"readinessValues": ["READY", "NEEDS_EVIDENCE", "BLOCKED", "REJECTED"],
|
||||
"topics": topics,
|
||||
index["ssotSha256"] = techlog.sha256_of(os.path.join(base, ssot))
|
||||
index["generatedAt"] = datetime.date.today().isoformat()
|
||||
index["counts"] = {
|
||||
"topics": len(index.get("topics", {})),
|
||||
"nodes": total,
|
||||
"written": written,
|
||||
"unwritten": total - written,
|
||||
"unlisted": len(unlisted),
|
||||
"candidates": len(index.get("candidates", [])),
|
||||
}
|
||||
index["unlisted"] = unlisted
|
||||
|
||||
warnings = []
|
||||
if unlisted:
|
||||
warnings.append(f"계약에 없는 기록 {len(unlisted)}건이 디스크에 있다 — "
|
||||
"글감으로 올리거나 지운다")
|
||||
return index, warnings
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
projects = argv[1:] or [
|
||||
os.path.basename(os.path.dirname(p))
|
||||
for p in glob.glob(os.path.join(ROOT, "docs/*/tech-log-studio"))
|
||||
if not os.path.basename(os.path.dirname(p)).startswith("_")
|
||||
]
|
||||
failed = 0
|
||||
for project in sorted(projects):
|
||||
tree = build(project)
|
||||
index, messages = build(project)
|
||||
if index is None:
|
||||
failed += 1
|
||||
for line in messages:
|
||||
print(line)
|
||||
continue
|
||||
out = os.path.join(ROOT, "docs", project, "tech-log-studio", "tech-log-tree.json")
|
||||
with open(out, "w", encoding="utf-8") as fh:
|
||||
json.dump(tree, fh, ensure_ascii=False, indent=2)
|
||||
json.dump(index, fh, ensure_ascii=False, indent=2)
|
||||
fh.write("\n")
|
||||
n = sum(len(v) for t in tree["topics"].values() for v in t["kinds"].values())
|
||||
print(f"{os.path.relpath(out, ROOT)} — 주제 {len(tree['topics'])} · 글감 {n}")
|
||||
return 0
|
||||
c = index["counts"]
|
||||
extra = f" · 계약 밖 기록 {c['unlisted']}" if c.get("unlisted") else ""
|
||||
print(f"{os.path.relpath(out, ROOT)} — 주제 {c['topics']} · 글감 {c['nodes']} "
|
||||
f"(쓴 것 {c['written']}){extra}")
|
||||
for line in messages:
|
||||
print(f" ! {line}")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user