From 54399a07ae7a68bc05a450aaea45c69400cdf4b1 Mon Sep 17 00:00:00 2001 From: tpals0409 <83855438+tpals0409@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:52:48 +0000 Subject: [PATCH] feat: add Team-PinLog organization profile --- .github/workflows/validate.yml | 23 ++++ .gitignore | 2 + README.md | 11 +- profile/README.md | 85 ++++++++++++ scripts/validate_profile.py | 241 +++++++++++++++++++++++++++++++++ tests/test_profile.py | 52 +++++++ 6 files changed, 412 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/validate.yml create mode 100644 .gitignore create mode 100644 profile/README.md create mode 100644 scripts/validate_profile.py create mode 100644 tests/test_profile.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..e611d5b --- /dev/null +++ b/.github/workflows/validate.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43ae0e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md index 6eb6a4e..f0fa0ef 100644 --- a/README.md +++ b/README.md @@ -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 테스트를 실행합니다. 승인되지 않은 비공개 자산이나 내부 운영 정보는 포함하지 않습니다. diff --git a/profile/README.md b/profile/README.md new file mode 100644 index 0000000..db17e00 --- /dev/null +++ b/profile/README.md @@ -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). diff --git a/scripts/validate_profile.py b/scripts/validate_profile.py new file mode 100644 index 0000000..a6d8b30 --- /dev/null +++ b/scripts/validate_profile.py @@ -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"(? 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"!\[[^\]]*\]\([^)]+\)|= 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()) diff --git a/tests/test_profile.py b/tests/test_profile.py new file mode 100644 index 0000000..b8a288d --- /dev/null +++ b/tests/test_profile.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import importlib.util +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +VALIDATOR_PATH = ROOT / "scripts" / "validate_profile.py" + +spec = importlib.util.spec_from_file_location("validate_profile", VALIDATOR_PATH) +assert spec is not None and spec.loader is not None +validator = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = validator +spec.loader.exec_module(validator) + + +class OrganizationProfileContractTests(unittest.TestCase): + def test_profile_and_maintenance_contract(self) -> None: + errors = validator.validate_repository(ROOT, check_links=False) + self.assertEqual([], errors, "\n".join(errors)) + + def test_documented_repository_and_docs_links_resolve(self) -> None: + profile_path = ROOT / "profile" / "README.md" + self.assertTrue(profile_path.is_file(), "profile/README.md가 아직 구현되지 않음") + links = validator.extract_links(profile_path.read_text(encoding="utf-8")) + failures = validator.broken_links(links) + self.assertEqual([], failures, "\n".join(failures)) + + def test_validator_rejects_direct_frontend_to_ai_call(self) -> None: + invalid = """# PinLog + +MVP 소개 + +```mermaid +flowchart TB + FE[Frontend] --> AI[FastAPI AI] +``` +""" + errors = validator.validate_profile(invalid) + self.assertTrue(any("직접 호출" in error for error in errors), errors) + + def test_github_link_mapper_supports_canonical_docs_path(self) -> None: + mapped = validator.github_api_url(validator.CANONICAL_DOCS) + self.assertEqual( + "https://api.github.com/repos/Team-PinLog/docs/contents/README.md?ref=main", + mapped, + ) + + +if __name__ == "__main__": + unittest.main()