Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Validate organization profile

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Compile Python
run: python3 -m compileall -q scripts tests
- name: Run stdlib tests
run: python3 -m unittest discover -s tests -p 'test_*.py' -v
- name: Run profile validator
run: python3 scripts/validate_profile.py --offline
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
*.py[cod]
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
# .github
PinLog Organization public profile
# Team-PinLog Organization Profile

이 저장소는 Team-PinLog의 GitHub 조직 공개 프로필을 관리합니다.

- 공개 프로필 본문: `profile/README.md`
- 검증기: `scripts/validate_profile.py`
- 계약 테스트: `tests/test_profile.py`

변경 전에는 공개 문서를 근거로 내용을 확인하고, 검증기와 stdlib 테스트를 실행합니다. 승인되지 않은 비공개 자산이나 내부 운영 정보는 포함하지 않습니다.
85 changes: 85 additions & 0 deletions profile/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# PinLog

장소를 저장한 맥락까지 기록해, 이름이 떠오르지 않아도 다시 찾고 새로운 장소를 발견하도록 돕는 서비스입니다.

PinLog은 공개 문서에 정의된 MVP 핵심 흐름과 설계를 구현해 나가고 있습니다.

## 서비스 가치

- 장소만이 아니라 저장한 이유와 경험을 맥락으로 남깁니다.
- 장소명을 기억하지 못해도 내 맥락을 자연어로 검색합니다.
- 신원과 맥락 원문을 드러내지 않는 익명 컬렉션에서 취향을 발견합니다.
- 발견한 장소에는 타인의 기록을 복사하지 않고 나만의 맥락을 더합니다.

## MVP 핵심 흐름

- 기록: 장소 검색 → 장소 선택 → 맥락 작성 → 레코드 저장 → 비동기 키워드·임베딩 생성
- 검색: 자연어 질의 → 내 맥락의 의미 검색 → 관련 레코드 확인
- 발견: 익명 컬렉션 탐색 → 장소 선택 → 내 맥락 작성 → 내 레코드로 저장

AI 처리는 저장 이후 비동기로 진행되며, MVP 설계에서는 AI 결과가 기본 기록 흐름의 완료 조건이 아닙니다.

## 핵심 개념

- Place: 지도에서 찾은 실제 장소의 공용 정보
- Record: 사용자와 장소를 연결하며 하나 이상의 Context를 가진 기록
- Context: 장소를 저장한 이유나 경험을 담는 불변 서술 단위
- Keyword: AI가 사전 정의 목록에서 매핑하는 비식별화 표현
- Collection: 하나 이상의 Record를 묶은 공개 그룹
- Shelf: 한 사용자가 발행한 Collection의 목록
- Library: 내 Shelf와 팔로우한 Shelf를 함께 보는 개인 공간
- Feed: 발행된 Collection을 익명으로 발견하는 영역

## 아키텍처

MVP 요청은 Frontend에서 Spring Backend로 전달됩니다. Client는 FastAPI AI를 직접 호출하지 않으며, Spring Backend가 Core 도메인과 최종 응답을 담당합니다.

```mermaid
flowchart TB
U[사용자] --> FE[Frontend]
FE --> BE[Spring Backend]
BE --> PG[(PostgreSQL + pgvector)]
BE --> R[(Redis)]
BE --> AI[FastAPI AI]
AI --> PG
AI --> EXT[외부 Embedding / LLM API]

G[Infra GitOps 저장소] --> CD[Argo CD]
CD --> K[k3s]
K -. 배포 구성 .-> FE
K -. 배포 구성 .-> BE
K -. 배포 구성 .-> AI
```

k3s 배포 구성은 Infra 저장소에 선언하고 Argo CD가 GitOps 방식으로 반영하도록 설계합니다.

### 텍스트 대체 설명

```text
사용자 → Frontend → Spring Backend
Spring Backend → PostgreSQL + pgvector
Spring Backend → Redis
Spring Backend → FastAPI AI → PostgreSQL + pgvector
FastAPI AI → 외부 Embedding / LLM API
Infra GitOps 저장소 → Argo CD → k3s 배포 구성
```

## 제품 저장소

- [front](https://github.com/Team-PinLog/front) — PinLog Frontend
- [back](https://github.com/Team-PinLog/back) — Spring Boot 기반 Core Backend
- [ai](https://github.com/Team-PinLog/ai) — FastAPI 기반 AI 처리와 자연어 검색
- [docs](https://github.com/Team-PinLog/docs) — 제품 기획, 정책, 용어와 파트 간 공식 계약
- [infra](https://github.com/Team-PinLog/infra) — k3s·Argo CD 기반 GitOps 배포 구성
- [mockup](https://github.com/Team-PinLog/mockup)

## 팀 도구와 지식

제품 구성요소와 별도로 협업과 지식 관리를 위한 도구를 관리합니다.

- [cowork](https://github.com/Team-PinLog/cowork) — 팀 작업 등록을 돕는 도구
- [pico-agent](https://github.com/Team-PinLog/pico-agent) — 출처 중심의 로컬 지식 시스템

## 공식 문서

제품의 범위, 정책, 용어와 설계 계약은 [PinLog 공식 문서에서 확인하세요](https://github.com/Team-PinLog/docs/blob/main/README.md).
241 changes: 241 additions & 0 deletions scripts/validate_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""Team-PinLog 공개 조직 프로필 계약을 검증한다 (Python stdlib only)."""

from __future__ import annotations

import argparse
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

REQUIRED_SECTIONS = (
"## 서비스 가치",
"## MVP 핵심 흐름",
"## 핵심 개념",
"## 아키텍처",
"## 제품 저장소",
"## 팀 도구와 지식",
"## 공식 문서",
)

PRODUCT_REPOS = ("front", "back", "ai", "docs", "infra", "mockup")
TEAM_TOOLS = ("cowork", "pico-agent")
CANONICAL_DOCS = "https://github.com/Team-PinLog/docs/blob/main/README.md"

FORBIDDEN_PATTERNS = (
(r"(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?![\w.])", "IP 주소"),
(r"(?i)\b(?:localhost|127\.0\.0\.1)\b", "로컬 호스트"),
(r"(?i)\b[\w.-]+\.ssafy\.io\b", "운영 호스트명"),
(r"(?i)\b(?:pinlog-(?:prod|dev)|argocd|kube-system)\b", "내부 namespace"),
(r"(?i)(?:api[_ -]?key|secret|token|password)\s*[:=]", "자격증명 형태"),
(r"(?i)production[- ]ready|프로덕션[- ]?준비|운영\s*중|정식\s*출시|출시\s*완료", "과도한 제품 상태 주장"),
(r"(?i)brand-resource|raw\.githubusercontent\.com|private[-_/ ]asset", "비공개/raw 자산"),
)

LINK_RE = re.compile(r"(?<!!)\[[^\]]+\]\((https?://[^)\s]+)\)")


def _read(path: Path, errors: list[str]) -> str:
try:
return path.read_text(encoding="utf-8")
except FileNotFoundError:
errors.append(f"필수 파일 없음: {path.as_posix()}")
except UnicodeDecodeError:
errors.append(f"UTF-8 아님: {path.as_posix()}")
return ""


def extract_links(markdown: str) -> list[str]:
return LINK_RE.findall(markdown)


def github_api_url(url: str) -> str | None:
parsed = urllib.parse.urlparse(url)
if parsed.scheme != "https" or parsed.netloc != "github.com":
return None
parts = [urllib.parse.unquote(part) for part in parsed.path.split("/") if part]
if len(parts) < 2:
return None
owner, repo = parts[:2]
api = f"https://api.github.com/repos/{urllib.parse.quote(owner)}/{urllib.parse.quote(repo)}"
if len(parts) == 2:
return api
if len(parts) >= 5 and parts[2] == "blob":
branch = parts[3]
file_path = "/".join(parts[4:])
return (
f"{api}/contents/{urllib.parse.quote(file_path, safe='/')}"
f"?ref={urllib.parse.quote(branch)}"
)
return None


def broken_links(links: list[str], timeout: float = 15.0) -> list[str]:
failures: list[str] = []
for link in sorted(set(links)):
api_url = github_api_url(link)
if api_url is None:
failures.append(f"허용되지 않거나 검증할 수 없는 링크: {link}")
continue
request = urllib.request.Request(
api_url,
headers={
"Accept": "application/vnd.github+json",
"User-Agent": "team-pinlog-org-profile-validator",
},
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
if response.status != 200:
failures.append(f"링크 응답 {response.status}: {link}")
except urllib.error.HTTPError as exc:
if exc.code not in (403, 429):
failures.append(f"링크 확인 실패: {link} (HTTP {exc.code})")
continue
# Anonymous GitHub API rate limit에 걸리면 같은 공개 URL을 HEAD로 확인한다.
fallback = urllib.request.Request(
link,
method="HEAD",
headers={"User-Agent": "team-pinlog-org-profile-validator"},
)
try:
with urllib.request.urlopen(fallback, timeout=timeout) as response:
if response.status != 200:
failures.append(f"링크 응답 {response.status}: {link}")
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as fallback_exc:
failures.append(
f"링크 확인 실패: {link} ({type(fallback_exc).__name__})"
)
except (urllib.error.URLError, TimeoutError) as exc:
failures.append(f"링크 확인 실패: {link} ({type(exc).__name__})")
return failures


def validate_profile(markdown: str) -> list[str]:
errors: list[str] = []
lines = markdown.splitlines()

if not lines or not re.fullmatch(r"#\s+PinLog", lines[0].strip()):
errors.append("profile/README.md의 첫 줄은 '# PinLog' H1이어야 함")
if len(lines) < 3 or not lines[2].strip():
errors.append("H1 다음에 제품 한 줄 가치가 있어야 함")

for section in REQUIRED_SECTIONS:
if section not in markdown:
errors.append(f"필수 섹션 없음: {section}")

if re.search(r"^\s*\|.*\|\s*$", markdown, re.MULTILINE):
errors.append("모바일 가독성을 위해 Markdown 표를 사용할 수 없음")

if "MVP" not in markdown:
errors.append("제품 상태를 MVP로 명시해야 함")

for pattern, label in FORBIDDEN_PATTERNS:
if re.search(pattern, markdown):
errors.append(f"금지 정보/표현 감지: {label}")

if re.search(r"!\[[^\]]*\]\([^)]+\)|<img\b", markdown, re.IGNORECASE):
errors.append("승인된 공개 이미지가 없으므로 이미지 참조를 둘 수 없음")

links = extract_links(markdown)
if re.search(r"(?<!https:)//github\.com", markdown):
errors.append("GitHub 링크는 HTTPS만 허용")
for link in links:
if not link.startswith("https://github.com/Team-PinLog/"):
errors.append(f"Team-PinLog HTTPS GitHub 링크만 허용: {link}")

for repo in PRODUCT_REPOS:
expected = f"https://github.com/Team-PinLog/{repo}"
if expected not in links:
errors.append(f"제품 저장소 링크 없음: {repo}")
for repo in TEAM_TOOLS:
expected = f"https://github.com/Team-PinLog/{repo}"
if expected not in links:
errors.append(f"팀 도구 링크 없음: {repo}")
if CANONICAL_DOCS not in links:
errors.append("공식 docs canonical README CTA가 없음")

product_start = markdown.find("## 제품 저장소")
tools_start = markdown.find("## 팀 도구와 지식")
docs_start = markdown.find("## 공식 문서")
if product_start >= 0 and tools_start >= 0:
product_block = markdown[product_start:tools_start]
for tool in TEAM_TOOLS:
if f"Team-PinLog/{tool}" in product_block:
errors.append(f"팀 도구가 제품 저장소에 섞임: {tool}")
if tools_start >= 0 and docs_start >= 0:
tools_block = markdown[tools_start:docs_start]
for tool in TEAM_TOOLS:
if f"Team-PinLog/{tool}" not in tools_block:
errors.append(f"팀 도구 섹션 링크 없음: {tool}")

if "```mermaid" not in markdown or "flowchart TB" not in markdown:
errors.append("Mermaid TB 아키텍처가 없음")
architecture_tokens = (
"U[사용자] --> FE[Frontend]",
"FE --> BE[Spring Backend]",
"BE --> PG[(PostgreSQL + pgvector)]",
"BE --> R[(Redis)]",
"BE --> AI[FastAPI AI]",
"AI --> PG",
"AI --> EXT[외부 Embedding / LLM API]",
"k3s",
"Argo CD",
"GitOps",
)
for token in architecture_tokens:
if token not in markdown:
errors.append(f"아키텍처 계약 없음: {token}")
if re.search(r"FE(?:\[[^\]]*\])?\s*--?>\s*AI", markdown):
errors.append("Client/Frontend가 AI를 직접 호출할 수 없음")

mermaid_end = markdown.find("```", markdown.find("```mermaid") + len("```mermaid"))
fallback_start = markdown.find("### 텍스트 대체 설명")
if mermaid_end < 0 or fallback_start <= mermaid_end:
errors.append("Mermaid 뒤에 텍스트 대체 설명이 없음")
elif "```text" not in markdown[fallback_start:]:
errors.append("아키텍처 텍스트 fallback 코드 블록이 없음")

return errors


def validate_repository(root: Path, check_links: bool = True) -> list[str]:
errors: list[str] = []
profile_path = root / "profile" / "README.md"
maintenance_path = root / "README.md"
profile = _read(profile_path, errors)
maintenance = _read(maintenance_path, errors)

if profile:
errors.extend(validate_profile(profile))
if maintenance:
if "조직 공개 프로필" not in maintenance or "profile/README.md" not in maintenance:
errors.append("README.md에 조직 공개 프로필 maintenance 설명이 없음")
for pattern, label in FORBIDDEN_PATTERNS:
if re.search(pattern, maintenance):
errors.append(f"README.md 금지 정보/표현 감지: {label}")
if check_links and profile:
errors.extend(broken_links(extract_links(profile)))
return errors


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
parser.add_argument("--offline", action="store_true", help="네트워크 링크 확인 생략")
args = parser.parse_args(argv)

errors = validate_repository(args.root.resolve(), check_links=not args.offline)
if errors:
for error in errors:
print(f"ERROR: {error}")
return 1
print("Profile validation passed.")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading