๊ฒฝํ—˜ ๐ŸŒ/์ฝ˜ํผ๋Ÿฐ์Šค

2026 Build with AI: Hands-on Campus - ๋‚ด GitHub ํ”„๋กœ์ ํŠธ ์ €์žฅ์†Œ๋ฅผ ๋ถ„์„ํ•˜๊ณ  ์ •๋ฆฌํ•˜๋Š” AI ์—์ด์ „ํŠธ ๋งŒ๋“ค๊ธฐ

interfacer_han 2026. 7. 10. 18:56

#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์— ์žˆ๋Š” ๋‚ด์šฉ์„ ๋ฐฐ์šธ ์ˆ˜ ์žˆ์–ด์„œ ์ข‹์•˜๋‹ค. ์ฝ˜ํผ๋Ÿฐ์Šค๊ฐ€ ์ข‹์€ ์ ์ด ์ด๊ฑฐ๋‹ค. ๋‚ด๊ฐ€ ๋ชจ๋ฅด๋Š” ๊ฑธ ์•„๋Š” ์‚ฌ๋žŒ์ด, ์นœ์ ˆํ•˜๊ณ  ์—ด์ •์ ์œผ๋กœ ๊ทธ ๋‚ด์šฉ์„ ์•Œ๋ ค์ค€๋‹ค๋Š” ๊ฒƒ.