From 330f5fa89e756b1b283a96c564c27cdc81f463e0 Mon Sep 17 00:00:00 2001 From: Interludeal Date: Fri, 12 Jun 2026 15:08:15 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EB=A6=AC=EB=B7=B0=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/auto-code-review.yml | 140 +++++ Dynamic_RAG/README.md | 34 +- holding.py | 2 +- scripts/review_pr.py | 713 +++++++++++++++++++++++++ 4 files changed, 871 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/auto-code-review.yml create mode 100644 scripts/review_pr.py diff --git a/.github/workflows/auto-code-review.yml b/.github/workflows/auto-code-review.yml new file mode 100644 index 00000000..2fe10c7d --- /dev/null +++ b/.github/workflows/auto-code-review.yml @@ -0,0 +1,140 @@ +name: πŸ” PR Code Review + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: + - develop + - main + +permissions: + contents: read + pull-requests: write + +jobs: + code-review: + name: μ½”λ“œ λ³€κ²½ 뢄석 및 PM 리뷰 리포트 생성 + runs-on: ubuntu-latest + + steps: + # ── 전체 νžˆμŠ€ν† λ¦¬ 포함 체크아웃 ───────── + - name: Checkout (full history) + uses: actions/checkout@v4 + with: + fetch-depth: 0 # diff μΆ”μΆœμ„ μœ„ν•΄ 전체 νžˆμŠ€ν† λ¦¬ ν•„μš” + + # ── Python 및 뢄석 도ꡬ μ„€μΉ˜ ───────────── + - name: Python Setup + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Analysis Tools + run: | + pip install pyflakes --quiet + + # ── PR 메타 정보 ν™˜κ²½λ³€μˆ˜λ‘œ μ„ΈνŒ… ───────── + - name: Set Review Environment + run: | + echo "REVIEW_BASE_SHA=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_ENV" + echo "REVIEW_HEAD_SHA=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_ENV" + echo "REVIEW_BRANCH=${{ github.head_ref }}" >> "$GITHUB_ENV" + echo "PR_NUMBER=${{ github.event.pull_request.number }}" >> "$GITHUB_ENV" + + # base 브랜치λ₯Ό remoteμ—μ„œ κ°€μ Έμ™€μ„œ diff 기쀀점 확보 + git fetch origin ${{ github.event.pull_request.base.ref }} --depth=1 + + # ── 핡심 뢄석 μ‹€ν–‰ ─────────────────────── + - name: Run Code Review Analysis + id: analysis + run: | + python scripts/review_pr.py + continue-on-error: true # BLOCK이어도 μ½”λ©˜νŠΈλŠ” κ²Œμ‹œν•΄μ•Ό ν•˜λ―€λ‘œ + + # ── PR μ½”λ©˜νŠΈλ‘œ κ²°κ³Ό κ²Œμ‹œ ──────────────── + - name: Post Review Comment + if: always() + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + + // ── Markdown 리포트 읽기 ── + let body = ''; + try { + body = fs.readFileSync('pr_review_result.md', 'utf8'); + } catch { + body = '## ⚠️ μ½”λ“œ 리뷰 μ‹€ν–‰ μ‹€νŒ¨\n\n뢄석 슀크립트 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€. Actions 둜그λ₯Ό ν™•μΈν•˜μ„Έμš”.'; + } + + // ── 인라인 μ½”λ©˜νŠΈ μΆ”κ°€ (BLOCK / HIGH 이슈) ── + let reviewData = { issues: [] }; + try { + reviewData = JSON.parse(fs.readFileSync('pr_review_result.json', 'utf8')); + } catch {} + + const headSha = context.payload.pull_request.head.sha; + const inlineIssues = (reviewData.issues || []) + .filter(i => ['BLOCK', 'HIGH'].includes(i.severity) && i.line > 0); + + for (const issue of inlineIssues.slice(0, 20)) { + try { + await github.rest.pulls.createReviewComment({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + commit_id: headSha, + path: issue.file, + line: issue.line, + body: [ + `**${issue.severity === 'BLOCK' ? '🚫' : 'πŸ”΄'} [${issue.severity}] ${issue.category}**`, + issue.message + ].join('\n') + }); + } catch (e) { + // 라인이 diff λ²”μœ„λ₯Ό λ²—μ–΄λ‚˜λ©΄ 인라인 μ½”λ©˜νŠΈ μ‹€νŒ¨ β€” λ¬΄μ‹œ + } + } + + // ── κΈ°μ‘΄ 봇 μ½”λ©˜νŠΈ μ—…λ°μ΄νŠΈ or μ‹ κ·œ 생성 ── + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => + c.user.type === 'Bot' && c.body.includes('PR μ½”λ“œ 리뷰 β€”') + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + # ── GitHub Actions Step Summary ────────── + - name: Write Step Summary + if: always() + run: | + if [ -f pr_review_result.md ]; then + cat pr_review_result.md >> "$GITHUB_STEP_SUMMARY" + fi + + # ── BLOCK νŒμ • μ‹œ μ›Œν¬ν”Œλ‘œμš° μ‹€νŒ¨ 처리 ── + - name: Fail on BLOCK + if: steps.analysis.outcome == 'failure' + run: | + echo "::error title=μ½”λ“œ 리뷰 BLOCK::μžλ™ λΆ„μ„μ—μ„œ Mergeλ₯Ό 차단할 μ΄μŠˆκ°€ λ°œκ²¬λμŠ΅λ‹ˆλ‹€. PR μ½”λ©˜νŠΈλ₯Ό ν™•μΈν•˜μ„Έμš”." + exit 1 diff --git a/Dynamic_RAG/README.md b/Dynamic_RAG/README.md index 7724af1f..470d5fcb 100644 --- a/Dynamic_RAG/README.md +++ b/Dynamic_RAG/README.md @@ -2,7 +2,7 @@ Chrome ν™•μž₯ ν”„λ‘œκ·Έλž¨ ZIP/디렉토리λ₯Ό μž…λ ₯λ°›μ•„ JSΒ·HTMLΒ·DNR μ½”λ“œλ₯Ό μ •μ μœΌλ‘œ λΆ„μ„ν•˜κ³ , μž„λ² λ”© 및 μ‹œλ‚˜λ¦¬μ˜€ 맀칭에 μ‚¬μš©ν•  `vector_fingerprint.json`을 μƒμ„±ν•©λ‹ˆλ‹€. -LLM 호좜, μž„λ² λ”© μˆ˜ν–‰, Vector DB μ €μž₯/검색은 이 λͺ¨λ“ˆμ˜ λ²”μœ„κ°€ μ•„λ‹™λ‹ˆλ‹€. 순수 정적 뢄석 기반 JSON μƒμ„±λ§Œ μˆ˜ν–‰ν•©λ‹ˆλ‹€. +LLM 호좜, μž„λ² λ”© μˆ˜ν–‰, Vector DB μ €μž₯/검색은 이 λͺ¨λ“ˆμ˜ λ²”μœ„κ°€ μ•„λ‹™λ‹ˆλ‹€. 순수 정적 뢄석 기반 JSON μƒμ„±λ§Œ μˆ˜ν–‰ν•©λ‹ˆλ‹€. κ·Έλ ‡μŠ΅λ‹ˆλ‹€. ## 디렉토리 ꡬ쑰 @@ -99,25 +99,25 @@ python3 -m rag_fingerprint.main extract --output out/samp ## 탐지 μ‹ ν˜Έ μΉ΄ν…Œκ³ λ¦¬ (code_scanner.py) -| μΉ΄ν…Œκ³ λ¦¬ | μ˜ˆμ‹œ μ‹ ν˜Έ | -|----------|-----------| -| λ„€νŠΈμ›Œν¬ | `network.fetch`, `network.xhr`, `network.WebSocket`, `network.sendBeacon`, `network.axios` | -| λ©”μ‹œμ§• | `messaging.runtime.sendMessage`, `messaging.runtime.onMessage`, `messaging.tabs.sendMessage` | -| μŠ€ν† λ¦¬μ§€ | `storage.localStorage`, `storage.sessionStorage`, `storage.chrome.storage.local` | -| μ§€μ—° μ‹€ν–‰ | `delayed_execution.setInterval`, `delayed_execution.setTimeout` | -| 탐색 | `navigation.tabs.create`, `navigation.tabs.update`, `navigation.location.href` | -| 동적 μ‹€ν–‰ | `dynamic.eval`, `dynamic.new_function`, `dynamic.importScripts` | -| DOM | `dom.event.submit`, `dom.event.input`, `dom.selector.password`, `dom.selector.email` | +| μΉ΄ν…Œκ³ λ¦¬ | μ˜ˆμ‹œ μ‹ ν˜Έ | +| --------- | -------------------------------------------------------------------------------------------- | +| λ„€νŠΈμ›Œν¬ | `network.fetch`, `network.xhr`, `network.WebSocket`, `network.sendBeacon`, `network.axios` | +| λ©”μ‹œμ§• | `messaging.runtime.sendMessage`, `messaging.runtime.onMessage`, `messaging.tabs.sendMessage` | +| μŠ€ν† λ¦¬μ§€ | `storage.localStorage`, `storage.sessionStorage`, `storage.chrome.storage.local` | +| μ§€μ—° μ‹€ν–‰ | `delayed_execution.setInterval`, `delayed_execution.setTimeout` | +| 탐색 | `navigation.tabs.create`, `navigation.tabs.update`, `navigation.location.href` | +| 동적 μ‹€ν–‰ | `dynamic.eval`, `dynamic.new_function`, `dynamic.importScripts` | +| DOM | `dom.event.submit`, `dom.event.input`, `dom.selector.password`, `dom.selector.email` | ## Capability β†’ behavior_tags λ§€ν•‘ μ˜ˆμ‹œ -| μ‹ ν˜Έ μ‘°ν•© | behavior_tag | -|-----------|--------------| -| `content_script_run_at: document_start` | `early_injection` | -| `localStorage` + `network.fetch` | `session_theft_pattern`, `external_communication` | -| `runtime.sendMessage` | `message_passing_bridge` | -| `setInterval` | `repeated_exfiltration` | -| DNR `redirect` action | `redirect_hijacking`, `request_modification` | +| μ‹ ν˜Έ μ‘°ν•© | behavior_tag | +| --------------------------------------- | ------------------------------------------------- | +| `content_script_run_at: document_start` | `early_injection` | +| `localStorage` + `network.fetch` | `session_theft_pattern`, `external_communication` | +| `runtime.sendMessage` | `message_passing_bridge` | +| `setInterval` | `repeated_exfiltration` | +| DNR `redirect` action | `redirect_hijacking`, `request_modification` | ## vector λ²”μœ„ μ œμ™Έ κΈ°μ€€ diff --git a/holding.py b/holding.py index ddf041fe..8d78467a 100644 --- a/holding.py +++ b/holding.py @@ -37,6 +37,6 @@ async def holding( return { "status": "success", - "message": "파일 μˆ˜μ‹  및 홀딩 등둝 μ™„λ£Œ", + "message": "파일 μˆ˜μ‹  및 홀딩 등둝 μ™„λ£Œ μ™„λ£Œ", "holding_seconds": result["holding_seconds"], } diff --git a/scripts/review_pr.py b/scripts/review_pr.py new file mode 100644 index 00000000..cdb06948 --- /dev/null +++ b/scripts/review_pr.py @@ -0,0 +1,713 @@ +#!/usr/bin/env python3 +""" +PR μ½”λ“œ 리뷰 슀크립트 +──────────────────── +μ—­ν• : PR의 λ³€κ²½ μ½”λ“œλ₯Ό μ •λ°€ λΆ„μ„ν•˜μ—¬ PM이 Merge νŒλ‹¨μ— μ“Έ 수 μžˆλŠ” + κ΅¬μ‘°ν™”λœ 리포트λ₯Ό JSON + Markdown으둜 μƒμ„±ν•œλ‹€. + +뢄석 ν•­λͺ©: + 1. λ³€κ²½ μš”μ•½ β€” μ–΄λ–€ 파일이 μ–΄λ–»κ²Œ λ°”λ€Œμ—ˆλŠ”κ°€ + 2. μ‹ κ·œ 둜직 β€” μΆ”κ°€λœ ν•¨μˆ˜Β·ν΄λž˜μŠ€Β·μ—”λ“œν¬μΈνŠΈ λͺ©λ‘ + 3. μ‚­μ œ/μˆ˜μ • β€” κΈ°μ‘΄ μΈν„°νŽ˜μ΄μŠ€μ˜ λ³€κ²½Β·μ œκ±° μ—¬λΆ€ + 4. μ˜μ‘΄μ„± λ³€ν™” β€” requirements.txt μΆ”κ°€Β·μ‚­μ œΒ·λ²„μ „ λ³€κ²½ + 5. import 검증 β€” μƒˆλ‘œ μΆ”κ°€λœ importκ°€ requirements에 μžˆλŠ”μ§€ + 6. 정적 였λ₯˜ β€” pyflakes 기반 undefined/unused 심볼 + 7. λ³΅μž‘λ„ κ²½κ³  β€” ν•¨μˆ˜ 길이·쀑첩 κΉŠμ΄κ°€ μž„κ³„κ°’ 초과 μ—¬λΆ€ + 8. μœ„ν—˜ νŒ¨ν„΄ β€” eval/exec/subprocess/os.system/ν•˜λ“œμ½”λ”© μ‹œν¬λ¦Ώ + 9. μ’…ν•© νŒμ • β€” MERGE_READY / NEEDS_REVIEW / BLOCK +""" + +import ast +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass, field, asdict +from pathlib import Path +from typing import Optional + +# ────────────────────────────────────────────── +# 데이터 λͺ¨λΈ +# ────────────────────────────────────────────── + +@dataclass +class FileChange: + path: str + status: str # added | modified | deleted | renamed + additions: int = 0 + deletions: int = 0 + new_functions: list = field(default_factory=list) + removed_functions: list = field(default_factory=list) + new_classes: list = field(default_factory=list) + new_endpoints: list = field(default_factory=list) # FastAPI @app.get λ“± + new_imports: list = field(default_factory=list) + + +@dataclass +class DependencyChange: + package: str + before: Optional[str] # None = μ‹ κ·œ + after: Optional[str] # None = μ‚­μ œ + kind: str # added | removed | upgraded | downgraded + + +@dataclass +class Issue: + severity: str # BLOCK | HIGH | MEDIUM | LOW + category: str # static_error | risk_pattern | complexity | dependency | interface + file: str + line: int + message: str + + +@dataclass +class ReviewReport: + pr_number: str + base_sha: str + head_sha: str + branch: str + changed_files: list = field(default_factory=list) + dependency_changes: list= field(default_factory=list) + issues: list = field(default_factory=list) + new_symbols: dict = field(default_factory=dict) # {file: [func/class/endpoint]} + removed_symbols: dict = field(default_factory=dict) + verdict: str = "MERGE_READY" # MERGE_READY | NEEDS_REVIEW | BLOCK + verdict_reason: str = "" + summary: str = "" + + +# ────────────────────────────────────────────── +# Git μœ ν‹Έ +# ────────────────────────────────────────────── + +def run(cmd: str, check=False) -> tuple[str, int]: + r = subprocess.run(cmd, shell=True, capture_output=True, text=True) + return r.stdout.strip(), r.returncode + + +def get_env(key: str, default: str = "") -> str: + return os.environ.get(key, default) + + +def get_changed_files(base: str, head: str) -> list[dict]: + out, _ = run(f"git diff --name-status {base}...{head}") + files = [] + for line in out.splitlines(): + line = line.strip() + if not line: + continue + parts = line.split("\t") + status_raw = parts[0] + if status_raw.startswith("R"): # Renamed + path = parts[2] if len(parts) > 2 else parts[1] + status = "renamed" + else: + path = parts[1] if len(parts) > 1 else "" + status_map = {"A": "added", "M": "modified", "D": "deleted"} + status = status_map.get(status_raw, "modified") + if path: + files.append({"path": path, "status": status}) + return files + + +def get_file_at(sha: str, path: str) -> Optional[str]: + content, code = run(f"git show {sha}:{path} 2>/dev/null") + return content if code == 0 else None + + +def get_diff_stat(base: str, head: str, path: str) -> tuple[int, int]: + out, _ = run(f"git diff --numstat {base}...{head} -- {path}") + for line in out.splitlines(): + parts = line.split("\t") + if len(parts) >= 2: + try: + return int(parts[0]), int(parts[1]) + except ValueError: + pass + return 0, 0 + + +# ────────────────────────────────────────────── +# AST 뢄석 +# ────────────────────────────────────────────── + +def extract_symbols(source: str) -> dict: + """ν•¨μˆ˜Β·ν΄λž˜μŠ€Β·FastAPI μ—”λ“œν¬μΈνŠΈΒ·importλ₯Ό μΆ”μΆœν•œλ‹€.""" + result = { + "functions": [], + "classes": [], + "endpoints": [], + "imports": [], + } + if not source: + return result + try: + tree = ast.parse(source) + except SyntaxError: + return result + + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + # FastAPI μ—”λ“œν¬μΈνŠΈ 감지 (@app.get / @router.post λ“±) + for dec in node.decorator_list: + dec_str = ast.unparse(dec) if hasattr(ast, "unparse") else "" + if re.search(r'\.(get|post|put|delete|patch|websocket)\s*\(', dec_str): + result["endpoints"].append({ + "name": node.name, + "line": node.lineno, + "decorator": dec_str, + }) + result["functions"].append({ + "name": node.name, + "line": node.lineno, + "is_async": isinstance(node, ast.AsyncFunctionDef), + "args": [a.arg for a in node.args.args], + }) + + elif isinstance(node, ast.ClassDef): + result["classes"].append({ + "name": node.name, + "line": node.lineno, + }) + + elif isinstance(node, ast.Import): + for alias in node.names: + result["imports"].append(alias.name.split(".")[0]) + + elif isinstance(node, ast.ImportFrom): + if node.module: + result["imports"].append(node.module.split(".")[0]) + + return result + + +def diff_symbols(before: dict, after: dict) -> tuple[list, list]: + """(μƒˆλ‘œ μΆ”κ°€λœ 심볼, 제거된 심볼) λ°˜ν™˜""" + def names(sym_list): + return {s["name"] for s in sym_list} + + before_fns = names(before.get("functions", [])) + after_fns = names(after.get("functions", [])) + before_cls = names(before.get("classes", [])) + after_cls = names(after.get("classes", [])) + + added = sorted((after_fns - before_fns) | (after_cls - before_cls)) + removed = sorted((before_fns - after_fns) | (before_cls - after_cls)) + return added, removed + + +# ────────────────────────────────────────────── +# λ³΅μž‘λ„ / μœ„ν—˜ νŒ¨ν„΄ 뢄석 +# ────────────────────────────────────────────── + +RISK_PATTERNS = [ + (r"\beval\s*\(", "BLOCK", "eval() μ‚¬μš© β€” μ½”λ“œ μΈμ μ…˜ μœ„ν—˜"), + (r"\bexec\s*\(", "BLOCK", "exec() μ‚¬μš© β€” μ½”λ“œ μΈμ μ…˜ μœ„ν—˜"), + (r"os\.system\s*\(", "HIGH", "os.system() β€” subprocess둜 λŒ€μ²΄ ꢌμž₯"), + (r"subprocess\.call.*shell\s*=\s*True", + "HIGH", "shell=True subprocess β€” μΈμ μ…˜ μœ„ν—˜"), + (r"(?i)(password|secret|api_key|token)\s*=\s*['\"][^'\"]{6,}['\"]", + "HIGH", "ν•˜λ“œμ½”λ”©λœ μ‹œν¬λ¦Ώ μ˜μ‹¬"), + (r"pickle\.loads?\s*\(", "HIGH", "pickle.load β€” μ‹ λ’°ν•  수 μ—†λŠ” 데이터에 μœ„ν—˜"), + (r"__import__\s*\(", "MEDIUM", "__import__() 동적 μž„ν¬νŠΈ"), + (r"open\([^,)]+,\s*['\"]w['\"]", + "LOW", "파일 μ“°κΈ° β€” 경둜 검증 μ—¬λΆ€ 확인 ν•„μš”"), +] + +MAX_FUNC_LINES = 80 +MAX_NEST_DEPTH = 5 + + +def check_risk_patterns(source: str, path: str) -> list[Issue]: + issues = [] + for lineno, line in enumerate(source.splitlines(), 1): + for pattern, severity, msg in RISK_PATTERNS: + if re.search(pattern, line): + issues.append(Issue( + severity=severity, + category="risk_pattern", + file=path, + line=lineno, + message=msg, + )) + return issues + + +def check_complexity(source: str, path: str) -> list[Issue]: + issues = [] + try: + tree = ast.parse(source) + except SyntaxError: + return issues + + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + + # ν•¨μˆ˜ 길이 + end_line = getattr(node, "end_lineno", node.lineno) + length = end_line - node.lineno + if length > MAX_FUNC_LINES: + issues.append(Issue( + severity="MEDIUM", + category="complexity", + file=path, + line=node.lineno, + message=f"ν•¨μˆ˜ `{node.name}` 길이 {length}쀄 (ꢌμž₯ {MAX_FUNC_LINES}쀄 μ΄ν•˜)", + )) + + # 쀑첩 깊이 + depth = _max_depth(node) + if depth > MAX_NEST_DEPTH: + issues.append(Issue( + severity="LOW", + category="complexity", + file=path, + line=node.lineno, + message=f"ν•¨μˆ˜ `{node.name}` 쀑첩 깊이 {depth} (ꢌμž₯ {MAX_NEST_DEPTH} μ΄ν•˜)", + )) + + return issues + + +def _max_depth(node: ast.AST, current: int = 0) -> int: + branch_nodes = (ast.If, ast.For, ast.While, ast.With, + ast.Try, ast.ExceptHandler, ast.AsyncFor, ast.AsyncWith) + if isinstance(node, branch_nodes): + current += 1 + return max( + [current] + + [_max_depth(child, current) for child in ast.iter_child_nodes(node)] + ) + + +# ────────────────────────────────────────────── +# pyflakes 기반 정적 였λ₯˜ +# ────────────────────────────────────────────── + +def run_pyflakes(path: str) -> list[Issue]: + issues = [] + out, _ = run(f"python -m pyflakes {path} 2>&1") + for line in out.splitlines(): + m = re.match(r"(.+?):(\d+):\d+\s+(.*)", line) + if not m: + m = re.match(r"(.+?):(\d+)\s+(.*)", line) + if m: + msg = m.group(3) + # undefined name은 HIGH, unusedλŠ” LOW + sev = "HIGH" if "undefined name" in msg else "LOW" + issues.append(Issue( + severity=sev, + category="static_error", + file=path, + line=int(m.group(2)), + message=msg, + )) + return issues + + +# ────────────────────────────────────────────── +# μ˜μ‘΄μ„± 뢄석 +# ────────────────────────────────────────────── + +def parse_requirements(text: str) -> dict[str, str]: + pkgs = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + m = re.match(r"([A-Za-z0-9_\-\.]+)\s*(?:==|>=|<=|~=|!=)?\s*([\d\.]*)", line) + if m: + pkgs[m.group(1).lower()] = m.group(2) + return pkgs + + +def diff_requirements(before_text: Optional[str], after_text: Optional[str]) -> list[DependencyChange]: + before = parse_requirements(before_text or "") + after = parse_requirements(after_text or "") + changes = [] + + all_keys = set(before) | set(after) + for pkg in sorted(all_keys): + bv, av = before.get(pkg), after.get(pkg) + if bv is None and av is not None: + changes.append(DependencyChange(pkg, None, av, "added")) + elif av is None and bv is not None: + changes.append(DependencyChange(pkg, bv, None, "removed")) + elif bv != av: + kind = "upgraded" if av > bv else "downgraded" + changes.append(DependencyChange(pkg, bv, av, kind)) + return changes + + +def check_import_vs_requirements(new_imports: list[str], req_text: str) -> list[str]: + """requirements에 μ—†λŠ” μ‹ κ·œ import λ°˜ν™˜ (stdlib μ œμ™Έ)""" + stdlib = { + "os", "sys", "re", "json", "ast", "io", "abc", "copy", "math", + "time", "datetime", "pathlib", "typing", "dataclasses", "functools", + "itertools", "collections", "contextlib", "threading", "asyncio", + "subprocess", "shutil", "tempfile", "hashlib", "hmac", "base64", + "struct", "enum", "logging", "warnings", "traceback", "inspect", + "unittest", "argparse", "csv", "xml", "html", "http", "urllib", + "socket", "ssl", "email", "uuid", "random", "string", "textwrap", + "__future__", "builtins", "weakref", "gc", "platform", "signal", + "queue", "heapq", "bisect", "array", "decimal", "fractions", + "statistics", "cmath", "numbers", "operator", "types", "abc", + } + req_pkgs = parse_requirements(req_text) + missing = [] + for imp in new_imports: + norm = imp.lower().replace("-", "_") + if norm in stdlib: + continue + # requirementsμ—μ„œ μ°ΎκΈ° (νŒ¨ν‚€μ§€λͺ… μ •κ·œν™”) + if norm not in req_pkgs and norm.replace("_", "-") not in req_pkgs: + missing.append(imp) + return missing + + +# ────────────────────────────────────────────── +# 리포트 λ Œλ”λ§ +# ────────────────────────────────────────────── + +VERDICT_EMOJI = { + "MERGE_READY": "βœ…", + "NEEDS_REVIEW": "⚠️", + "BLOCK": "🚫", +} + +SEVERITY_EMOJI = { + "BLOCK": "🚫", + "HIGH": "πŸ”΄", + "MEDIUM": "🟠", + "LOW": "🟑", +} + +CATEGORY_KO = { + "static_error": "정적 였λ₯˜", + "risk_pattern": "μœ„ν—˜ νŒ¨ν„΄", + "complexity": "λ³΅μž‘λ„", + "dependency": "μ˜μ‘΄μ„±", + "interface": "μΈν„°νŽ˜μ΄μŠ€ λ³€κ²½", +} + + +def render_markdown(report: ReviewReport) -> str: + v_emoji = VERDICT_EMOJI.get(report.verdict, "❓") + lines = [ + f"## {v_emoji} PR μ½”λ“œ 리뷰 β€” `{report.branch}`", + "", + f"> **νŒμ •: {report.verdict}** β€” {report.verdict_reason}", + "", + ] + + # ── 1. λ³€κ²½ μš”μ•½ ────────────────────────── + lines += ["### πŸ“ λ³€κ²½ 파일 μš”μ•½", ""] + lines += ["| 파일 | μƒνƒœ | +μΆ”κ°€ | -μ‚­μ œ |", "|------|------|:-----:|:-----:|"] + for fc in report.changed_files: + status_ko = {"added": "μ‹ κ·œ", "modified": "μˆ˜μ •", "deleted": "μ‚­μ œ", "renamed": "이름변경"}.get(fc["status"], fc["status"]) + lines.append(f"| `{fc['path']}` | {status_ko} | +{fc.get('additions',0)} | -{fc.get('deletions',0)} |") + lines.append("") + + # ── 2. μ‹ κ·œ 심볼 ────────────────────────── + all_new = [] + for f, syms in report.new_symbols.items(): + for s in syms: + all_new.append((f, s)) + if all_new: + lines += ["### πŸ†• μ‹ κ·œ μΆ”κ°€λœ ν•¨μˆ˜ / 클래슀 / μ—”λ“œν¬μΈνŠΈ", ""] + for fpath, sym in all_new: + lines.append(f"- `{fpath}` β€” **{sym}**") + lines.append("") + + # ── 3. 제거된 심볼 ──────────────────────── + all_removed = [] + for f, syms in report.removed_symbols.items(): + for s in syms: + all_removed.append((f, s)) + if all_removed: + lines += ["### πŸ—‘οΈ 제거된 ν•¨μˆ˜ / 클래슀 (μΈν„°νŽ˜μ΄μŠ€ λ³€κ²½ 주의)", ""] + for fpath, sym in all_removed: + lines.append(f"- `{fpath}` β€” ~~{sym}~~") + lines.append("") + + # ── 4. μ˜μ‘΄μ„± λ³€ν™” ──────────────────────── + if report.dependency_changes: + lines += ["### πŸ“¦ μ˜μ‘΄μ„± λ³€ν™”", ""] + lines += ["| νŒ¨ν‚€μ§€ | λ³€ν™” | 이전 버전 | λ³€κ²½ ν›„ |", "|--------|------|:---------:|:-------:|"] + for dc in report.dependency_changes: + kind_ko = {"added": "βž• μΆ”κ°€", "removed": "βž– μ‚­μ œ", "upgraded": "⬆️ μ—…κ·Έλ ˆμ΄λ“œ", "downgraded": "⬇️ λ‹€μš΄κ·Έλ ˆμ΄λ“œ"}.get(dc["kind"], dc["kind"]) + lines.append(f"| `{dc['package']}` | {kind_ko} | {dc.get('before') or '-'} | {dc.get('after') or '-'} |") + lines.append("") + + # ── 5. 이슈 λͺ©λ‘ ────────────────────────── + if report.issues: + lines += ["### πŸ”Ž 발견된 이슈", ""] + # 심각도 순 μ •λ ¬ + order = {"BLOCK": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3} + sorted_issues = sorted(report.issues, key=lambda i: order.get(i["severity"], 9)) + + lines += ["| 심각도 | λΆ„λ₯˜ | 파일 | 쀄 | λ‚΄μš© |", "|:------:|------|------|----|------|"] + for iss in sorted_issues: + sev_str = f"{SEVERITY_EMOJI.get(iss['severity'],'')} {iss['severity']}" + cat_str = CATEGORY_KO.get(iss["category"], iss["category"]) + lines.append(f"| {sev_str} | {cat_str} | `{iss['file']}` | {iss['line']} | {iss['message']} |") + lines.append("") + + # ── 6. 전체 μš”μ•½ ────────────────────────── + lines += ["### πŸ“‹ PM νŒλ‹¨ μš”μ•½", ""] + lines.append(report.summary) + lines += [ + "", + "---", + "*πŸ€– μžλ™ 생성 β€” Code Review Pipeline (Static Analysis)*", + ] + return "\n".join(lines) + + +# ────────────────────────────────────────────── +# 메인 뢄석 흐름 +# ────────────────────────────────────────────── + +def determine_verdict(issues: list[dict], dep_changes: list[dict], + removed_symbols: dict) -> tuple[str, str]: + block_count = sum(1 for i in issues if i["severity"] == "BLOCK") + high_count = sum(1 for i in issues if i["severity"] == "HIGH") + medium_count = sum(1 for i in issues if i["severity"] == "MEDIUM") + removed_count = sum(len(v) for v in removed_symbols.values()) + removed_deps = [d for d in dep_changes if d["kind"] == "removed"] + downgraded = [d for d in dep_changes if d["kind"] == "downgraded"] + + reasons = [] + + if block_count > 0: + reasons.append(f"BLOCK 이슈 {block_count}건 (eval/exec/ν•˜λ“œμ½”λ”© μ‹œν¬λ¦Ώ λ“±)") + return "BLOCK", " | ".join(reasons) + + if high_count > 0: + reasons.append(f"HIGH 이슈 {high_count}건") + if removed_count > 0: + reasons.append(f"κΈ°μ‘΄ ν•¨μˆ˜Β·ν΄λž˜μŠ€ {removed_count}개 제거됨 (μΈν„°νŽ˜μ΄μŠ€ 파괴 μœ„ν—˜)") + if removed_deps: + reasons.append(f"μ˜μ‘΄μ„± {len(removed_deps)}개 μ‚­μ œ: {', '.join(d['package'] for d in removed_deps)}") + if downgraded: + reasons.append(f"μ˜μ‘΄μ„± λ‹€μš΄κ·Έλ ˆμ΄λ“œ {len(downgraded)}건") + + if reasons: + return "NEEDS_REVIEW", " | ".join(reasons) + + if medium_count > 3: + return "NEEDS_REVIEW", f"MEDIUM 이슈 {medium_count}건 λ‹€μˆ˜" + + return "MERGE_READY", "μ£Όμš” 이슈 μ—†μŒ" + + +def build_summary(report: ReviewReport) -> str: + fc_count = len(report.changed_files) + new_sym_count = sum(len(v) for v in report.new_symbols.values()) + rem_sym_count = sum(len(v) for v in report.removed_symbols.values()) + dep_count = len(report.dependency_changes) + block = sum(1 for i in report.issues if i["severity"] == "BLOCK") + high = sum(1 for i in report.issues if i["severity"] == "HIGH") + med = sum(1 for i in report.issues if i["severity"] == "MEDIUM") + low = sum(1 for i in report.issues if i["severity"] == "LOW") + + parts = [ + f"총 **{fc_count}개 파일** λ³€κ²½.", + f"μ‹ κ·œ 심볼 **{new_sym_count}개** μΆ”κ°€" + (f", κΈ°μ‘΄ 심볼 **{rem_sym_count}개 제거** (⚠️ ν˜Έν™˜μ„± κ²€ν†  ν•„μš”)" if rem_sym_count else "."), + ] + if dep_count: + parts.append(f"μ˜μ‘΄μ„± **{dep_count}건** λ³€κ²½.") + if block or high or med or low: + parts.append( + f"발견된 이슈: 🚫 BLOCK {block}건 / πŸ”΄ HIGH {high}건 / 🟠 MEDIUM {med}건 / 🟑 LOW {low}건." + ) + else: + parts.append("μžλ™ λΆ„μ„μ—μ„œ μ΄μŠˆκ°€ λ°œκ²¬λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.") + + return " ".join(parts) + + +def main(): + base_sha = get_env("REVIEW_BASE_SHA") or get_env("GITHUB_BASE_SHA") + head_sha = get_env("REVIEW_HEAD_SHA") or get_env("GITHUB_HEAD_SHA") or "HEAD" + branch = get_env("REVIEW_BRANCH") or get_env("GITHUB_HEAD_REF", "unknown") + pr_num = get_env("REVIEW_PR_NUMBER")or get_env("PR_NUMBER", "0") + + if not base_sha: + # fallback: PR baseλ₯Ό origin/develop으둜 + base_sha, _ = run("git merge-base origin/develop HEAD 2>/dev/null || git rev-parse HEAD~1") + if not base_sha: + print("❌ base SHAλ₯Ό κ²°μ •ν•  수 μ—†μŠ΅λ‹ˆλ‹€. REVIEW_BASE_SHA ν™˜κ²½λ³€μˆ˜λ₯Ό μ„€μ •ν•˜μ„Έμš”.") + sys.exit(1) + + print(f"\n{'='*60}") + print(f" πŸ” PR μ½”λ“œ 리뷰 뢄석 μ‹œμž‘") + print(f" 브랜치 : {branch}") + print(f" Base SHA: {base_sha[:12]}") + print(f" Head SHA: {head_sha[:12]}") + print(f"{'='*60}\n") + + report = ReviewReport( + pr_number=pr_num, + base_sha=base_sha, + head_sha=head_sha, + branch=branch, + ) + + # ── λ³€κ²½ 파일 λͺ©λ‘ ──────────────────────── + changed = get_changed_files(base_sha, head_sha) + print(f"[1/6] λ³€κ²½ 파일 감지: {len(changed)}개") + + py_files = [f for f in changed if f["path"].endswith(".py")] + req_files = [f for f in changed if "requirements" in f["path"] and f["path"].endswith(".txt")] + + # ── requirements μ˜μ‘΄μ„± diff ────────────── + print(f"[2/6] μ˜μ‘΄μ„± λ³€ν™” 뢄석: {len(req_files)}개 requirements 파일") + for rf in req_files: + before_text = get_file_at(base_sha, rf["path"]) + after_text = get_file_at(head_sha, rf["path"]) + dep_changes = diff_requirements(before_text, after_text) + for dc in dep_changes: + report.dependency_changes.append(asdict(dc)) + + # ── Python 파일 뢄석 ────────────────────── + print(f"[3/6] Python 파일 심볼 + μœ„ν—˜ νŒ¨ν„΄ 뢄석: {len(py_files)}개") + + all_new_imports = [] + + for fc in changed: + path = fc["path"] + status = fc["status"] + add, rem = get_diff_stat(base_sha, head_sha, path) + fc_record = { + "path": path, + "status": status, + "additions": add, + "deletions": rem, + } + + if path.endswith(".py") and status != "deleted": + before_src = get_file_at(base_sha, path) or "" + after_src = get_file_at(head_sha, path) or "" + + before_syms = extract_symbols(before_src) + after_syms = extract_symbols(after_src) + + added_syms, removed_syms = diff_symbols(before_syms, after_syms) + if added_syms: + report.new_symbols[path] = added_syms + if removed_syms: + report.removed_symbols[path] = removed_syms + # μΈν„°νŽ˜μ΄μŠ€ μ œκ±°λŠ” μ΄μŠˆλ‘œλ„ 등둝 + for sym in removed_syms: + report.issues.append(asdict(Issue( + severity="HIGH", + category="interface", + file=path, + line=0, + message=f"`{sym}` μ‚­μ œλ¨ β€” λ‹€λ₯Έ λͺ¨λ“ˆμ—μ„œ μ‚¬μš© 쀑인지 확인 ν•„μš”", + ))) + + # μ‹ κ·œ import μˆ˜μ§‘ (added/modified 파일만) + if status in ("added", "modified"): + before_imp = set(before_syms.get("imports", [])) + after_imp = set(after_syms.get("imports", [])) + new_imps = list(after_imp - before_imp) + all_new_imports.extend(new_imps) + + fc_record["new_endpoints"] = after_syms.get("endpoints", []) + + report.changed_files.append(fc_record) + + # ── import vs requirements 검증 ─────────── + print(f"[4/6] Import-Requirements μ •ν•©μ„± 검사") + req_content_after = "" + for rf in req_files: + rc = get_file_at(head_sha, rf["path"]) + if rc: + req_content_after += rc + "\n" + if not req_content_after: + # HEAD의 requirements.txt μ‚¬μš© + rc, _ = run("cat requirements.txt 2>/dev/null") + req_content_after = rc + + if all_new_imports and req_content_after: + missing = check_import_vs_requirements(all_new_imports, req_content_after) + for pkg in missing: + report.issues.append(asdict(Issue( + severity="HIGH", + category="dependency", + file="requirements.txt", + line=0, + message=f"`import {pkg}` μΆ”κ°€λμœΌλ‚˜ requirements.txt에 μ—†μŒ", + ))) + + # ── μœ„ν—˜ νŒ¨ν„΄ + λ³΅μž‘λ„ (λ³€κ²½λœ 파일만) ── + print(f"[5/6] μœ„ν—˜ νŒ¨ν„΄ / λ³΅μž‘λ„ 뢄석") + for fc in py_files: + if fc["status"] == "deleted": + continue + path = fc["path"] + # μž„μ‹œ 파일둜 μ €μž₯ν•΄μ„œ pyflakes μ‹€ν–‰ + after_src = get_file_at(head_sha, path) or "" + if not after_src: + continue + + tmp_path = f"/tmp/_review_{path.replace('/', '_')}" + Path(tmp_path).parent.mkdir(parents=True, exist_ok=True) + with open(tmp_path, "w", encoding="utf-8") as f: + f.write(after_src) + + risk_issues = check_risk_patterns(after_src, path) + complex_issues = check_complexity(after_src, path) + pyflakes_issues = run_pyflakes(tmp_path) + # pyflakes 결과의 파일λͺ…을 μ›λž˜ path둜 ꡐ정 + for iss in pyflakes_issues: + iss.file = path + + for iss in risk_issues + complex_issues + pyflakes_issues: + report.issues.append(asdict(iss)) + + try: + os.remove(tmp_path) + except OSError: + pass + + # ── νŒμ • ────────────────────────────────── + print(f"[6/6] μ’…ν•© νŒμ •") + verdict, reason = determine_verdict( + report.issues, + report.dependency_changes, + report.removed_symbols, + ) + report.verdict = verdict + report.verdict_reason = reason + report.summary = build_summary(report) + + # ── 좜λ ₯ ────────────────────────────────── + result_dict = asdict(report) + + with open("pr_review_result.json", "w", encoding="utf-8") as f: + json.dump(result_dict, f, ensure_ascii=False, indent=2) + + md = render_markdown(result_dict) + with open("pr_review_result.md", "w", encoding="utf-8") as f: + f.write(md) + + print(f"\n{'='*60}") + v_emoji = VERDICT_EMOJI.get(verdict, "❓") + print(f" {v_emoji} νŒμ •: {verdict}") + print(f" 이유: {reason}") + issue_count = len(report.issues) + print(f" 이슈: {issue_count}건") + print(f" κ²°κ³Ό 파일: pr_review_result.json / pr_review_result.md") + print(f"{'='*60}\n") + + # BLOCK이면 exit 1 β†’ GitHub Actionsμ—μ„œ Check μ‹€νŒ¨λ‘œ ν‘œμ‹œ + sys.exit(1 if verdict == "BLOCK" else 0) + + +if __name__ == "__main__": + main() \ No newline at end of file From b4497070be53dc44ff2277d5917569a6bee51a9c Mon Sep 17 00:00:00 2001 From: Interludeal Date: Fri, 12 Jun 2026 15:22:45 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20review=5Fpr=20UTF-16=20=EC=9D=B8?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/review_pr.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/scripts/review_pr.py b/scripts/review_pr.py index cdb06948..dd5bfb70 100644 --- a/scripts/review_pr.py +++ b/scripts/review_pr.py @@ -82,8 +82,15 @@ class ReviewReport: # ────────────────────────────────────────────── def run(cmd: str, check=False) -> tuple[str, int]: - r = subprocess.run(cmd, shell=True, capture_output=True, text=True) - return r.stdout.strip(), r.returncode + r = subprocess.run(cmd, shell=True, capture_output=True) + try: + stdout = r.stdout.decode("utf-8").strip() + except UnicodeDecodeError: + try: + stdout = r.stdout.decode("utf-16").strip() + except UnicodeDecodeError: + stdout = r.stdout.decode("latin-1").strip() + return stdout, r.returncode def get_env(key: str, default: str = "") -> str: @@ -629,9 +636,16 @@ def main(): if rc: req_content_after += rc + "\n" if not req_content_after: - # HEAD의 requirements.txt μ‚¬μš© - rc, _ = run("cat requirements.txt 2>/dev/null") - req_content_after = rc + # HEAD의 requirements.txtλ₯Ό 직접 읽기 (인코딩 μžλ™ 감지) + req_path = Path("requirements.txt") + if req_path.exists(): + raw = req_path.read_bytes() + for enc in ("utf-8-sig", "utf-16", "utf-8", "latin-1"): + try: + req_content_after = raw.decode(enc) + break + except (UnicodeDecodeError, Exception): + continue if all_new_imports and req_content_after: missing = check_import_vs_requirements(all_new_imports, req_content_after) From 5c262e0d9fb72a4bd1a687f454eacbd836c642e0 Mon Sep 17 00:00:00 2001 From: Interludeal Date: Fri, 12 Jun 2026 15:33:39 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20render=5Fmarkdown=EC=97=90=20dict=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20ReviewReport=20=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/review_pr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/review_pr.py b/scripts/review_pr.py index dd5bfb70..1e589eb2 100644 --- a/scripts/review_pr.py +++ b/scripts/review_pr.py @@ -706,7 +706,7 @@ def main(): with open("pr_review_result.json", "w", encoding="utf-8") as f: json.dump(result_dict, f, ensure_ascii=False, indent=2) - md = render_markdown(result_dict) + md = render_markdown(report) # ← dict μ•„λ‹Œ ReviewReport 객체λ₯Ό 전달 with open("pr_review_result.md", "w", encoding="utf-8") as f: f.write(md) From 73ff66f18f24769fa32c2364b1eeb4a33be4eb25 Mon Sep 17 00:00:00 2001 From: Interludeal Date: Fri, 12 Jun 2026 15:40:16 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20review=5Fpr.py=20=EC=9E=90=EA=B8=B0?= =?UTF-8?q?=20=EC=9E=90=EC=8B=A0=EC=9D=84=20=EC=9C=84=ED=97=98=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20=EB=B6=84=EC=84=9D=EC=97=90=EC=84=9C=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/review_pr.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/review_pr.py b/scripts/review_pr.py index 1e589eb2..9b6f5764 100644 --- a/scripts/review_pr.py +++ b/scripts/review_pr.py @@ -659,11 +659,17 @@ def main(): ))) # ── μœ„ν—˜ νŒ¨ν„΄ + λ³΅μž‘λ„ (λ³€κ²½λœ 파일만) ── + # CI 도ꡬ 자체(review_pr.py)λŠ” νŒ¨ν„΄ λ¬Έμžμ—΄μ΄ 포함돼 μžˆμœΌλ―€λ‘œ 뢄석 μ œμ™Έ + SELF_SKIP = {"scripts/review_pr.py"} + print(f"[5/6] μœ„ν—˜ νŒ¨ν„΄ / λ³΅μž‘λ„ 뢄석") for fc in py_files: if fc["status"] == "deleted": continue path = fc["path"] + if path in SELF_SKIP: + print(f" ⏭ {path} β€” CI 도ꡬ 파일, μœ„ν—˜ νŒ¨ν„΄ 뢄석 μ œμ™Έ") + continue # μž„μ‹œ 파일둜 μ €μž₯ν•΄μ„œ pyflakes μ‹€ν–‰ after_src = get_file_at(head_sha, path) or "" if not after_src: From 872a7c921d2f3fb777ad99e4bfea49625b9aa097 Mon Sep 17 00:00:00 2001 From: Interludeal Date: Fri, 12 Jun 2026 16:19:00 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20merge=20=EC=A4=80=EB=B9=84"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dynamic_RAG/README.md | 2 +- holding.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dynamic_RAG/README.md b/Dynamic_RAG/README.md index 470d5fcb..58ab5409 100644 --- a/Dynamic_RAG/README.md +++ b/Dynamic_RAG/README.md @@ -2,7 +2,7 @@ Chrome ν™•μž₯ ν”„λ‘œκ·Έλž¨ ZIP/디렉토리λ₯Ό μž…λ ₯λ°›μ•„ JSΒ·HTMLΒ·DNR μ½”λ“œλ₯Ό μ •μ μœΌλ‘œ λΆ„μ„ν•˜κ³ , μž„λ² λ”© 및 μ‹œλ‚˜λ¦¬μ˜€ 맀칭에 μ‚¬μš©ν•  `vector_fingerprint.json`을 μƒμ„±ν•©λ‹ˆλ‹€. -LLM 호좜, μž„λ² λ”© μˆ˜ν–‰, Vector DB μ €μž₯/검색은 이 λͺ¨λ“ˆμ˜ λ²”μœ„κ°€ μ•„λ‹™λ‹ˆλ‹€. 순수 정적 뢄석 기반 JSON μƒμ„±λ§Œ μˆ˜ν–‰ν•©λ‹ˆλ‹€. κ·Έλ ‡μŠ΅λ‹ˆλ‹€. +LLM 호좜, μž„λ² λ”© μˆ˜ν–‰, Vector DB μ €μž₯/검색은 이 λͺ¨λ“ˆμ˜ λ²”μœ„κ°€ μ•„λ‹™λ‹ˆλ‹€. 순수 정적 뢄석 기반 JSON μƒμ„±λ§Œ μˆ˜ν–‰ν•©λ‹ˆλ‹€. ## 디렉토리 ꡬ쑰 diff --git a/holding.py b/holding.py index 8d78467a..ddf041fe 100644 --- a/holding.py +++ b/holding.py @@ -37,6 +37,6 @@ async def holding( return { "status": "success", - "message": "파일 μˆ˜μ‹  및 홀딩 등둝 μ™„λ£Œ μ™„λ£Œ", + "message": "파일 μˆ˜μ‹  및 홀딩 등둝 μ™„λ£Œ", "holding_seconds": result["holding_seconds"], }