#1 ์ด์ ๊ธ
[์ฝํผ๋ฐ์ค] 2026 Build with AI: Hands-on Campus - ์ฐธ์ ํ๊ธฐ
#1 ๊ฐ์#1-1 ์ฃผ์ AI๋ก ์ด๋๊น์ง ๋ง๋ค ์ ์์๊น? Gemini, Vertex AI ๋ฑ Google์ ์ต์ AI ๊ธฐ์ ์ ์ง์ ํ์ฉํด๋ณด๋ฉฐ ์ค์ ๊ฒฝํ์ ์๋ 200๋ช ๊ท๋ชจ ํธ์ฆ์จ ์ด๋ฒคํธGDG(Google Developer Groups)์์ ์ฃผ์ตํ๋ ์ด๋ฒคํธ๋ค.
kenel.tistory.com
์์ ๊ฒ์๊ธ์์ ์ด์ด์ง๋ค.
#2 ๊ฐ์

์ฐ์ฌ (์ฝ๋ ฅ)
ํ์ฅ: ์กฐ์์ธ (25-26 GDG on Campus Sookmyung Organizer)
ํ์: ์ก์ ๋ฏผ, ์ดํ๋, ํ์์
ํคํฌ์ธํธ
ํ๋ก๊ทธ๋จ์ ์บ์นํ๋ ์ด์ฆ: "์ฝ๋๋ฅผ ์ ์ฐ๋ ๊ฐ๋ฐ์๋ณด๋ค, ์ฝ๋๋ฅผ ๋ ์ ์ค๋ช ํ๋ ๊ฐ๋ฐ์๋ก."
AI Agent์ ๊ฐ์ฅ ํฐ ์ญํ ์ ๋ฌด์๋ฏธํ ๋ฐ๋ณต ์์ ์ ๋๋ฆฌ ์ํ์ด๋ค. GitHub ๋ฑ์ ์ฌ๋ฆฌ๋ ๋ ํฌ์งํ ๋ฆฌ์ README.md ํ์ผ์ ๋งค๋ฒ ์ ๋ ๊ฒ๊ณผ ๊ฐ์ ์ผ ๋ง์ด๋ค. ๋น์ทํ๊ฒ, Pull requests ๋ํ ๋งค๋ฒ ํ๊ธฐ ๋ฒ๊ฑฐ๋กญ๋ค. ์ด 2๊ฐ์ง๋ฅผ ์ ๋ถ ์๋ํํ๋ ๊ฒ์ด ์ฌ๊ธฐ์ ๋ค๋ฃฐ Agent๋ค.
#3 ์ค์ต ๋ก๋๋งต
#3-1 ์ค์ต PPT
ํ์ฅ์์ QR ์ฝ๋๋ฅผ ํตํด PPT๋ฅผ ๋๋ ์ฃผ์ จ๋๋ฐ, ์ด PPT๊ฐ ์ ์ฅ๋ ๊ณณ์ด ๋ง์ฒํ(?)์ ๋๋ฌ๋ ์ฃผ์๋ผ๊ธฐ๋ณด๋จ ๊ฐ์ธ ์ ์ฅ์์ ๊ฐ๊น์ ๋ณด์๋ค. ๊ทธ๋์ (ํน์ ๋ชจ๋ฅด๋) PPT๋ ์ฒจ๋ถํ์ง ์๊ฒ ๋ค. ๋์ , #4 ~ #5๋ง ๋ด๋ ๋ฐ๋ผ ํ๋ ๋ฐ์๋ ๋ฌธ์ ๊ฐ ์๋ค.
#3-2 ์ค์ต์ฉ ๋ ํฌ์งํ ๋ฆฌ
GitHub - yeverycode/Agent: [25-26 GDGoC Sookmyung Build with AI] GitHub ๋ถ์ ์์ด์ ํธ
[25-26 GDGoC Sookmyung Build with AI] GitHub ๋ถ์ ์์ด์ ํธ - yeverycode/Agent
github.com
์์ ์ ์ฅ์๋ฅผ forkํ ์ ์ฅ์๋ฅผ ๋ง๋ค๊ณ ์งํํ๋ค.
#4 ๋ฐฐ๊ฒฝ ์ง์ & ์ฌ์ ์์
#4-1 GitHub Token & ๋ฐ๊ธ
์ด๋ค GitHub ๋ ํฌ์งํ ๋ฆฌ์ ์ ๊ทผํ ์ ์๋ ํ๊ฐ์ฆ. ๋ฌธ์์ด์ ํํ๋ค. ์์ด๋๊ฐ ์๋ ๋น๋ฐ๋ฒํธ๋ผ๊ณ ๋ด๋ ์ข๋ค. AI Agent๊ฐ Pull requests๋ฅผ ํ ๋ ์ง๋ ์ผ ํ๊ธฐ์ ์ฑ๊ฒจ์ฃผ์ด์ผ ํ๋ค.
๋ฐ๊ธ ๋ฐฉ๋ฒ์ ๊ตฌ๊ธ ๊ฒ์์ ํตํด ์์. ์ด๋ Token์ ์ด๋ฆ์ MY_GITHUB_TOKEN์ผ๋ก ์ง๋๋ค. ํ ํฐ์ ๋ด์ฉ(๊ฐ)์ ๋ณต์ฌํด์ (๋จ๋ค์ ๋ณผ ์ ์๋) ๋ฉ๋ชจ์ฅ ๋ฑ์ ๋ถ์ฌ ๋์. ๊นํ๋ธ ํ ํฐ์ ํ์ฌ ์์ ๊ธฐ์ค "ghp_a1b2c3d4e5f6g7..."์ ๋น์ทํ๊ฒ ์๊ฒผ๋ค.
#4-2 GitHub Secret & ์ถ๊ฐ
API ํค, ํ ํฐ, ๋น๋ฐ๋ฒํธ ๊ฐ์ ๋ฏผ๊ฐํ ๊ฐ์ GitHub์ ์์ ํ๊ฒ ์ ์ฅํด์ฃผ๋ ๊ธฐ๋ฅ. ์ ์ฅํด๋๋ฉด, GitHub Actions ๋ฐ GitHub Codespace์์ ์ฐธ์กฐํ์ฌ ๋๊ณ ๋๊ณ ์ฌ์ฉํ ์ ์๋ค.
[์ค์ต์ฉ ๋ ํฌ์งํ ๋ฆฌ๋ฅผ forkํ ์ ์ฅ์] - [Setting] - [Secrets and variables] - [New repository secret]์ Secret์ ์ ๋ฐ์ดํธํ๋ค. ์๋์ 2๊ฐ์ง๋ฅผ ์ถ๊ฐํด ์ฃผ๋ฉด ๋๋ค. Name์ ์์๋ก ์ ์ง ๋ง๊ณ , ์๋์ ๋๊ฐ์ด ์ ์ด์ผ ํ๋ค. (์ฐธ์กฐ: GEMINI_API_KEY ๋ฐ๊ธ)
- Name: GEMINI_API_KEY
- Secret: AbCdEfG...
- Name: MY_GITHUB_TOKEN
- Secret: ghp_a1b2c3d4e5f6g7...
#4-3 GitHub Codespaces & ์ ์ฅ
๋ธ๋ผ์ฐ์ ์์ ๋ฐ๋ก ์ด ์ ์๋ IDE (์ผ๋ฐ์ ์ผ๋ก VS Code). Codespaces๋ ์ ์ฒด ํ๋ก์ ํธ ๊ตฌ์กฐ์ ์ต์๋จ์ผ๋ก๋ถํฐ ".devcontainer/devcontainer.json"๋ฅผ ์ฝ๊ณ ์ด๋ค ํ๊ฒฝ(์ด์์ฒด์ , ๋ผ์ด๋ธ๋ฌ๋ฆฌ, ํ๊ฒฝ๋ณ์, ...)์์ ์คํ์ํฌ์ง๋ฅผ ์ฝ๊ณ ์ฒ์ฒ ์คํํด ์ค๋ค.
ํ๋ก๊ทธ๋จ ์คํ ํ๊ฒฝ์ (devcontainer.json ํ์ผ์ ์ ๋ ฅํด ์ฃผ๋ ๊ฒ๋ง์ผ๋ก) ์์ฝ๊ฒ ์ ๋ฐ์ดํธํ ์ ์๋ค. Codespaces์ฉ VS Code์ Live Share ์ต์คํ ์ ์ ์ค์นํ๋ฉด, ๊ฐ์ ํ๊ฒฝ์์ + ์ค์๊ฐ์ผ๋ก + ๊ณต๋ ํธ์ง๊น์ง ๊ฐ๋ฅํ๋ค.
[์ค์ต์ฉ ๋ ํฌ์งํ ๋ฆฌ๋ฅผ forkํ ์ ์ฅ์] - [Code] ํญ - [<> Code] ๋ฒํผ - [Codespace] - [Create codespace]์ผ๋ก Codespace์ ์ ์ฅํ ์ ์๋ค. ๋ฏธ๋ฆฌ ์ ์ฅํด ๋์. ๊ทธ๋์ผ Codespaces๊ฐ devcontainer.json์ ๋ด์ฉ์ ๋ง์ถฐ ์คํ ํ๊ฒฝ์ ์ค๋นํ๋ ์๊ฐ์ ๋ฒ์ด ๋น ๋ฅด๊ฒ ์งํํ ์ ์๋ค. ์ฐ๋ฆฌ๋ ํ๊ตญ์ธ์ด๋๊น...
#4-4 PyGitHub
GitHub REST API๋ฅผ ํธํ๊ฒ ์ฐ๊ฒ ํด์ฃผ๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ. GitHub์์ ์ํธ์์ฉ์ Python ๊ฐ์ฒด ๋ค๋ฃจ๋ฏ์ด ํ ์ ์๋ค.
PyGitHub๊ฐ ์ ๊ณตํ๋ ๋ํ์ ์ธ ๋ฉ์๋๋ ์๋์ ๊ฐ๋ค. ๋ด๊ฐ ์ง์ ๋ฉ์๋๋ฅผ ์ฐพ์๋ณด๋ฉฐ ์ ๋ฆฌํ ๊ฒ์ด ์๋๋ผ, ์ฐ์ฌ๋ถ์ด ์ผ๋ชฉ์์ฐํ๊ฒ ์ ๋ฆฌํด ๋์ ๊ฑธ ๊ทธ๋๋ก ๋ณต์ฌํด ๊ฐ์ ธ์จ ๊ฒ์ด๋ค.
| ๊ธฐ๋ฅ (Action) | PyGithub ๋ฉ์๋ | REST API ์๋ํฌ์ธํธ (HTTP ํธ์ถ) |
| ํน์ ๋ ํฌ์งํ ๋ฆฌ ์ถ์ถ | g.get_repo() | GET /repos/{owner}/{repo} |
| ๋ธ๋์น ์ ๋ณด ์ถ์ถ | repo.get_branch() | GET /repos/{owner}/{repo}/branches/{branch} |
| ์ ์ฒด ํ์ผ ํธ๋ฆฌ ์ถ์ถ | repo.get_git_tree() | GET /repos/{owner}/{repo}/git/trees/{sha}?recursive=1 |
| ํ์ผ ๋ด์ฉ ์ฝ๊ธฐ | repo.get_contents() | GET /repos/{owner}/{repo}/contents/{path} |
| ์ ๋ธ๋์น ์์ฑ | repo.create_git_ref() * | POST /repos/{owner}/{repo}/git/refs |
| ์ ๋ธ๋์น ์์ฑ | repo.create_file() | PUT /repos/{owner}/{repo}/contents/{path} |
| ํ์ผ ์ ๋ฐ์ดํธ | repo.update_file() | PUT /repos/{owner}/{repo}/contents/{path} |
| PR(Pull Request) ์์ฑ | repo.create_pull() | POST /repos/{owner}/{repo}/pulls |
#5 ์ค์ต & ๋ถ์
ํฌ๊ฒ ๋ณด์ ์ด 5๋จ๊ณ๋ฅผ ๊ฑฐ์น๋ฉฐ ํ๋ก๊ทธ๋จ์ด ์คํ๋๋ค. main.py์ ๋ณด๋ฉด ํ๋ก๊ทธ๋จ์ ํ๋ฆ์ ํ๋์ ์ ์ ์๋ค. ๊ฐ ๋จ๊ณ๋ฅผ ๊ดํตํ๋ ํต์ฌ ๊ฐ์ฒด๋ "project_data"๋ผ๋ ์ด๋ฆ์ Python dict ํ ๊ฐ์ฒด๋ค. project_data์๋ ์ต์ข ์ ์ผ๋ก 7๊ฐ์ ํ๋๊ฐ ์ฝ์ ๋๋ค. ๋จ๊ณ๋ณ๋ก ์ด๋ค ํ๋๊ฐ ์ฝ์ ๋๊ณ ์ด๋ค ํ๋ ๊ฐ์ด ์ด์ฉ๋๋์ง๋ฅผ ๋ฐ๋ผ๊ฐ๋ ์์ผ๋ก ๋ณด๋, ํ๋ก๊ทธ๋จ ํ๋ฆ์ ์์ํ๊ฒ ์ฝ์ ์ ์์๋ค.
#5-1 [00] ์์
์ฌ์ฉ์๊ฐ main.py์ ์์ "TARGET_REPO"์ "MODEL"์ ๊ฐ์ ์ ๋ ฅ(ํ๋ ์ฝ๋ฉ)ํ ํ, ํฐ๋ฏธ๋์ "python main.py"์ ์ ์กํ์ฌ ํ๋ก๊ทธ๋จ ์์
TARGET_REPO์๋ ๋ณธ์ธ์ ํ ์ด ํ๋ก์ ํธ๋ฅผ ๋ฃ๋ ๊ฒ ์ข์ ๊ฒ์ด๋ค. MODEL ๊ฐ์ผ๋ก๋ "gemini-3.1-flash-lite"๋ฅผ ์ฌ์ฉํ๋ค. "gemini-flash-latest"๋ฅผ ์ฌ์ฉํ๋ฉด ์ต์ flash ๋ชจ๋ธ์ ์์์ ์ฌ์ฉํ๋ค. flash ๋ชจ๋ธ ๋ง๊ณ ์ข ๋ ๋ฌด๊ฑฐ์ด ๋ชจ๋ธ์ ์ฌ์ฉํ๋ฉด, ๋ต๋ณ ์๋๋ ๋๋ ค์ง์ง๋ง ๋ ์ข์ ํ๋ฆฌํฐ๋ก ์๋ตํ ๊ฒ์ด๋ค.
#5-2 [01] ์ ๋ณด ์์ง
# in main.py
...
def main():
...
repo_mgr = RepoManagerAgent(token=github_token, api_key=gemini_api_key, model=MODEL)
...
project_data = repo_mgr.extract_project_data(TARGET_REPO)
...
...
repo_manager.py ๋ชจ๋์ ํธ์ถํด project_data๋ฅผ ๊ฒฐ๊ด๊ฐ์ผ๋ก ๋ฐ๋๋ค. project_data์ ์์๋ ์๋์ ๊ฐ๋ค.
{
"status": "success",
"repo_name": "Kanmanemone/notion-auto-clone",
"repo_url": "https://github.com/Kanmanemone/notion-auto-clone",
"tree_paths": [
".github/workflows/run.yml",
".gitignore",
"README.md",
"custom_filter.py",
"custom_patch.py",
"custom_property.py",
"main.py",
"notion/__init__.py",
"notion/api.py",
"notion/filter.py",
"notion/filter_compiler.py",
"notion/filter_logic.py",
"notion/property.py",
"requirements.txt"
],
"selected_files": {
"main.py": "import os\n\nfrom custom_filter import pages_to_clone_filter, already_cloned_filter\nfrom custom_patch import source_page_patch, target_page_patch\nfrom custom_property import target_page_properties\nfrom notion.api import NotionApi\n\nnotion_api = NotionApi(os.environ[\"NOTION_TOKEN\"])\nsource_db_id = os.environ[\"SOURCE_DB_ID\"]\ntarget_db_id = os.environ[\"TARGET_DB_ID\"]\n\n# ์ฌ์ฉ์ ์ ์ ์กฐ๊ฑด์ ๊ฐ์ ธ์ source DB์์ ๋ณต์ ํ ํ์ด์ง๋ฅผ ์กฐํ\nsource_pages = notion_api.read(\n db_id=source_db_id,\n filter=pages_to_clone_filter(),\n sorts=[\n {\n \"property\": \"time_order\",\n \"direction\": \"descending\",\n },\n ],\n)\n\nfor source_page in source_pages[\"results\"]:\n # source DB์์ ์กฐํํ ํ์ด์ง๋ฅผ target DB์ ์ ํ์ด์ง๋ก ์์ฑ\n already_cloned_page = notion_api.read(\n db_id=target_db_id,\n filter=already_cloned_filter(source_page=source_page),\n page_size=1,\n )\n if already_cloned_page[\"results\"]:\n page = already_cloned_page[\"results\"][0]\n title = page[\"properties\"][\"name\"][\"title\"]\n date = page[\"properties\"][\"date\"][\"formula\"][\"date\"][\"start\"]\n print(f\"(์ด๋ฏธ ์กด์ฌ) {title[0]['plain_text'] if title else '(no title)'} | {date}\")\n continue\n\n target_page = notion_api.create(\n db_id=target_db_id,\n properties=target_page_properties(source_page)\n )\n\n # ํ์ฒ๋ฆฌ (post process)\n notion_api.update(page_id=source_page[\"id\"], properties=source_page_patch(source_page, target_page))\n notion_api.update(page_id=target_page[\"id\"], properties=target_page_patch(source_page, target_page))\n\n # ์ฝ์์ฐฝ ๋ก๊ทธ\n title = target_page[\"properties\"][\"name\"][\"title\"]\n print(title[0][\"plain_text\"] if title else \"(no title)\")\n",
"notion/api.py": "import json\nimport time\nimport urllib.error\nimport urllib.request as r\nfrom typing import Any\n\nfrom notion.filter_compiler import to_notion_filter\n\n\nclass NotionApi:\n _BASE_URL = \"https://api.notion.com/v1\"\n\n def __init__(self, token: str):\n # ๋ชจ๋ ์์ฒญ์ ๊ณตํต์ผ๋ก ์ฌ์ฉํ ํค๋\n self._headers = {\n \"Authorization\": \"Bearer \" + token,\n \"Notion-Version\": \"2022-06-28\",\n \"Content-Type\": \"application/json\",\n }\n\n # Notion REST API์ HTTP ์์ฒญ์ ๋ณด๋ด๊ณ ์๋ต์ผ๋ก JSON์ ๋ฐ๋ ๋ฒ์ฉ ํจ์\n def _api(self, method: str, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]:\n req = r.Request(\n self._BASE_URL + path,\n json.dumps(body).encode() if body else None,\n self._headers,\n method=method,\n )\n\n time.sleep(0.4) # Notion API ์ด๋น 3๊ฑด ์ ํ → 0.4์ด ๊ฐ๊ฒฉ ์ ์ง\n\n wait = 1\n for attempt in range(1, 6): # ์ต๋ 5ํ ์๋\n try:\n with r.urlopen(req) as res:\n return json.loads(res.read())\n\n except urllib.error.HTTPError as e:\n is_rate_limit = (e.code == 429)\n is_last_attempt = (attempt == 5)\n\n if not is_rate_limit or is_last_attempt:\n raise # 429 ์ธ ์๋ฌ๊ฑฐ๋ 5ํ ๋ชจ๋ ์คํจ ์ ๊ทธ๋ฅ ๋์ง\n\n # Retry-After ํค๋๊ฐ ์์ผ๋ฉด ๊ทธ ๊ฐ, ์์ผ๋ฉด ์ง์ ๋ฐฑ์คํ(1→2→4...์ต๋ 60์ด)\n wait = int(e.headers.get(\"Retry-After\", wait))\n print(f\"Rate limited. {attempt}ํ ์๋ ์คํจ, {wait}์ด ํ ์ฌ์๋...\")\n time.sleep(wait)\n wait = min(wait * 2, 60)\n\n # noinspection PyShadowingBuiltins\n def read(\n self,\n db_id: str,\n filter: dict[str, Any] | None = None,\n page_size: int | None = None,\n sorts: list[dict[str, str]] | None = None,\n ) -> dict[str, Any]:\n body: dict[str, Any] = {}\n compiled = to_notion_filter(filter)\n if compiled:\n body[\"filter\"] = compiled\n if page_size is not None:\n body[\"page_size\"] = page_size\n if sorts:\n body[\"sorts\"] = sorts\n return self._api(\"POST\", f\"/databases/{db_id}/query\", body or None)\n\n def create(self, db_id: str, properties: dict[str, Any]) -> dict[str, Any]:\n return self._api(\"POST\", \"/pages\", {\"parent\": {\"database_id\": db_id}, \"properties\": properties})\n\n def update(self, page_id: str, properties: dict[str, Any]) -> dict[str, Any]:\n return self._api(\"PATCH\", f\"/pages/{page_id}\", {\"properties\": properties})\n",
"notion/filter_compiler.py": "import json\nfrom typing import Any\n\nfrom sympy import And, Or, Symbol\nfrom sympy.logic.boolalg import BooleanFalse, BooleanTrue, to_dnf\n\nfrom .filter_logic import and_, or_\n\n\n# custom_filter.py์์๋ ์ฌ๋์ด ์ฝ๊ธฐ ์ฌ์ด ์ค์ฒฉ and/or ์กฐ๊ฑด์ ์์ฑํ๋ค.\n# Notion API๋ compound filter ์์ ๋ค์ compound filter๊ฐ ๊น๊ฒ ๋ค์ด๊ฐ๋ฉด\n# validation_error๋ฅผ ๋ฐํํ ์ ์์ผ๋ฏ๋ก, API ์์ฒญ ์ง์ ์ DNF ํํ๋ก ํผ์น๋ค.\n# ์๋ฅผ ๋ค์ด, A AND (B OR C)๋ฅผ Notion์ด ๋ฐ๊ธฐ ์ฌ์ด ํํ์ธ\n# (A AND B) OR (A AND C)๋ก ๋ณํํ๋ค.\n\n\ndef to_notion_filter(filter_body: dict[str, Any] | None) -> dict[str, Any] | None:\n if not filter_body:\n return None\n\n symbol_to_filter: dict[Symbol, dict[str, Any]] = {}\n filter_to_symbol: dict[str, Symbol] = {}\n\n def to_expr(item: dict[str, Any]):\n if \"and\" in item:\n return And(*(to_expr(child) for child in item[\"and\"]))\n if \"or\" in item:\n return Or(*(to_expr(child) for child in item[\"or\"]))\n\n key = json.dumps(item, ensure_ascii=False, sort_keys=True)\n if key not in filter_to_symbol:\n symbol = Symbol(f\"f{len(filter_to_symbol)}\")\n filter_to_symbol[key] = symbol\n symbol_to_filter[symbol] = item\n return filter_to_symbol[key]\n\n def from_expr(expr):\n if isinstance(expr, BooleanTrue):\n return None\n if isinstance(expr, BooleanFalse):\n return None\n if isinstance(expr, Symbol):\n return symbol_to_filter[expr]\n if isinstance(expr, Or):\n return or_(*(from_expr(arg) for arg in expr.args))\n if isinstance(expr, And):\n return and_(*(from_expr(arg) for arg in expr.args))\n raise TypeError(f\"Unsupported filter expression: {expr!r}\")\n\n return from_expr(to_dnf(to_expr(filter_body), simplify=False))\n",
"notion/filter_logic.py": "from typing import Any\n\ndef and_(*items: dict[str, Any] | None) -> dict[str, Any] | None:\n items = [x for x in items if x]\n\n if len(items) == 0:\n return None\n\n if len(items) == 1:\n return items[0]\n\n return {\"and\": items}\n\n\ndef or_(*items: dict[str, Any] | None) -> dict[str, Any] | None:\n items = [x for x in items if x]\n\n if len(items) == 0:\n return None\n\n if len(items) == 1:\n return items[0]\n\n return {\"or\": items}\n",
"notion/property.py": "from copy import deepcopy\n\n\ndef _writable_rich_text_item(item: dict) -> dict:\n # Notion read responses include read-only fields like plain_text, href, and expanded user data.\n # Keep only the fields accepted by create/update rich_text payloads.\n writable = {\"type\": item[\"type\"]}\n\n if item[\"type\"] == \"text\":\n writable[\"text\"] = item[\"text\"]\n elif item[\"type\"] == \"mention\":\n mention = item[\"mention\"]\n mention_type = mention[\"type\"]\n mention_value = mention[mention_type]\n\n writable[\"mention\"] = {\"type\": mention_type}\n if isinstance(mention_value, dict) and \"id\" in mention_value:\n writable[\"mention\"][mention_type] = {\"id\": mention_value[\"id\"]}\n else:\n writable[\"mention\"][mention_type] = mention_value\n elif item[\"type\"] == \"equation\":\n writable[\"equation\"] = item[\"equation\"]\n\n if \"annotations\" in item:\n writable[\"annotations\"] = item[\"annotations\"]\n\n return writable\n\n\ndef _writable_rich_text(content: str | list) -> str | list:\n # Normalize rich_text copied from another page before sending it back to Notion.\n if isinstance(content, list):\n return [_writable_rich_text_item(deepcopy(item)) for item in content]\n return content\n\n\nclass NotionProperties(dict):\n \"\"\"\n Notion property dict builder with method chaining.\n Subclasses dict so instances can be passed directly to NotionApi.create/update.\n\n Usage:\n props = (\n NotionProperties()\n .title(\"name\", \"ํ๊ธธ๋\")\n .checkbox(\"is_in_guild\", True)\n .relation(\"config\", config_page_id)\n .number(\"level\", 260)\n .rich_text(\"ocid\", ocid)\n .date(\"last_api_sync_date\", \"2025-01-01\")\n .select(\"character_relation_type\", \"bossใdrop\")\n )\n notion_api.update(page_id=..., properties=props)\n \"\"\"\n\n def title(self, name: str, content: str | list) -> \"NotionProperties\":\n self[name] = {\"title\": content if isinstance(content, list) else [{\"type\": \"text\", \"text\": {\"content\": content}}]}\n return self\n\n def title_mention(self, name: str, page_id: str) -> \"NotionProperties\":\n self[name] = {\"title\": [{\"type\": \"mention\", \"mention\": {\"type\": \"page\", \"page\": {\"id\": page_id}}}]}\n return self\n\n def rich_text(self, name: str, content: str | list) -> \"NotionProperties\":\n content = _writable_rich_text(content)\n self[name] = {\"rich_text\": content if isinstance(content, list) else [{\"type\": \"text\", \"text\": {\"content\": content}}]}\n return self\n\n def number(self, name: str, value: int | float | None) -> \"NotionProperties\":\n self[name] = {\"number\": value}\n return self\n\n def checkbox(self, name: str, value: bool) -> \"NotionProperties\":\n self[name] = {\"checkbox\": value}\n return self\n\n def select(self, name: str, value: str | None) -> \"NotionProperties\":\n self[name] = {\"select\": {\"name\": value} if value is not None else None}\n return self\n\n def relation(self, name: str, *ids: str) -> \"NotionProperties\":\n self[name] = {\"relation\": [{\"id\": id_} for id_ in ids]}\n return self\n\n def url(self, name: str, value: str | None) -> \"NotionProperties\":\n self[name] = {\"url\": value}\n return self\n\n def multi_select(self, name: str, values: list) -> \"NotionProperties\":\n self[name] = {\"multi_select\": [{\"name\": v[\"name\"]} for v in values]}\n return self\n\n def date(self, name: str, start: str | None, end: str | None = None) -> \"NotionProperties\":\n if start is not None:\n self[name] = {\"date\": {\"start\": start, \"end\": end}}\n return self\n",
"custom_filter.py": "from calendar import monthrange\nfrom datetime import datetime, timedelta, timezone\nfrom typing import Any\n\nfrom notion.filter import NotionFilter as f\nfrom notion.filter_logic import and_, or_\n\n\ndef _day_start_iso(dt: datetime) -> str:\n # Notion์ datetime ์์ฑ์ UTC ๊ธฐ์ค์ผ๋ก ๋น๊ตํ๋ฏ๋ก, date-only ๋์ ์๊ฐ ํฌํจ ISO ๋ฌธ์์ด ์ฌ์ฉ\n return dt.replace(hour=0, minute=0, second=0, microsecond=0).isoformat(timespec=\"seconds\")\n\n\ndef _day_end_iso(dt: datetime) -> str:\n return dt.replace(hour=23, minute=59, second=59, microsecond=0).isoformat(timespec=\"seconds\")\n\n\ndef already_cloned_filter(source_page: dict[str, Any]) -> dict[str, Any] | None:\n minimum_interval_days = source_page[\"properties\"][\"minimum_interval_days\"][\"number\"]\n if minimum_interval_days is None:\n minimum_interval_days = 0\n\n today = datetime.now(timezone(timedelta(hours=9)))\n search_range_end_str = _day_end_iso(today)\n today_start_str = _day_start_iso(today)\n yesterday_end_str = _day_end_iso(today - timedelta(days=1))\n\n # minimum_interval_days=7์ด๋ฉด 14์ผ์ clone ํ 21์ผ, 28์ผ์ ์ฌ๋ณต์ (์: 14 -> 21 -> 28)\n # on_or_after(≥) ์ฌ์ฉ ์ ๊ฒฝ๊ณ์ผ ๋น์ผ ์ ์ธ๋๋ฏ๋ก +1์ผ ๋ณด์ \n search_range_start_str = _day_start_iso(today - timedelta(days=minimum_interval_days - 1))\n\n return and_(\n f.relation(name=\"schedule\", page_id=source_page[\"id\"]),\n f.formula_date(name=\"date\", value=search_range_start_str, date_operator=\"on_or_after\"),\n f.formula_date(name=\"date\", value=search_range_end_str, date_operator=\"on_or_before\"),\n or_(\n # ๋น์ผ์ ์์ง ํ๋ ์ฌ์ง๊ฐ ์์ด '์๋ฃ' ํ์ ์ด ์๋๋ฏ๋ก progress ๋ฌด๊ดํ๊ฒ ํฌํจ\n f.formula_date(name=\"date\", value=today_start_str, date_operator=\"on_or_after\"), # ํ์ด์ง์ ๋ ์ง๊ฐ ์ค๋์ธ๊ฐ? (์ ํํ๋ ์ค๋ ์ดํ์ธ๊ฐ?)\n # ๋น์ผ ์ด์ (์ด์ ๊น์ง)์ progress๊ฐ '์๋ฃ'์ฌ์ผ ํฌํจ\n and_( # ํ์ด์ง๊ฐ ์ค๋๋ณด๋ค ์ด์ ๋ ์ง์ด๋ฉด์, ์ํ๊ฐ '์๋ฃ'์ธ๊ฐ?\n f.formula_date(name=\"date\", value=yesterday_end_str, date_operator=\"on_or_before\"),\n f.status(name=\"progress\", value=\"์๋ฃ\"),\n ),\n ),\n )\n\n\ndef pages_to_clone_filter() -> dict[str, Any] | None:\n today = datetime.now(\n timezone(timedelta(hours=9))) # astimezone() ๋์ KST ๋ช
์: GitHub Actions๋ UTC ํ๊ฒฝ์ด๋ผ OS ํ์์กด์ ์์กดํ๋ฉด ๋ ์ง๊ฐ ๋ฌ๋ผ์ง\n today_str = today.date().isoformat() # \"2026-05-17\" ํํ. ์๊ฐ ๋ฌด์ํ๊ณ ๋ ์ง๋ก๋ง ๋น๊ต\n # today_str = today.isoformat(timespec=\"seconds\") # \"2026-05-17T21:25:25+09:00\" ํํ. ์๊ฐ๊น์ง ๋น๊ต\n\n month = today.month\n monthday = today.day\n last_monthday = monthrange(today.year, month)[1]\n week_of_month = (monthday - 1) // 7 + 1\n last_week_of_month = (last_monthday - 1) // 7 + 1\n weekday_value = [\"์ (mon)\", \"ํ (tue)\", \"์ (wed)\", \"๋ชฉ (thu)\", \"๊ธ (fri)\", \"ํ (sat)\", \"์ผ (sun)\"][today.weekday()]\n\n return and_(\n f.checkbox(\n name=\"deprecated\",\n value=False,\n ),\n f.checkbox(\n name=\"trashed\",\n value=False,\n ),\n f.checkbox(\n name=\"auto_clone\",\n value=True,\n ),\n or_(\n and_(\n # ๋ ์ง ๋ฒ์: ์์์ผ์ ์ค๋ ์ด์ /๋น์ผ์ด๊ณ , ์ข
๋ฃ์ผ์ ์ค๋ ์ดํ/๋น์ผ์ธ ํญ๋ชฉ\n f.date(name=\"date\", value=_day_end_iso(today), date_operator=\"on_or_before\"),\n f.date(name=\"_end_date\", value=_day_start_iso(today), date_operator=\"on_or_after\"),\n ),\n f.date(name=\"date\", value=None),\n ),\n or_(\n # or๋ก ์ ์ฉ๋๋ ํน์ ์กฐ๊ฑด\n # TODO f.multi_select(name=\"clone_on_before_or\", values=None),\n and_(\n # and๋ก ์ ์ฉ๋๋ ํน์ ์กฐ๊ฑด\n # TODO f.multi_select(name=\"clone_on_before_and\", values=None),\n\n # ๋ฌ (1 ~ 12), ๋น์ด ์์ผ๋ฉด ๋งค์ ๋ฐ๋ณต\n or_(\n f.multi_select(name=\"clone_on_month_and\", values=[str(month)]),\n f.multi_select(name=\"clone_on_month_and\", values=None),\n ),\n\n # ์ผ (1 ~ 31), ๋น์ด ์์ผ๋ฉด ๋งค์ผ ๋ฐ๋ณต\n or_(\n f.multi_select(name=\"clone_on_monthday_and\", values=[str(monthday)]),\n f.multi_select(\n name=\"clone_on_monthday_and\",\n values=[\"last_date\"],\n ) if monthday == last_monthday else None,\n f.multi_select(name=\"clone_on_monthday_and\", values=None),\n ),\n\n # ์ฃผ์ฐจ (1 ~ 5), ๋น์ด ์์ผ๋ฉด ๋งค์ฃผ ๋ฐ๋ณต\n or_(\n f.multi_select(name=\"clone_on_week_of_month_and\", values=[str(week_of_month)]),\n f.multi_select(\n name=\"clone_on_week_of_month_and\",\n values=[\"last_week\"],\n ) if week_of_month == last_week_of_month else None,\n f.multi_select(name=\"clone_on_week_of_month_and\", values=None),\n ),\n\n # ์์ผ (์ ~ ์ผ), ๋น์ด ์์ผ๋ฉด ๋งค์ผ ๋ฐ๋ณต\n or_(\n f.multi_select(name=\"clone_on_weekday_and\", values=[weekday_value]),\n f.multi_select(name=\"clone_on_weekday_and\", values=None),\n ),\n ),\n ),\n )\n",
"custom_property.py": "from datetime import datetime, timedelta, timezone\nfrom typing import Any\n\nfrom notion.property import NotionProperties\n\n_now = datetime.now(\n timezone(timedelta(hours=9))) # astimezone() ๋์ KST ๋ช
์: GitHub Actions๋ UTC ํ๊ฒฝ์ด๋ผ OS ํ์์กด์ ์์กดํ๋ฉด ๋ ์ง๊ฐ ๋ฌ๋ผ์ง\n_iso = _now.isocalendar()\n_weekday_str = [\"์ (mon)\", \"ํ (tue)\", \"์ (wed)\", \"๋ชฉ (thu)\", \"๊ธ (fri)\", \"ํ (sat)\", \"์ผ (sun)\"][_now.weekday()]\n\n\ndef target_page_properties(source_page: dict[str, Any]) -> NotionProperties:\n props = (\n NotionProperties()\n .relation(\"schedule\", source_page[\"id\"])\n .title_mention(\"name\", source_page[\"id\"])\n .rich_text(\"memo\", source_page[\"properties\"][\"memo\"][\"rich_text\"])\n .rich_text(\"place\", source_page[\"properties\"][\"place\"][\"rich_text\"])\n .relation(\"people\", *[p[\"id\"] for p in source_page[\"properties\"][\"people\"][\"relation\"]])\n .number(\"duration_hour\", 0)\n .number(\"_source_year\", _iso.year)\n .number(\"_source_week_of_year\", _iso.week)\n .select(\"_source_day_of_week\", _weekday_str)\n )\n return props\n",
".github/workflows/run.yml": "on:\n workflow_dispatch:\n schedule:\n # ๋งค์ผ ํ๊ตญ์๊ฐ 00:22 ์คํ\n # KST 00:22 = UTC ์ ๋ 15:22\n - cron: \"22 15 * * *\"\n\njobs:\n r:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v5\n - run: python3 -m pip install -r requirements.txt\n - run: python3 main.py\n env:\n NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}\n SOURCE_DB_ID: ${{ secrets.SOURCE_DB_ID }}\n TARGET_DB_ID: ${{ secrets.TARGET_DB_ID }}\n"
}
}
tree_paths์ ํ๋ก์ ํธ์ ์ ์ฒด ๊ตฌ์กฐ๊ฐ ๋ด๊ธด๋ค. selected_files์๋ ํด๋น ๋ ํฌ์งํ ๋ฆฌ๋ฅผ ๋ํํ๋ ํ์ผ์ ์ต๋ 10๊ฐ๊น์ง ๋ด๊ธฐ๋๋ฐ, ์์ค ์ฝ๋๊น์ง ๊ฐ์ ธ์จ๋ค.
#5-3 [02] ๊ตฌ์กฐ ํด์
# in main.py
...
def main():
...
analyst = AnalystAgent(api_key=gemini_api_key, model=MODEL)
...
analyst_output = analyst.run(project_data)
...
project_data["analysis_summary"] = ... analyst_output.get("analysis_result", "{}") ...
...
ananalyst.py ๋ชจ๋์ ํธ์ถํด analyst_output์ ๊ฒฐ๊ด๊ฐ์ผ๋ก ๋ฐ๋๋ค. analyst_output์ ์ผ๋ถ ๋ฐ์ดํฐ๋ analysis_summary๋ผ๋ key ์ด๋ฆ์ผ๋ก project_data์ ์ถ๊ฐ๋๊ณ , ๋๋จธ์ง ๋ฐ์ดํฐ๋ [03] ํค์๋ ์ถ์ถ ๋จ๊ณ์์ ์ฌ์ฉ๋๋ค. analyst_output์ ์์๋ ์๋์ ๊ฐ๋ค.
{
"status": "success",
"repo_name": "Kanmanemone/notion-auto-clone",
"repo_url": "https://github.com/Kanmanemone/notion-auto-clone",
"candidate_files": ["README.md", "requirements.txt", "main.py"],
"structure_summary": {
"top_directories": {
"notion": 6,
".github": 1
},
"important_root_files": ["README.md", "requirements.txt"],
"extension_summary": {
"py": 10,
"yml": 1,
"gitignore": 1,
"md": 1,
"txt": 1
},
"total_paths": 14
},
"structure_hints": {
"has_frontend": false,
"has_backend": false,
"has_components_dir": false,
"has_pages_dir": false,
"has_api_dir": false,
"has_docker": false,
"has_readme": true,
"possible_framework_files": ["requirements.txt"]
},
"analysis_context": "=== REPOSITORY META ===\nRepository name: Kanmanemone/notion-auto-clone\nRepository URL: https://github.com/Kanmanemone/notion-auto-clone\n\n=== TREE SUMMARY ===\nTotal paths: 14\nTop directories: {'notion': 6, '.github': 1}\nImportant root files: ['README.md', 'requirements.txt']\nExtension summary: {'py': 10, 'yml': 1, 'gitignore': 1, 'md': 1, 'txt': 1}\n\n=== STRUCTURE HINTS ===\n{'has_frontend': False, 'has_backend': False, 'has_components_dir': False, 'has_pages_dir': False, 'has_api_dir': False, 'has_docker': False, 'has_readme': True, 'possible_framework_files': ['requirements.txt']}\n\n=== REPOSITORY TREE (partial) ===\n.github/workflows/run.yml\n.gitignore\nREADME.md\ncustom_filter.py\ncustom_patch.py\ncustom_property.py\nmain.py\nnotion/__init__.py\nnotion/api.py\nnotion/filter.py\nnotion/filter_compiler.py\nnotion/filter_logic.py\nnotion/property.py\nrequirements.txt\n\n=== SELECTED FILE CONTENTS ===\n\n--- FILE: main.py ---\nimport os\n\nfrom custom_filter import pages_to_clone_filter, already_cloned_filter\nfrom custom_patch import source_page_patch, target_page_patch\nfrom custom_property import target_page_properties\nfrom notion.api import NotionApi\n\nnotion_api = NotionApi(os.environ[\"NOTION_TOKEN\"])\nsource_db_id = os.environ[\"SOURCE_DB_ID\"]\ntarget_db_id = os.environ[\"TARGET_DB_ID\"]\n\n# ์ฌ์ฉ์ ์ ์ ์กฐ๊ฑด์ ๊ฐ์ ธ์ source DB์์ ๋ณต์ ํ ํ์ด์ง๋ฅผ ์กฐํ\nsource_pages = notion_api.read(\n db_id=source_db_id,\n filter=pages_to_clone_filter(),\n sorts=[\n {\n \"property\": \"time_order\",\n \"direction\": \"descending\",\n },\n ],\n)\n\nfor source_page in source_pages[\"results\"]:\n # source DB์์ ์กฐํํ ํ์ด์ง๋ฅผ target DB์ ์ ํ์ด์ง๋ก ์์ฑ\n already_cloned_page = notion_api.read(\n db_id=target_db_id,\n filter=already_cloned_filter(source_page=source_page),\n page_size=1,\n )\n if already_cloned_page[\"results\"]:\n page = already_cloned_page[\"results\"][0]\n title = page[\"properties\"][\"name\"][\"title\"]\n date = page[\"properties\"][\"date\"][\"formula\"][\"date\"][\"start\"]\n print(f\"(์ด๋ฏธ ์กด์ฌ) {title[0]['plain_text'] if title else '(no title)'} | {date}\")\n continue\n\n target_page = notion_api.create(\n db_id=target_db_id,\n properties=target_page_properties(source_page)\n )\n\n # ํ์ฒ๋ฆฌ (post process)\n notion_api.update(page_id=source_page[\"id\"], properties=source_page_patch(source_page, target_page))\n notion_api.update(page_id=target_page[\"id\"], properties=target_page_patch(source_page, target_page))\n\n # ์ฝ์์ฐฝ ๋ก๊ทธ\n title = target_page[\"properties\"][\"name\"][\"title\"]\n print(title[0][\"plain_text\"] if title else \"(no title)\")\n\n\n--- FILE: notion/api.py ---\nimport json\nimport time\nimport urllib.error\nimport urllib.request as r\nfrom typing import Any\n\nfrom notion.filter_compiler import to_notion_filter\n\n\nclass NotionApi:\n _BASE_URL = \"https://api.notion.com/v1\"\n\n def __init__(self, token: str):\n # ๋ชจ๋ ์์ฒญ์ ๊ณตํต์ผ๋ก ์ฌ์ฉํ ํค๋\n self._headers = {\n \"Authorization\": \"Bearer \" + token,\n \"Notion-Version\": \"2022-06-28\",\n \"Content-Type\": \"application/json\",\n }\n\n # Notion REST API์ HTTP ์์ฒญ์ ๋ณด๋ด๊ณ ์๋ต์ผ๋ก JSON์ ๋ฐ๋ ๋ฒ์ฉ ํจ์\n def _api(self, method: str, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]:\n req = r.Request(\n self._BASE_URL + path,\n json.dumps(body).encode() if body else None,\n self._headers,\n method=method,\n )\n\n time.sleep(0.4) # Notion API ์ด๋น 3๊ฑด ์ ํ → 0.4์ด ๊ฐ๊ฒฉ ์ ์ง\n\n wait = 1\n for attempt in range(1, 6): # ์ต๋ 5ํ ์๋\n try:\n with r.urlopen(req) as res:\n return json.loads(res.read())\n\n except urllib.error.HTTPError as e:\n is_rate_limit = (e.code == 429)\n is_last_attempt = (attempt == 5)\n\n if not is_rate_limit or is_last_attempt:\n raise # 429 ์ธ ์๋ฌ๊ฑฐ๋ 5ํ ๋ชจ๋ ์คํจ ์ ๊ทธ๋ฅ ๋์ง\n\n # Retry-After ํค๋๊ฐ ์์ผ๋ฉด ๊ทธ ๊ฐ, ์์ผ๋ฉด ์ง์ ๋ฐฑ์คํ(1→2→4...์ต๋ 60์ด)\n wait = int(e.headers.get(\"Retry-After\", wait))\n print(f\"Rate limited. {attempt}ํ ์๋ ์คํจ, {wait}์ด ํ ์ฌ์๋...\")\n time.sleep(wait)\n wait = min(wait * 2, 60)\n\n # noinspection PyShadowingBuiltins\n def read(\n self,\n db_id: str,\n filter: dict[str, Any] | None = None,\n page_size: int | None = None,\n sorts: list[dict[str, str]] | None = None,\n ) -> dict[str, Any]:\n body: dict[str, Any] = {}\n compiled = to_notion_filter(filter)\n if compiled:\n body[\"filter\"] = compiled\n if page_size is not None:\n body[\"page_size\"] = page_size\n if sorts:\n body[\"sorts\"] = sorts\n return self._api(\"POST\", f\"/databases/{db_id}/query\", body or None)\n\n def create(self, db_id: str, properties: dict[str, Any]) -> dict[str, Any]:\n return self._api(\"POST\", \"/pages\", {\"parent\": {\"database_id\": db_id}, \"properties\": properties})\n\n def update(self, page_id: str, properties: dict[str, Any]) -> dict[str, Any]:\n return self._api(\"PATCH\", f\"/pages/{page_id}\", {\"properties\": properties})\n\n\n--- FILE: notion/filter_compiler.py ---\nimport json\nfrom typing import Any\n\nfrom sympy import And, Or, Symbol\nfrom sympy.logic.boolalg import BooleanFalse, BooleanTrue, to_dnf\n\nfrom .filter_logic import and_, or_\n\n\n# custom_filter.py์์๋ ์ฌ๋์ด ์ฝ๊ธฐ ์ฌ์ด ์ค์ฒฉ and/or ์กฐ๊ฑด์ ์์ฑํ๋ค.\n# Notion API๋ compound filter ์์ ๋ค์ compound filter๊ฐ ๊น๊ฒ ๋ค์ด๊ฐ๋ฉด\n# validation_error๋ฅผ ๋ฐํํ ์ ์์ผ๋ฏ๋ก, API ์์ฒญ ์ง์ ์ DNF ํํ๋ก ํผ์น๋ค.\n# ์๋ฅผ ๋ค์ด, A AND (B OR C)๋ฅผ Notion์ด ๋ฐ๊ธฐ ์ฌ์ด ํํ์ธ\n# (A AND B) OR (A AND C)๋ก ๋ณํํ๋ค.\n\n\ndef to_notion_filter(filter_body: dict[str, Any] | None) -> dict[str, Any] | None:\n if not filter_body:\n return None\n\n symbol_to_filter: dict[Symbol, dict[str, Any]] = {}\n filter_to_symbol: dict[str, Symbol] = {}\n\n def to_expr(item: dict[str, Any]):\n if \"and\" in item:\n return And(*(to_expr(child) for child in item[\"and\"]))\n if \"or\" in item:\n return Or(*(to_expr(child) for child in item[\"or\"]))\n\n key = json.dumps(item, ensure_ascii=False, sort_keys=True)\n if key not in filter_to_symbol:\n symbol = Symbol(f\"f{len(filter_to_symbol)}\")\n filter_to_symbol[key] = symbol\n symbol_to_filter[symbol] = item\n return filter_to_symbol[key]\n\n def from_expr(expr):\n if isinstance(expr, BooleanTrue):\n return None\n if isinstance(expr, BooleanFalse):\n return None\n if isinstance(expr, Symbol):\n return symbol_to_filter[expr]\n if isinstance(expr, Or):\n return or_(*(from_expr(arg) for arg in expr.args))\n if isinstance(expr, And):\n return and_(*(from_expr(arg) for arg in expr.args))\n raise TypeError(f\"Unsupported filter expression: {expr!r}\")\n\n return from_expr(to_dnf(to_expr(filter_body), simplify=False))\n\n\n--- FILE: notion/filter_logic.py ---\nfrom typing import Any\n\ndef and_(*items: dict[str, Any] | None) -> dict[str, Any] | None:\n items = [x for x in items if x]\n\n if len(items) == 0:\n return None\n\n if len(items) == 1:\n return items[0]\n\n return {\"and\": items}\n\n\ndef or_(*items: dict[str, Any] | None) -> dict[str, Any] | None:\n items = [x for x in items if x]\n\n if len(items) == 0:\n return None\n\n if len(items) == 1:\n return items[0]\n\n return {\"or\": items}\n\n\n--- FILE: notion/property.py ---\nfrom copy import deepcopy\n\n\ndef _writable_rich_text_item(item: dict) -> dict:\n # Notion read responses include read-only fields like plain_text, href, and expanded user data.\n # Keep only the fields accepted by create/update rich_text payloads.\n writable = {\"type\": item[\"type\"]}\n\n if item[\"type\"] == \"text\":\n writable[\"text\"] = item[\"text\"]\n elif item[\"type\"] == \"mention\":\n mention = item[\"mention\"]\n mention_type = mention[\"type\"]\n mention_value = mention[mention_type]\n\n writable[\"mention\"] = {\"type\": mention_type}\n if isinstance(mention_value, dict) and \"id\" in mention_value:\n writable[\"mention\"][mention_type] = {\"id\": mention_value[\"id\"]}\n else:\n writable[\"mention\"][mention_type] = mention_value\n elif item[\"type\"] == \"equation\":\n writable[\"equation\"] = item[\"equation\"]\n\n if \"annotations\" in item:\n writable[\"annotations\"] = item[\"annotations\"]\n\n return writable\n\n\ndef _writable_rich_text(content: str | list) -> str | list:\n # Normalize rich_text copied from another page before sending it back to Notion.\n if isinstance(content, list):\n return [_writable_rich_text_item(deepcopy(item)) for item in content]\n return content\n\n\nclass NotionProperties(dict):\n \"\"\"\n Notion property dict builder with method chaining.\n Subclasses dict so instances can be passed directly to NotionApi.create/update.\n\n Usage:\n props = (\n NotionProperties()\n .title(\"name\", \"ํ๊ธธ๋\")\n .checkbox(\"is_in_guild\", True)\n .relation(\"config\", config_page_id)\n .number(\"level\", 260)\n .rich_text(\"ocid\", ocid)\n .date(\"last_api_sync_date\", \"2025-01-01\")\n .select(\"character_relation_type\", \"bossใdrop\")\n )\n notion_api.update(page_id=..., properties=props)\n \"\"\"\n\n def title(self, name: str, content: str | list) -> \"NotionProperties\":\n self[name] = {\"title\": content if isinstance(content, list) else [{\"type\": \"text\", \"text\": {\"content\": content}}]}\n return self\n\n def title_mention(self, name: str, page_id: str) -> \"NotionProperties\":\n self[name] = {\"title\": [{\"type\": \"mention\", \"mention\": {\"type\": \"page\", \"page\": {\"id\": page_id}}}]}\n return self\n\n def rich_text(self, name: str, content: str | list) -> \"NotionProperties\":\n content = _writable_rich_text(content)\n self[name] = {\"rich_text\": content if isinstance(content, list) else [{\"type\": \"text\", \"text\": {\"content\": content}}]}\n return self\n\n def number(self, name: str, value: int | float | None) -> \"NotionProperties\":\n self[name] = {\"number\": value}\n return self\n\n def checkbox(self, name: str, value: bool) -> \"NotionProperties\":\n self[name] = {\"checkbox\": value}\n return self\n\n def select(self, name: str, value: str | None) -> \"NotionProperties\":\n self[name] = {\"select\": {\"name\": value} if value is not None else None}\n return self\n\n def relation(self, name: str, *ids: str) -> \"NotionProperties\":\n self[name] = {\"relation\": [{\"id\": id_} for id_ in ids]}\n return self\n\n def url(self, name: str, value: str | None) -> \"NotionProperties\":\n self[name] = {\"url\": value}\n return self\n\n def multi_select(self, name: str, values: list) -> \"NotionProperties\":\n self[name] = {\"multi_select\": [{\"name\": v[\"name\"]} for v in values]}\n return self\n\n def date(self, name: str, start: str | None, end: str | None = None) -> \"NotionProperties\":\n if start is not None:\n self[name] = {\"date\": {\"start\": start, \"end\": end}}\n return self\n\n\n--- FILE: custom_filter.py ---\nfrom calendar import monthrange\nfrom datetime import datetime, timedelta, timezone\nfrom typing import Any\n\nfrom notion.filter import NotionFilter as f\nfrom notion.filter_logic import and_, or_\n\n\ndef _day_start_iso(dt: datetime) -> str:\n # Notion์ datetime ์์ฑ์ UTC ๊ธฐ์ค์ผ๋ก ๋น๊ตํ๋ฏ๋ก, date-only ๋์ ์๊ฐ ํฌํจ ISO ๋ฌธ์์ด ์ฌ์ฉ\n return dt.replace(hour=0, minute=0, second=0, microsecond=0).isoformat(timespec=\"seconds\")\n\n\ndef _day_end_iso(dt: datetime) -> str:\n return dt.replace(hour=23, minute=59, second=59, microsecond=0).isoformat(timespec=\"seconds\")\n\n\ndef already_cloned_filter(source_page: dict[str, Any]) -> dict[str, Any] | None:\n minimum_interval_days = source_page[\"properties\"][\"minimum_interval_days\"][\"number\"]\n if minimum_interval_days is None:\n minimum_interval_days = 0\n\n today = datetime.now(timezone(timedelta(hours=9)))\n search_range_end_str = _day_end_iso(today)\n today_start_str = _day_start_iso(today)\n yesterday_end_str = _day_end_iso(today - timedelta(days=1))\n\n # minimum_interval_days=7์ด๋ฉด 14์ผ์ clone ํ 21์ผ, 28์ผ์ ์ฌ๋ณต์ (์: 14 -> 21 -> 28)\n # on_or_after(≥) ์ฌ์ฉ ์ ๊ฒฝ๊ณ์ผ ๋น์ผ ์ ์ธ๋๋ฏ๋ก +1์ผ ๋ณด์ \n search_range_start_str = _day_start_iso(today - timedelta(days=minimum_interval_days - 1))\n\n return and_(\n f.relation(name=\"schedule\", page_id=source_page[\"id\"]),\n f.formula_date(name=\"date\", value=search_range_start_str, date_operator=\"on_or_after\"),\n f.formula_date(name=\"date\", value=search_range_end_str, date_operator=\"on_or_before\"),\n or_(\n # ๋น์ผ์ ์์ง ํ๋ ์ฌ์ง๊ฐ ์์ด '์๋ฃ' ํ์ ์ด ์๋๋ฏ๋ก progress ๋ฌด๊ดํ๊ฒ ํฌํจ\n f.formula_date(name=\"date\", value=today_start_str, date_operator=\"on_or_after\"), # ํ์ด์ง์ ๋ ์ง๊ฐ ์ค๋์ธ๊ฐ? (์ ํํ๋ ์ค๋ ์ดํ์ธ๊ฐ?)\n # ๋น์ผ ์ด์ (์ด์ ๊น์ง)์ progress๊ฐ '์๋ฃ'์ฌ์ผ ํฌํจ\n and_( # ํ์ด์ง๊ฐ ์ค๋๋ณด๋ค ์ด์ ๋ ์ง์ด๋ฉด์, ์ํ๊ฐ '์๋ฃ'์ธ๊ฐ?\n f.formula_date(name=\"date\", value=yesterday_end_str, date_operator=\"on_or_before\"),\n f.status(name=\"progress\", value=\"์๋ฃ\"),\n ),\n ),\n )\n\n\ndef pages_to_clone_filter() -> dict[str, Any] | None:\n today = datetime.now(\n timezone(timedelta(hours=9))) # astimezone() ๋์ KST ๋ช
์: GitHub Actions๋ UTC ํ๊ฒฝ์ด๋ผ OS ํ์์กด์ ์์กดํ๋ฉด ๋ ์ง๊ฐ ๋ฌ๋ผ์ง\n today_str = today.date().isoformat() # \"2026-05-17\" ํํ. ์๊ฐ ๋ฌด์ํ๊ณ ๋ ์ง๋ก๋ง ๋น๊ต\n # today_str = today.isoformat(timespec=\"seconds\") # \"2026-05-17T21:25:25+09:00\" ํํ. ์๊ฐ๊น์ง ๋น๊ต\n\n month = today.month\n monthday = today.day\n last_monthday = monthrange(today.year, month)[1]\n week_of_month = (monthday - 1) // 7 + 1\n last_week_of_month = (last_monthday - 1) // 7 + 1\n weekday_value = [\"์ (mon)\", \"ํ (tue)\", \"์ (wed)\", \"๋ชฉ (thu)\", \"๊ธ (fri)\", \"ํ (sat)\", \"์ผ (sun)\"][today.weekday()]\n\n return and_(\n f.checkbox(\n name=\"deprecated\",\n value=False,\n ),\n f.checkbox(\n name=\"trashed\",\n value=False,\n ),\n f.checkbox(\n name=\"auto_clone\",\n value=True,\n ),\n or_(\n and_(\n # ๋ ์ง ๋ฒ์: ์์์ผ์ ์ค๋ ์ด์ /๋น์ผ์ด๊ณ , ์ข
๋ฃ์ผ์ ์ค๋ ์ดํ/๋น์ผ์ธ ํญ๋ชฉ\n f.date(name=\"date\", value=_day_end_iso(today), date_operator=\"on_or_before\"),\n f.date(name=\"_end_date\", value=_day_start_iso(today), date_operator=\"on_or_after\"),\n ),\n f.date(name=\"date\", value=None),\n ),\n or_(\n # or๋ก ์ ์ฉ๋๋ ํน์ ์กฐ๊ฑด\n # TODO f.multi_select(name=\"clone_on_before_or\", values=None),\n and_(\n # and๋ก ์ ์ฉ๋๋ ํน์ ์กฐ๊ฑด\n # TODO f.multi_select(name=\"clone_on_before_and\", values=None),\n\n # ๋ฌ (1 ~ 12), ๋น์ด ์์ผ๋ฉด ๋งค์ ๋ฐ๋ณต\n or_(\n f.multi_select(name=\"clone_on_month_and\", values=[str(month)]),\n f.multi_select(name=\"clone_on_month_and\", values=None),\n ),\n\n # ์ผ (1 ~ 31), ๋น์ด ์์ผ๋ฉด ๋งค์ผ ๋ฐ๋ณต\n or_(\n f.multi_select(name=\"clone_on_monthday_and\", values=[str(monthday)]),\n f.multi_select(\n \n\n--- FILE: custom_property.py ---\nfrom datetime import datetime, timedelta, timezone\nfrom typing import Any\n\nfrom notion.property import NotionProperties\n\n_now = datetime.now(\n timezone(timedelta(hours=9))) # astimezone() ๋์ KST ๋ช
์: GitHub Actions๋ UTC ํ๊ฒฝ์ด๋ผ OS ํ์์กด์ ์์กดํ๋ฉด ๋ ์ง๊ฐ ๋ฌ๋ผ์ง\n_iso = _now.isocalendar()\n_weekday_str = [\"์ (mon)\", \"ํ (tue)\", \"์ (wed)\", \"๋ชฉ (thu)\", \"๊ธ (fri)\", \"ํ (sat)\", \"์ผ (sun)\"][_now.weekday()]\n\n\ndef target_page_properties(source_page: dict[str, Any]) -> NotionProperties:\n props = (\n NotionProperties()\n .relation(\"schedule\", source_page[\"id\"])\n .title_mention(\"name\", source_page[\"id\"])\n .rich_text(\"memo\", source_page[\"properties\"][\"memo\"][\"rich_text\"])\n .rich_text(\"place\", source_page[\"properties\"][\"place\"][\"rich_text\"])\n .relation(\"people\", *[p[\"id\"] for p in source_page[\"properties\"][\"people\"][\"relation\"]])\n .number(\"duration_hour\", 0)\n .number(\"_source_year\", _iso.year)\n .number(\"_source_week_of_year\", _iso.week)\n .select(\"_source_day_of_week\", _weekday_str)\n )\n return props\n\n\n--- FILE: custom_patch.py ---\nfrom typing import Any\n\n\ndef source_page_patch(source_page: dict[str, Any], target_page: dict[str, Any]) -> dict[str, Any]:\n # TODO\n\n return {}\n\n\ndef target_page_patch(source_page: dict[str, Any], target_page: dict[str, Any]) -> dict[str, Any]:\n #TODO\n\n return {}\n\n\n--- FILE: .github/workflows/run.yml ---\non:\n workflow_dispatch:\n schedule:\n # ๋งค์ผ ํ๊ตญ์๊ฐ 00:22 ์คํ\n # KST 00:22 = UTC ์ ๋ 15:22\n - cron: \"22 15 * * *\"\n\njobs:\n r:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v5\n - run: python3 -m pip install -r requirements.txt\n - run: python3 main.py\n env:\n NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}\n SOURCE_DB_ID: ${{ secrets.SOURCE_DB_ID }}\n TARGET_DB_ID: ${{ secrets.TARGET_DB_ID }}\n",
"analysis_result": "{\n \"project_overview\": \"Notion์ ํน์ ๋ฐ์ดํฐ๋ฒ ์ด์ค(Source DB)์ ์ ์๋ ๋ฐ๋ณต ์ผ์ ์ด๋ ์์
์ ์กฐ๊ฑด์ ๋ง์ถฐ ๋ค๋ฅธ ๋ฐ์ดํฐ๋ฒ ์ด์ค(Target DB)๋ก ์๋์ผ๋ก ๋ณต์ ํด์ฃผ๋ ์๋ํ ๋๊ตฌ์
๋๋ค. GitHub Actions๋ฅผ ํตํด ์ ๊ธฐ์ ์ผ๋ก ์คํ๋๋ฉฐ, ์ฌ์ฉ์๊ฐ ์ ์ํ ๋ณต์กํ ํํฐ๋ง ๋ก์ง์ ๋ฐ๋ผ ํ์ด์ง๋ฅผ ์์ฑํ๊ณ ๊ด๊ณํ ์ ๋ณด๋ฅผ ๋๊ธฐํํฉ๋๋ค.\",\n \"directory_structure\": \"ํ๋ก์ ํธ๋ ํ์ด์ฌ ๊ธฐ๋ฐ์ผ๋ก ๊ตฌ์ฑ๋์ด ์์ผ๋ฉฐ, 'notion/' ๋๋ ํ ๋ฆฌ ๋ด์ Notion REST API ํธ์ถ์ ์ํ ํต์ฌ ๋ชจ๋(api, ํํฐ ๋ก์ง, ์์ฑ ๋น๋)์ด ์์นํฉ๋๋ค. ๋ฃจํธ ๋๋ ํ ๋ฆฌ์ ํ์ผ๋ค(custom_*.py)์ ์ฌ์ฉ์ ์ ์ ๋น์ฆ๋์ค ๋ก์ง์ ๋ด๋นํ๋ฉฐ, '.github/' ๋๋ ํ ๋ฆฌ๋ ๋ฐฐํฌ ๋ฐ ์๋ ์คํ์ ์ํ CI/CD ์ค์ ์ ํฌํจํฉ๋๋ค.\",\n \"key_features\": [\n \"Notion API๋ฅผ ํ์ฉํ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์๋ ์ฝ๊ธฐ/์ฐ๊ธฐ ๋ฐ ํ์ด์ง ๋ณต์ \",\n \"SymPy ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ํ์ฉํ ๋ณต์กํ Notion ํํฐ(AND/OR ์กฐ๊ฑด)์ DNF ๋ณํ ๋ฐ ์ฟผ๋ฆฌ ์ต์ ํ\",\n \"GitHub Actions๋ฅผ ์ด์ฉํ ๋งค์ผ ํน์ ์๊ฐ(KST ๊ธฐ์ค) ๋ฐ๋ณต ์์
์ํ\",\n \"Rate Limit(429) ๋์์ ์ํ ์ฌ์๋ ๋ก์ง ๋ฐ ์ง์ ๋ฐฑ์คํ ์ ์ฉ\",\n \"Method Chaining์ ํ์ฉํ ์ง๊ด์ ์ธ Notion ํ์ด์ง ์์ฑ ๋น๋ ์ ๊ณต\"\n ],\n \"key_files\": {\n \"main.py\": \"ํ๋ก์ ํธ์ ์ง์
์ . Source DB์์ ๋์ ํ์ด์ง๋ฅผ ์กฐํํ๊ณ , ์ค๋ณต ์ฌ๋ถ๋ฅผ ์ฒดํฌํ ๋ค Target DB์ ํ์ด์ง๋ฅผ ์์ฑํ๊ณ ํ์ฒ๋ฆฌ๋ฅผ ์ํํฉ๋๋ค.\",\n \"notion/api.py\": \"Notion REST API์์ ํต์ ์ ๊ด๋ฆฌํ๋ฉฐ, ์์ฒญ ํค๋ ์ค์ , ์๋ฌ ์ฒ๋ฆฌ, Rate Limit ๋์(์ฌ์๋) ๋ก์ง์ ํฌํจํฉ๋๋ค.\",\n \"notion/filter_compiler.py\": \"์ฌ๋์ด ์์ฑํ ์ค์ฒฉ๋ ํํฐ ์กฐ๊ฑด์ Notion API๊ฐ ์ดํดํ ์ ์๋ ํํ๋ก ๋ณํํฉ๋๋ค. ๋
ผ๋ฆฌ์ ๋จ์ํ ๋ฐ API ์์ฒญ ์ ์ฝ ๋์์ ์ํํฉ๋๋ค.\",\n \"notion/property.py\": \"Notion์ ๋ณต์กํ ์์ฑ JSON ๊ตฌ์กฐ๋ฅผ ์ฝ๊ฒ ์์ฑํ ์ ์๋๋ก ๋๋ 'NotionProperties' ๋น๋ ํด๋์ค๋ฅผ ์ ๊ณตํฉ๋๋ค.\",\n \"custom_filter.py\": \"์ฌ์ฉ์์ ํน์ ๋น์ฆ๋์ค ์๊ตฌ์ฌํญ(์: ์ฃผ๊ฐ/์๊ฐ ๋ฐ๋ณต ์ผ์ ์ฒดํฌ)์ ๋ฐ๋ฅธ ํ์ด์ง ์ ์ ๋ก์ง์ ๋ด๊ณ ์์ต๋๋ค.\"\n },\n \"execution_flow\": \"1. GitHub Actions ์ค์ผ์ค๋ฌ๊ฐ ๋งค์ผ ์ง์ ๋ ์๊ฐ์ ์ํฌํ๋ก์ฐ๋ฅผ ํธ๋ฆฌ๊ฑฐํจ. 2. 'main.py'๊ฐ ์คํ๋์ด ํ๊ฒฝ ๋ณ์์์ ํ ํฐ๊ณผ DB ID๋ฅผ ๋ก๋ํจ. 3. 'custom_filter.py'๋ฅผ ํตํด ๋ณต์ ๋์ ํ์ด์ง๋ฅผ ํํฐ๋งํจ. 4. ๊ฐ ํ์ด์ง์ ๋ํด 'Target DB'์ ์ด๋ฏธ ๋ณต์ ๋์๋์ง ํ์ธ. 5. ๋ณต์ ๋์ง ์์๋ค๋ฉด 'custom_property.py'๋ฅผ ํตํด ์์ฑ์ ๊ตฌ์ฑํ์ฌ ํ์ด์ง๋ฅผ ์์ฑ. 6. ์๋ฃ ํ ๋ก๊ทธ ์ถ๋ ฅ ๋ฐ ํ์ฒ๋ฆฌ ์
๋ฐ์ดํธ ์ํ.\",\n \"uncertain_points\": \"1. 'custom_patch.py' ํ์ผ ๋ด์ 'source_page_patch'์ 'target_page_patch' ํจ์๊ฐ 'TODO'๋ก ๋น์ด์์ด, ๋ณต์ ์ดํ ํ์ด์ง ๊ฐ ์ํ ๋๊ธฐํ๋ ์ํธ ์
๋ฐ์ดํธ ๊ธฐ๋ฅ์ ํ์ฌ ๊ตฌํ๋์ง ์์๊ฑฐ๋ ๋ฏธ์์ฑ ์ํ๋ก ์ถ์ ๋ฉ๋๋ค. 2. ์ฌ์ฉ์ ์ ์ ํํฐ์ ๋ณต์ก๋์ ๋ฐ๋ผ Notion์ ์ต๋ ํํฐ ๊น์ด ์ ํ ์ด์๊ฐ ์๋ฒฝํ ํด๊ฒฐ๋์๋์ง ์ถ๊ฐ ๊ฒ์ฆ์ด ํ์ํ ์ ์์ต๋๋ค.\"\n}"
}
candidate_files์ tree_paths์์ '๋ถ์ํ ๊ฐ์น๊ฐ ์๋' ํ์ผ์ ์ต๋ 8๊ฐ๊น์ง ๊ณจ๋ผ๋ธ ๋ชฉ๋ก์ด๋ค. structure_summary๋ ํ๋ก์ ํธ์ ์ ์ฒด ํด๋ ๊ตฌ์กฐ๋ฅผ ์์ฝํ ๊ฒ์ด๋ค. structure_hints๋ src/App.tsx๊ฐ ์์ผ๋ฉด React + TypeScript์ธ ๊ฒ์ฒ๋ผ, ์ด๋ค ํ๋ก์ ํธ์ธ์ง๋ฅผ ๊ฒฐ์ ํ๋ ๋จ์(hint)๋ฅผ ๋ฝ์๋ธ๋ค. analysis_context๋ (๋์ฑ ์ ํํ) analysis_result ๋์ถ์ ์ํด LLM ๋ชจ๋ธ์๊ฒ ์ ๋ฌํ ํ๋กฌํํธ๋ฅผ ์์ฑํ๋ค. analysis_result๋ project_data์ analysis_summary๋ฅผ ๋ง๋ค๊ธฐ ์ํ ์ต์ข ์ฌ๋ฃ๋ค.
#5-4 [03] ํค์๋ ์ถ์ถ
# in main.py
...
def main():
...
tech_exp = TechExpertAgent(api_key=gemini_api_key, model=MODEL)
...
analyst_output = analyst.run(project_data)
...
tech_output = tech_exp.run(analyst_output)
...
project_data["tech_summary"] = tech_output.get("tech_summary")
...
...
tech_expert.py ๋ชจ๋์ ํธ์ถํด tech_output์ ๊ฒฐ๊ด๊ฐ์ผ๋ก ๋ฐ๋๋ค. tech_output์ tech_summary๋ ๋๊ฐ์ ์ด๋ฆ์ธ tech_summary๋ผ๋ key ์ด๋ฆ์ผ๋ก project_data์ ์ถ๊ฐ๋๋ค. tech_output์ ์์๋ ์๋์ ๊ฐ๋ค.
{
"status": "success",
"tech_summary": {
"tech_stack": {
"Backend": [
"Python (Notion API๋ฅผ ํ์ฉํ ์๋ํ ๋ก์ง ๊ตฌํ์ ์ ํฉํ ๊ฐ๋
์ฑ๊ณผ ํ๋ถํ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ ๊ณตํฉ๋๋ค)",
"SymPy (๋ณต์กํ ๋
ผ๋ฆฌ์(DNF ๋ณํ)์ ์ํ์ ์ผ๋ก ํด์ํ์ฌ Notion API์ ํํฐ ์ ์ฝ์ ์ ์ฐํ๊ฒ ํด๊ฒฐํฉ๋๋ค)"
],
"Database": [
"Notion Database (๊ตฌ์กฐํ๋ ๋ฐ์ดํฐ๋ฅผ API๋ก ์์ฝ๊ฒ ๊ด๋ฆฌํ๊ณ ์๊ฐํํ ์ ์๋ ์ ์ฐํ ๋
ธ์ฝ๋ DB๋ฅผ ํ์ฉํฉ๋๋ค)"
],
"DevOps": [
"GitHub Actions (๋ณ๋์ ์๋ฒ ๊ตฌ์ถ ์์ด๋ ํด๋ผ์ฐ๋ ํ๊ฒฝ์์ ์ค์ผ์ค๋ง๋ ์๋ํ ์์
์ ์์ ์ ์ผ๋ก ์คํํฉ๋๋ค)"
]
},
"architecture_summary": "GitHub Actions๋ฅผ ํตํด ๋งค์ผ ํน์ ์๊ฐ์ ๊ตฌ๋๋๋ Python ๊ธฐ๋ฐ์ ์คํฌ๋ฆฝํธ๊ฐ Notion REST API๋ฅผ ํ์ฉํ์ฌ ์์ค ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ํํฐ๋ง ๋ ํ์ด์ง๋ฅผ ํ๊ฒ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ๋ณต์ ํ๊ณ , ์์ฑ๋ ํ์ด์ง๋ฅผ ์ํธ ์ฐธ์กฐ(Patch)ํ์ฌ ๋๊ธฐํํ๋ ์๋ํ ์์คํ
์
๋๋ค.",
"mermaid_code": "graph TD\n classDef backend fill:#D4E6F1,stroke:#3498DB,stroke-width:2px;\n classDef external fill:#FADBD8,stroke:#E74C3C,stroke-width:2px;\n classDef storage fill:#D1F2EB,stroke:#2ECC71,stroke-width:2px;\n classDef user fill:#FCF3CF,stroke:#F1C40F,stroke-width:2px;\n\n github_action[\"GitHub Actions (Scheduler)\"]:::external\n main_py[\"main.py (Automation Logic)\"]:::backend\n notion_api[\"Notion REST API\"]:::external\n notion_db_source[\"Source Notion Database\"]:::storage\n notion_db_target[\"Target Notion Database\"]:::storage\n\n github_action -- \"๋งค์ผ ์ ์ ์คํ\" --> main_py\n main_py -- \"๋ฐ์ดํฐ ์กฐํ (Read)\" --> notion_db_source\n main_py -- \"API ์์ฒญ\" --> notion_api\n notion_api -- \"๊ฒฐ๊ณผ ๋ฐํ\" --> main_py\n main_py -- \"๋ฐ์ดํฐ ๋ณต์ /์์ฑ (Create/Update)\" --> notion_db_target",
"key_points": [
"SymPy๋ฅผ ์ด์ฉํ ํํฐ ์ฟผ๋ฆฌ DNF(์ด์ฅ๊ท์ ํ) ๋ณํ์ผ๋ก Notion API์ ๋ณต์กํ ์ค์ฒฉ ํํฐ ์ ํ ์ฐํ",
"Rate Limit ๋์์ ์ํด ์ง์ ๋ฐฑ์คํ ์ ๋ต ๋ฐ 429 ์๋ฌ ํธ๋ค๋ง ๋ก์ง ๊ตฌํ",
"GitHub Actions ํ๊ฒฝ์ UTC ํ์์กด ๋ฌธ์ ๋ฅผ KST๋ก ๋ช
์ํ์ฌ ๋ ์ง ๊ธฐ๋ฐ ์ค์ผ์ค๋ง ์ ํ๋ ํ๋ณด",
"ํด๋์ค ๊ธฐ๋ฐ์ Property ๋น๋๋ฅผ ์ฌ์ฉํ์ฌ Notion์ ๋ณต์กํ JSON ๊ตฌ์กฐ๋ฅผ ๊ฐ๋
์ฑ ๋์ ์ฒด์ด๋ ํจํด์ผ๋ก ๋จ์ํ"
]
}
}
์ฌ์ค์ tech_summary == tech_output์ด๋ผ๊ณ ํ ์ ์๊ฒ ๋ค (status ๋ฌด์).
#5-5 [04] README.md ์ ์
# in main.py
...
def main():
...
writer = WriterAgent(api_key=gemini_api_key, model=MODEL, mode="portfolio")
...
writer_output = writer.run(project_data)
...
final_readme = writer_output.get("readme_markdown")
...
...
writers.py ๋ชจ๋์ ํธ์ถํด writer_output์ ๊ฒฐ๊ด๊ฐ์ผ๋ก ๋ฐ๋๋ค. writer_output์ ์ง๊ธ๊น์ง ๋ง๋ project_data๋ฅผ ํตํด, REAMD.md๋ฅผ ๋ง๋ค์ด๋ธ๋ค. writer_output์ ์์๋ ์๋์ ๊ฐ๋ค.
{
"status": "success",
"readme_markdown": "# Notion Auto Clone\n\nNotion ๋ฐ์ดํฐ๋ฒ ์ด์ค ๊ฐ์ ๋ฐ์ดํฐ๋ฅผ ์๋์ผ๋ก ๋๊ธฐํํ๊ณ ๋ณต์ ํ๋ ํ์ด์ฌ ๊ธฐ๋ฐ์ ์๋ํ ๋๊ตฌ์
๋๋ค. ๋ณต์กํ ํํฐ๋ง ๋ก์ง๊ณผ Notion API์ ์ ์ฝ ์ฌํญ์ ํจ์จ์ ์ผ๋ก ํด๊ฒฐํ์ฌ ๋ฐ์ดํฐ ๊ด๋ฆฌ์ ์์ฐ์ฑ์ ๋์ด๋ ๋ฐ ๋ชฉ์ ์ด ์์ต๋๋ค.\n\n## ํ๋ก์ ํธ ์๊ฐ\n๋ณธ ํ๋ก์ ํธ๋ Notion ๋ฐ์ดํฐ๋ฒ ์ด์ค ๋ด์ ํน์ ํ์ด์ง๋ฅผ ์๋์ผ๋ก ์ถ์ถํ์ฌ ๋ค๋ฅธ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ก ๋ณต์ ํ๊ณ ๊ด๋ฆฌํ๋ ์๋ํ ์คํฌ๋ฆฝํธ์
๋๋ค. GitHub Actions๋ฅผ ํ์ฉํด ๋ณ๋์ ์๋ฒ ๊ตฌ์ถ ์์ด ์ฃผ๊ธฐ์ ์ธ ๋ฐ์ดํฐ ๋๊ธฐํ ํ์ดํ๋ผ์ธ์ ๊ตฌ์ถํ ์ ์์ต๋๋ค.\n\n## ์ฃผ์ ๊ธฐ๋ฅ\n- **์๋ํ๋ ๋ฐ์ดํฐ ๋๊ธฐํ**: ์์ค DB์์ ์กฐ๊ฑด์ ๋ง๋ ๋ฐ์ดํฐ๋ฅผ ์ถ์ถํ์ฌ ํ๊ฒ DB๋ก ๋ณต์ .\n- **๋ณต์กํ ํํฐ ์ฟผ๋ฆฌ ์ฒ๋ฆฌ**: SymPy๋ฅผ ์ด์ฉํ DNF(์ด์ฅ๊ท์ ํ) ๋ณํ์ผ๋ก Notion API์ ํํฐ ์ค์ฒฉ ์ ํ ์ฐํ.\n- **API ์์ ์ฑ ํ๋ณด**: ์ง์ ๋ฐฑ์คํ ์ ๋ต ๋ฐ 429 ์๋ฌ(Rate Limit) ๋์ ๋ก์ง ๊ตฌํ.\n- **์ง๊ด์ ์ธ ์์ฑ ๊ด๋ฆฌ**: ํด๋์ค ๊ธฐ๋ฐ ๋น๋ ํจํด์ผ๋ก Notion์ ๋ณต์กํ JSON ๊ตฌ์กฐ๋ฅผ ๊ฐ๋
์ฑ ์๊ฒ ์์ฑ.\n\n## ์์คํ
์ํคํ
์ฒ\n```mermaid\ngraph TD\n classDef backend fill:#D4E6F1,stroke:#3498DB,stroke-width:2px;\n classDef external fill:#FADBD8,stroke:#E74C3C,stroke-width:2px;\n classDef storage fill:#D1F2EB,stroke:#2ECC71,stroke-width:2px;\n\n github_action[\"GitHub Actions (Scheduler)\"]:::external\n main_py[\"main.py (Automation Logic)\"]:::backend\n notion_api[\"Notion REST API\"]:::external\n notion_db_source[\"Source Notion Database\"]:::storage\n notion_db_target[\"Target Notion Database\"]:::storage\n\n github_action -- \"๋งค์ผ ์ ์ ์คํ\" --> main_py\n main_py -- \"๋ฐ์ดํฐ ์กฐํ (Read)\" --> notion_db_source\n main_py -- \"API ์์ฒญ\" --> notion_api\n notion_api -- \"๊ฒฐ๊ณผ ๋ฐํ\" --> main_py\n main_py -- \"๋ฐ์ดํฐ ๋ณต์ /์์ฑ (Create/Update)\" --> notion_db_target\n```\n\n## ํ๋ก์ ํธ ๊ตฌ์กฐ ๋ฐ ํต์ฌ ํ์ผ\n```text\n.\nโโโ main.py # ํ๋ก์ ํธ ์ง์
์ , ๋ฐ์ดํฐ ์กฐํ ๋ฐ ๋๊ธฐํ ํ๋ฆ ์ ์ด\nโโโ custom_filter.py # ์ฌ์ฉ์์ ๋น์ฆ๋์ค ๋ก์ง(๋ฐ๋ณต ์ผ์ ๋ฑ)์ ๋ฐ๋ฅธ ํํฐ ์ ์ \nโโโ notion/\nโ โโโ api.py # Notion REST API ํต์ ๋ฐ ์๋ฌ ํธ๋ค๋ง\nโ โโโ filter_compiler.py # ๋ณต์กํ ํํฐ ์กฐ๊ฑด์ API ํ์์ผ๋ก ๋ณํ\nโ โโโ property.py # Notion ์์ฑ JSON ๋น๋ ํด๋์ค\n```\n\n## ๊ธฐ์ ์คํ\n- **Backend**: Python (๊ฐ๋
์ฑ๊ณผ ํ๋ถํ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ํตํ API ์๋ํ ๊ตฌํ)\n- **Library**: SymPy (๋ณต์กํ ๋
ผ๋ฆฌ์ ํด์ ๋ฐ Notion API ํํฐ ์ ์ฝ ํด๊ฒฐ)\n- **Database**: Notion Database (์ ์ฐํ ๊ตฌ์กฐํ์ API ์ ๊ทผ์ฑ์ ๊ฐ์ถ ๋
ธ์ฝ๋ DB)\n- **DevOps**: GitHub Actions (์๋ฒ ๋น์ฉ ์์ด ํด๋ผ์ฐ๋ ํ๊ฒฝ์์ ์๋ํ ์์
์ค์ผ์ค๋ง)\n\n## ๊ธฐ์ ์ ํ ์ด์ \n- **Python**: API ์ฐ๋ ๋ฐ ๋ฐ์ดํฐ ์ฒ๋ฆฌ ๋ก์ง์ ์ง๊ด์ ์ผ๋ก ์์ฑํ๊ธฐ์ ๊ฐ์ฅ ํจ์จ์ ์
๋๋ค.\n- **SymPy**: ์ค์ฒฉ ํํฐ ์ ์ฝ์ ์ํ์ ์ผ๋ก ์ต์ ํํ์ฌ ๋ณต์กํ ๋น์ฆ๋์ค ์กฐ๊ฑด ์ฒ๋ฆฌ๋ฅผ ๊ฐ๋ฅํ๊ฒ ํฉ๋๋ค.\n- **GitHub Actions**: ํด๋ผ์ฐ๋ ๊ธฐ๋ฐ์ ๊ฐํธํ CI/CD ํ๊ฒฝ์ ์ ๊ณตํ์ฌ ์ค์ผ์ค๋ง์ ์์ ์ ์ผ๋ก ์ด์ํ ์ ์์ต๋๋ค.\n\n## ์คํ ๋ฐฉ๋ฒ\n*์ถ๊ฐ ์์ฑ ํ์* (ํ์ฌ ์ ์ฅ์ ๋ด ๊ตฌ์ฒด์ ์ธ ์คํ ๊ฐ์ด๋๊ฐ ํฌํจ๋์ด ์์ง ์์ต๋๋ค.)\n\n## ๊ฐ์ ๋ฐฉํฅ ๋ฐ ์ฐธ๊ณ ์ฌํญ\n- **์ํ ๋๊ธฐํ**: `custom_patch.py` ๋ด์ `source_page_patch` ๋ฐ `target_page_patch` ํจ์๊ฐ ๊ตฌํ๋์ง ์์ ์ํ๋ก ํ์ธ๋ฉ๋๋ค. ํฅํ ํ์ด์ง ๋ณต์ ์ดํ ์๋ฐฉํฅ ์ํ ์
๋ฐ์ดํธ ๊ธฐ๋ฅ ์ถ๊ฐ๊ฐ ํ์ํฉ๋๋ค.\n- **ํํฐ ๊ฒ์ฆ**: ์ฌ์ฉ์ ์ ์ ํํฐ์ ๋ณต์ก๋์ ๋ฐ๋ผ Notion API์ ์ต๋ ๊น์ด ์ ํ ์ด์๊ฐ ์๋ฒฝํ ๋์๋๋์ง ์ถ๊ฐ์ ์ธ ํ
์คํธ๊ฐ ํ์ํ ์ ์์ต๋๋ค.\n\n---\n*๋ณธ ํ๋ก์ ํธ๋ ๊ฐ์ธ์ ์ธ ์๋ํ ํธ์์ฑ์ ์ํด ์ค๊ณ๋์์ผ๋ฉฐ, ๋น์ฆ๋์ค ์๊ตฌ์ฌํญ์ ๋ง์ถฐ ํ์ฅ ๊ฐ๋ฅํ๋๋ก ๋ชจ๋ํ๋์ด ์์ต๋๋ค.*",
"saved_path": "outputs/README.generated.md",
"section_check": {
"ํ๋ก์ ํธ ์๊ฐ": true,
"์ฃผ์ ๊ธฐ๋ฅ": true,
"ํ๋ก์ ํธ ๊ตฌ์กฐ": true,
"ํต์ฌ ํ์ผ ์ค๋ช
": false,
"๊ธฐ์ ์คํ": true,
"์์คํ
์ํคํ
์ฒ": true,
"์คํ ๋ฐฉ๋ฒ": true,
"๊ธฐ์ ์ ํ ์ด์ ": true,
"๊ฐ์ ๋ฐฉํฅ": true
}
}
readme_markdown์ README.md ํ์ผ์ ๋ด์ฉ์ด๋ค. saved_path๋ README.md ํ์ผ์ด ์ ์ฅ๋ ์์น๋ฅผ ๋ช ์ํ๋ค. section_check๋ README.md ํ์ผ์ ๋ด์ฉ์ด ์ ๋ค์ด๊ฐ ์๋์ง ํ์ธํ๋, hook์ ์ญํ ์ ์ํํ๋ค.
#5-6 [05] Pull Request ์ ์ก
# in main.py
...
def main():
...
final_readme = writer_output.get("readme_markdown")
...
publish_result = repo_mgr.publish_readme(TARGET_REPO, final_readme)
...
repo_manager.py ๋ชจ๋์ ํธ์ถํด Pull Request๋ฅผ ๋ณด๋ธ๋ค. repo_manager.py๋ ํ์ดํ๋ผ์ธ์ ์์(1๋จ๊ณ)๊ณผ ๋(5๋จ๊ณ)์์ ๊ฐ๊ฐ ์ฐ์๋ค. publish_result์ ์์๋ ์๋์ ๊ฐ๋ค.
{
"status": "success",
"pr_url": "https://github.com/Kanmanemone/notion-auto-clone/pull/3"
}
์ฐธ์กฐ: ์ด ํ๋ก๊ทธ๋จ์ ํตํด ์ค์ ๋ก ์ ์ก๋ Pull Request
#6 ๋์ ์๊ฐ
#6-1 General AI Model๊ณผ์ ๋น๊ต
README.md ์์ฑ์ GPT, Claude๊ฐ์ General AI Model์๊ฒ '์ํค๋ฉด' ๋๋ ๊ฑฐ ์๋๊ฐ? ์ด ํ๋ก๊ทธ๋จ์ ์์๋ ์กด์ฌํ ๊น? ์กด์ฌํ๋ค๊ณ ๋ณธ๋ค. ๋ฒ์ฉ LLM์ด '์ ๋ง ๋ง๋ง ํ๋ฉด ๋ค ํด์ฃผ๋' ์์ค๊น์ง ๋๋ฌํ๋ ๋ฏธ๋๊ฐ ์๋๋ผ๋ฉด ๋ง์ด๋ค. ์ด๋ ๊ฒ ์ข์ ๋ถ์ผ์์๋ผ๋ฉด ์ถฉ๋ถํ ๊ฒฝ์๋ ฅ์ด ์๋ค. ๋๋ ์์ง ๋ง๋ง ๋ณธ ๊ฒ ์ ๋ถ์ง๋ง, ํ๋ค์ค ์์ง๋์ด๋ง์ ๊ฐ๋ (hook, #5-5)๋ ๋ค์ด๊ฐ ์์ด ์ข์๋ค. ์ ๋ฐ ์์ผ๋ก ์ฐ๋ฉด ๋๊ฒ ๊ตฌ๋ ์ถ์๋ค. ๊ฒฐ๊ตญ ์ด๋ ์์ ๋ถํฐ๋, ๋ฒ์ฉ LLM์ ์ ๋นํ ์ปค์คํ ํ๋ค์ค๋ฅผ ์์ด ๊ฒ์ด ๋๋ถ๋ถ์ ํ๋ก๊ทธ๋จ๋ณด๋ค ๋์ ๋ ๋ ์ค๊ฒ ๋ค๋ ์๊ฐ๋ ๋ ๋ค. ๊ฐ๋ฐ์๋ก์๋ ์ฌ๋ฉํ๋ค. ํ์ง๋ง ๊ฐ๋ฐ์ ์ด์ ์ ์ฌ์ฉ์๋ก์ ๊ธฐ๋๋ ๋๋ค.
#6-3 ์คํ์์ ๋์๋ฆฌ
์์ค ์ฝ๋์ (์๋ ๋ณ์๋ฅผ ํธ์ถํ๋ค๋ ๊ฐ ํ๋ ์์) ์คํ์๊ฐ ๊ฝค ์์ด์ ์ง์ ๊ณ ์น ํ ๋ถ์ํด์ผ ํ๋ค. ๊ทธ๋๋, ์๋ณธ ํ๋ก๊ทธ๋จ ์์ฒด๋ ๊ณ ์น์ง ์์๋ ์ ๋์๊ฐ๊ธฐ๋ ํ๋ค. ๊ทธ๋ฆฌ๊ณ ์ด ๋ถ๋ถ์ ๋ด๊ฐ "๋ชปํ๋ค"๋ผ๊ณ ํํํ๊ธด ์ด๋ ต๋ค. ์๋ํ๋ฉด ์ ๋์ ์๊ฐ์ด ๋ถ์กฑํด ๋ณด์๊ธฐ ๋๋ฌธ์ด๋ค. ๋ฐํ์(์ฐ์ฌ)๋ 4๋ช ์ผ๋ก ์ด๋ฃจ์ด์ง ์ฝ๋ฉ ๋์๋ฆฌ์๋๋ฐ, 1ํ๊ธฐ ์ค๊ฐ๊ณ ์ฌ ๊ธฐ๊ฐ ๋์ค ์งฌ์ ๋ด์ ๋ง๋ ๊ฒ ๊ฐ์๋ค.
๋ง์ ์ฌ๋์ด ๋ชจ์ด๋ ๋ฌด๋์์ ์ง์ ๋ง๋ ํ๋ก๊ทธ๋จ์ ๋ณด์ฌ์ฃผ๋ ๊ฒ ์ ๋ง ๋ฉ์ ธ ๋ณด์๋ค. ์ฝ๊ฐ์ ๋ถ๋ฝ๊ธฐ๊น์ง ํ๋ค. ๋ด๊ฐ ๋ํ์ ๋ ์์๋ ๋์๋ฆฌ๋ ๋์ค ๋์๋ฆฌ์๋๋ฐ, ๊ทธ๊ฒ ํํ๋ ์ ์ ํ๋ฒ๋ ์์๋ค... ๊ทธ๋๋ก ๋์๊ฐ๋ค๋ฉด && ํ๋ฃจ๊ฐ 48์๊ฐ์ด๋ผ๋ฉด ์ถค ๋์๋ฆฌ์ ๋ํด ์ฝ๋ฉ ๋์๋ฆฌ๊น์ง ๊ฐ์ ํด์ผ๊ฒ ๋ค.
#6-4 ๋ค์ํ ๋๋ฉ์ธ ์ง์
#4์ ์๋ ๋ด์ฉ์ ๋ฐฐ์ธ ์ ์์ด์ ์ข์๋ค. ์ฝํผ๋ฐ์ค๊ฐ ์ข์ ์ ์ด ์ด๊ฑฐ๋ค. ๋ด๊ฐ ๋ชจ๋ฅด๋ ๊ฑธ ์๋ ์ฌ๋์ด, ์น์ ํ๊ณ ์ด์ ์ ์ผ๋ก ๊ทธ ๋ด์ฉ์ ์๋ ค์ค๋ค๋ ๊ฒ.