feat: Enable free public repo evaluation with Six Sommeliers mode#261
Conversation
- Add POST /api/evaluate/public endpoint for anonymous evaluations - Implement server-side public repo verification via GitHub API - Add IP-based rate limiting (5/hour) for anonymous requests - Force gemini-3-flash-preview model, six_sommeliers mode for free tier - Update stream/result/graph endpoints to allow anonymous access (user_id='anonymous') - Change frontend default tab to 'Enter URL' - Enable anonymous submit for public repos + six_sommeliers mode - Add 'Free evaluation' indicator banner in UI - Disable Grand Tasting mode for non-authenticated users
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. 개요공개 리포지토리에 대한 익명 평가 엔드포인트를 추가하고, IP 기반 속도 제한을 적용하며, 인증 없이 그래프 및 결과에 접근할 수 있도록 지원합니다. 프론트엔드는 인증 상태에 따라 조건부 평가 흐름을 제공합니다. 변경 사항
시퀀스 다이어그램sequenceDiagram
participant User as 익명 사용자
participant Frontend as 프론트엔드
participant API as 백엔드 API
participant GitHub as GitHub API
participant DB as 데이터베이스
User->>Frontend: 공개 리포지토리 URL 입력
Frontend->>API: POST /api/evaluate/public
Note over API: IP 기반 속도 제한 확인
API->>GitHub: GET /repos/{owner}/{repo}
GitHub-->>API: 200 OK (공개 리포지토리)
API->>DB: 평가 생성 (user_id: "anonymous")
DB-->>API: evaluation_id
API-->>Frontend: EvaluateResponse
Frontend->>API: 진행 중 스트림 (evaluation_id)
Note over API: 익명 평가 접근 허용
API-->>Frontend: 이벤트 스트림
par 백그라운드 작업
API->>DB: 평가 실행
DB-->>API: 결과
end
User->>Frontend: 결과 조회
Frontend->>API: GET /api/evaluate/{evaluation_id}/result
Note over API: 익명 사용자 접근 허용
API-->>Frontend: ResultResponse
예상 코드 리뷰 노력🎯 4 (복잡) | ⏱️ ~45분 관련될 수 있는 PR
시
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary of ChangesHello @ComBba, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request significantly expands the application's accessibility by enabling unauthenticated users to perform free evaluations of public GitHub repositories. The changes involve a new backend API for anonymous submissions with built-in rate limiting and public repository verification, alongside comprehensive frontend adjustments to guide users through this new flow and clearly communicate feature availability based on authentication status. Highlights
Changelog
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces a significant new feature enabling free, anonymous evaluations for public repositories. The changes are well-structured across the backend and frontend. My review identifies a few key areas for improvement, primarily focusing on the robustness of the new IP-based rate limiting, reducing code duplication to enhance maintainability, and refining error handling on both the server and client sides. Overall, this is a great addition, and addressing these points will make the implementation more scalable and robust.
| _anonymous_rate_limit_store: dict[str, list[float]] = {} | ||
| _ANONYMOUS_RATE_LIMIT = 5 | ||
| _ANONYMOUS_RATE_WINDOW = 3600 | ||
|
|
||
|
|
||
| def _check_anonymous_rate_limit(client_ip: str) -> bool: | ||
| """Check if the client IP has exceeded the anonymous evaluation rate limit. | ||
|
|
||
| Args: | ||
| client_ip: The client's IP address. | ||
|
|
||
| Returns: | ||
| True if the request is allowed, False if rate limited. | ||
| """ | ||
| now = time.time() | ||
| window_start = now - _ANONYMOUS_RATE_WINDOW | ||
| timestamps = _anonymous_rate_limit_store.get(client_ip, []) | ||
| timestamps = [ts for ts in timestamps if ts > window_start] | ||
|
|
||
| if len(timestamps) >= _ANONYMOUS_RATE_LIMIT: | ||
| _anonymous_rate_limit_store[client_ip] = timestamps | ||
| return False | ||
|
|
||
| timestamps.append(now) | ||
| _anonymous_rate_limit_store[client_ip] = timestamps | ||
| return True |
There was a problem hiding this comment.
The in-memory rate limiter (_anonymous_rate_limit_store) is not suitable for a production environment with multiple worker processes (e.g., using gunicorn). Each worker will have its own separate memory space and its own rate-limiting store, which will make the rate limit ineffective as requests can be distributed among workers. To ensure the rate limit is applied correctly across all instances, please consider using a distributed cache like Redis or Memcached.
| if (canSubmitAnonymously) { | ||
| try { | ||
| await onSubmit(repoUrl, criteria, evaluationMode); | ||
| } catch (err) { | ||
| setValidationError(err instanceof Error ? err.message : 'Evaluation failed'); | ||
| } | ||
| } else if (isAuthenticated) { | ||
| await onSubmit(repoUrl, criteria, evaluationMode); | ||
| } else { | ||
| setValidationError("Please login to submit an evaluation."); | ||
| } |
There was a problem hiding this comment.
The handleSubmit function has inconsistent error handling. A try-catch block is added for anonymous submissions, but not for authenticated ones. The parent component, EvaluatePage, already handles errors from the onSubmit call, including managing the loading state. This local try-catch is not only inconsistent but also incomplete, as it doesn't reset the loading state, potentially leaving the UI in a broken state on error. The logic can be simplified by using the canSubmit variable and letting the parent component handle all submission errors consistently.
if (canSubmit) {
await onSubmit(repoUrl, criteria, evaluationMode);
} else {
setValidationError("Please login to submit an evaluation.");
}
| @router.post("/public", response_model=EvaluateResponse) | ||
| async def create_public_evaluation( | ||
| request: EvaluateRequest, | ||
| req: Request, | ||
| ) -> EvaluateResponse: | ||
| """Start anonymous evaluation for public repositories. | ||
|
|
||
| This endpoint allows unauthenticated users to evaluate public GitHub repositories. | ||
| Constraints: | ||
| - Only six_sommeliers mode allowed | ||
| - Only public GitHub repos (verified server-side) | ||
| - Forced model: gemini-3-flash-preview | ||
| - No BYOK/provider/model/temperature overrides | ||
| - Rate limited by IP (5 evaluations per hour) | ||
|
|
||
| Args: | ||
| request: The evaluation request containing repo_url and criteria. | ||
| req: The FastAPI request object for IP-based rate limiting. | ||
|
|
||
| Returns: | ||
| EvaluateResponse with evaluation_id and status. | ||
|
|
||
| Raises: | ||
| CorkedError: If rate limit exceeded, repo is private, or evaluation fails. | ||
| """ | ||
| client_ip = _get_client_ip(req) | ||
| logger.info( | ||
| f"[Evaluate Public] Request from {client_ip}: repo_url={request.repo_url}" | ||
| ) | ||
|
|
||
| if not _check_anonymous_rate_limit(client_ip): | ||
| logger.warning(f"[Evaluate Public] Rate limit exceeded for IP: {client_ip}") | ||
| raise CorkedError( | ||
| "Rate limit exceeded. Please try again later or login for unlimited access." | ||
| ) | ||
|
|
||
| await verify_public_repo(request.repo_url) | ||
|
|
||
| try: | ||
| eval_id = await start_evaluation( | ||
| repo_url=request.repo_url, | ||
| criteria=request.criteria, | ||
| user_id="anonymous", | ||
| custom_criteria=request.custom_criteria, | ||
| evaluation_mode="six_sommeliers", | ||
| ) | ||
|
|
||
| event_channel = get_event_channel() | ||
| await event_channel.create_channel(eval_id) | ||
|
|
||
| async def run_in_background(): | ||
| try: | ||
| await run_evaluation_pipeline_with_events( | ||
| evaluation_id=eval_id, | ||
| repo_url=request.repo_url, | ||
| criteria=request.criteria, | ||
| user_id="anonymous", | ||
| evaluation_mode="six_sommeliers", | ||
| provider="gemini", | ||
| model="gemini-3-flash-preview", | ||
| temperature=None, | ||
| api_key=None, | ||
| github_token=None, | ||
| ) | ||
| except Exception as e: | ||
| logger.exception(f"Background evaluation failed: {eval_id}") | ||
| error_msg = str(e) | ||
| if "Resource not found" in error_msg or "404" in error_msg: | ||
| user_message = "Repository not found or is private. Please check the URL and try again." | ||
| elif "rate limit" in error_msg.lower(): | ||
| user_message = ( | ||
| "GitHub API rate limit exceeded. Please try again later." | ||
| ) | ||
| else: | ||
| user_message = f"Evaluation failed: {error_msg}" | ||
| await event_channel.emit( | ||
| eval_id, | ||
| create_sommelier_event( | ||
| evaluation_id=eval_id, | ||
| sommelier="system", | ||
| event_type=EventType.EVALUATION_ERROR.value, | ||
| progress_percent=-1, | ||
| message=user_message, | ||
| ), | ||
| ) | ||
| await handle_evaluation_error(eval_id, error_msg) | ||
| finally: | ||
| await event_channel.close_channel(eval_id) | ||
|
|
||
| try: | ||
| task = asyncio.create_task(run_in_background()) | ||
| await register_task(eval_id, task) | ||
| except Exception as e: | ||
| await event_channel.close_channel(eval_id) | ||
| raise CorkedError(f"Failed to start background task: {e!s}") from e | ||
|
|
||
| logger.info(f"[Evaluate Public] Background task started: {eval_id}") | ||
|
|
||
| return EvaluateResponse( | ||
| evaluation_id=eval_id, | ||
| status="pending", | ||
| evaluation_mode="six_sommeliers", | ||
| estimated_time=30, | ||
| ) | ||
| except CorkedError as e: | ||
| logger.error(f"[Evaluate Public] CorkedError: {e.detail}") | ||
| raise e | ||
| except Exception as e: | ||
| logger.error(f"[Evaluate Public] Exception: {type(e).__name__}: {str(e)}") | ||
| import traceback | ||
|
|
||
| logger.error(f"[Evaluate Public] Traceback: {traceback.format_exc()}") | ||
| raise CorkedError(f"Failed to start evaluation: {e!s}") from e |
There was a problem hiding this comment.
There is significant code duplication between the create_public_evaluation function and the existing create_evaluation function. The logic for creating an event channel, defining the run_in_background coroutine, creating and registering the background task, and handling exceptions is nearly identical. This duplication increases maintenance overhead. I recommend refactoring the common logic into a shared helper function that both endpoints can call with their specific parameters.
| raise e | ||
| except Exception as e: | ||
| logger.error(f"[Evaluate Public] Exception: {type(e).__name__}: {str(e)}") | ||
| import traceback |
| # Allow access if: authenticated owner OR anonymous evaluation without auth | ||
| if user is None and eval_user_id != "anonymous": | ||
| raise CorkedError("Authentication required to view this evaluation") | ||
|
|
||
| if user is not None and eval_user_id != user.id: | ||
| raise CorkedError("Access denied: evaluation belongs to another user") |
There was a problem hiding this comment.
The authorization logic to check if a user can access an evaluation is duplicated here and in the get_result endpoint within this file. A similar function _check_ownership also exists in backend/app/api/routes/graph.py. To improve maintainability and ensure consistency, this logic should be centralized into a single utility function or a FastAPI dependency that can be reused across all relevant endpoints.
| else: | ||
| raise CorkedError(f"GitHub API error: {response.status_code} - {response.text}") |
There was a problem hiding this comment.
Returning the raw response.text from the GitHub API in a user-facing error message can leak internal implementation details. It's better to return a more generic error message for unexpected API errors, while logging the detailed response on the server for debugging purposes.
| else: | |
| raise CorkedError(f"GitHub API error: {response.status_code} - {response.text}") | |
| raise CorkedError("An unexpected error occurred while verifying the repository with GitHub.") |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Fix all issues with AI agents
In `@backend/app/api/routes/evaluate.py`:
- Around line 74-86: The _get_client_ip function currently trusts the
X-Forwarded-For header unconditionally, allowing clients to spoof IPs and bypass
rate limits; change it to only use X-Forwarded-For when the app is configured to
be behind a trusted proxy (e.g. via a config flag like TRUSTED_PROXY or using
Starlette/FastAPI ForwardedHeaderMiddleware/TrustedHostMiddleware); otherwise
always return request.client.host (or "unknown") and ignore the header; update
_get_client_ip to check that trusted-proxy mode is enabled before parsing
request.headers["X-Forwarded-For"] so only trusted proxy deployments can rely on
forwarded IPs.
- Around line 507-514: In get_result, mirror the same access-check fix used in
stream_evaluation: read eval_user_id = progress.get("user_id") and keep the
unauthenticated guard (if user is None and eval_user_id != "anonymous" -> raise
CorkedError), but relax the authenticated guard so an authenticated user is
allowed to view anonymous evaluations; change the second check in get_result
(currently "if user is not None and eval_user_id != user.id") to only raise when
eval_user_id is neither "anonymous" nor the authenticated user's id (i.e., if
user is not None and eval_user_id != "anonymous" and eval_user_id != user.id ->
raise CorkedError).
- Around line 306-313: The code currently passes request.custom_criteria
straight into start_evaluation; change this so that when running an anonymous
evaluation (user_id set to "anonymous" in the evaluate route) you force
custom_criteria to None before calling start_evaluation (i.e., compute a local
value like custom_criteria_for_call = None if user_id == "anonymous" else
request.custom_criteria and pass that to start_evaluation); update the call site
that invokes start_evaluation (where repo_url, criteria, user_id,
custom_criteria, evaluation_mode are passed) to use this computed value so
anonymous users cannot inject custom criteria.
- Around line 407-414: The access logic in evaluate.py incorrectly blocks
authenticated users from viewing anonymous evaluations because eval_user_id ==
"anonymous" isn't exempted for the second check; update the checks around
eval_user_id and user (the variables from progress.get("user_id") and user) so
that anonymous evaluations are always allowed: keep the first check as "if user
is None and eval_user_id != 'anonymous' then raise CorkedError", and modify the
second check to only deny access when user is not None AND eval_user_id !=
'anonymous' AND eval_user_id != user.id (raise CorkedError). Ensure CorkedError
remains the raised exception.
- Around line 46-71: The in-memory _anonymous_rate_limit_store used by
_check_anonymous_rate_limit can grow unbounded because keys for IPs that never
return are never removed; replace or wrap the dict with a TTL-capable cache
(e.g., use cachetools.TTLCache or implement periodic cleanup) keyed by client_ip
so entries expire after a period longer than _ANONYMOUS_RATE_WINDOW, preserve
the rate logic using _ANONYMOUS_RATE_LIMIT and timestamps per IP, and update
_check_anonymous_rate_limit to read/modify the TTL cache instead of the raw
_anonymous_rate_limit_store to prevent memory leaks.
In `@backend/app/api/routes/graph.py`:
- Around line 55-80: The ownership check is inconsistent with evaluate.py
(stream_evaluation/get_result): for evaluations with evaluation["user_id"] ==
"anonymous" _check_ownership currently allows access to anyone, but evaluate.py
effectively denies authenticated users; fix by making the logic
consistent—either (A) require a user and only allow when user.id == "anonymous",
or (B) allow unauthenticated/public access—then apply that choice across both
modules; specifically update _check_ownership to implement the chosen rule for
eval_user_id == "anonymous" and refactor the shared logic into a single helper
used by stream_evaluation and get_result to avoid divergence.
In `@backend/app/services/github_service.py`:
- Around line 354-370: verify_public_repo currently calls GitHub API
unauthenticated and without the standard Accept header, risking the 60/hr rate
limit and inconsistent behavior with GitHubService._get; update
verify_public_repo to use the server GITHUB_TOKEN for Authorization (bearer)
when available, add the Accept: application/vnd.github.v3+json header to the
request, and when using an authenticated request inspect the JSON response's
"private" field to ensure the repo is truly public before returning True (and
still raise CorkedError on 404/403 or other errors as before).
🧹 Nitpick comments (7)
backend/app/services/github_service.py (2)
349-352:raise ... from None을 사용하여 예외 체인을 명확히 하세요.현재
except CorkedError블록에서 새로운CorkedError를 raise하면 원래 예외가 암시적으로 체인되어 traceback이 혼란스러워집니다. Ruff B904 경고와 일치합니다.♻️ 수정 제안
try: owner, repo = parse_github_url(repo_url) except CorkedError: - raise CorkedError("Invalid GitHub repository URL") + raise CorkedError("Invalid GitHub repository URL") from None
361-368: 404와 403 분기의 에러 메시지가 동일하므로 하나로 합칠 수 있습니다.♻️ 중복 제거 제안
- if response.status_code == 200: - return True - elif response.status_code == 404: - raise CorkedError( - "Repository not found or is private. Please login to evaluate private repositories." - ) - elif response.status_code == 403: - raise CorkedError( - "Repository not found or is private. Please login to evaluate private repositories." - ) - else: + if response.status_code == 200: + return True + elif response.status_code in (403, 404): + raise CorkedError( + "Repository not found or is private. Please login to evaluate private repositories." + ) + else:frontend/src/app/evaluate/page.tsx (1)
22-36: 인증 상태에 따른 분기 처리가 올바릅니다.한 가지 참고 사항: 비인증 사용자가 폼에서
criteria를 변경해도startPublicEvaluation은 항상'basic'을 사용합니다. 사용자에게 혼동을 줄 수 있으므로, 비인증 상태에서는CriteriaSelector를 비활성화하거나 고정값임을 표시하는 것을 고려해 보세요.backend/app/api/routes/evaluate.py (2)
318-380:create_evaluation과create_public_evaluation의 백그라운드 태스크 코드가 거의 동일하게 중복됩니다.
run_in_background내부의 에러 핸들링, 이벤트 채널 관리, 태스크 등록 로직이 양쪽 엔드포인트에서 반복됩니다. 공통 헬퍼 함수로 추출하면 유지보수성이 크게 향상됩니다.
293-296: 로그에 클라이언트 IP가 직접 기록되고 있습니다.GDPR/개인정보 관점에서 IP 주소 로깅이 적절한지 확인하세요. 필요하다면 해시 처리하거나 보존 기간을 제한하는 것을 고려하세요.
backend/app/api/routes/graph.py (1)
18-22:PUBLIC_DEMO_EVALUATIONS가evaluate.py와 중복 정의되어 있습니다.두 파일이 동일한 상수를 각각 정의하면 값이 달라질 수 있습니다. 공유 모듈(예:
app/core/constants.py)로 추출하세요. 또한 상수 정의가 import 문 사이에 위치하여 코드 구조가 어색합니다.♻️ 수정 제안
from app.api.deps import get_optional_user +from app.core.constants import PUBLIC_DEMO_EVALUATIONS -# Demo evaluation IDs that can be accessed without authentication -PUBLIC_DEMO_EVALUATIONS = { - "6986e6d6650de8503772babf", # ai/nanoid evaluation -} from app.core.exceptions import CorkedError, EmptyCellarErrorfrontend/src/components/EvaluationForm.tsx (1)
105-115: 익명 경로와 인증 경로의 에러 처리 방식이 비대칭입니다.익명 제출(Line 106-110)은
try-catch로setValidationError에 에러를 설정하지만, 인증된 제출(Line 112)은 예외를 상위로 전파합니다. 상위 컴포넌트가errorprop을 통해 에러를 전달하므로 기능적으로는 동작하지만, 에러 표시 경로가 다릅니다(validationError vs error prop).두 경로 모두 동일한 에러 처리 패턴을 사용하면 일관성이 높아집니다.
| def _check_ownership(evaluation: dict[str, Any], user, evaluation_id: str) -> None: | ||
| """Verify user owns the evaluation or it's a public demo. | ||
| """Verify user owns the evaluation or it's publicly accessible. | ||
|
|
||
| Args: | ||
| evaluation: The evaluation document. | ||
| user: The current authenticated user (can be None for public demos). | ||
| user: The current authenticated user (can be None for public/anonymous). | ||
| evaluation_id: The evaluation ID. | ||
|
|
||
| Raises: | ||
| CorkedError: If user does not own the evaluation and it's not public. | ||
| """ | ||
| # Allow access to public demo evaluations without auth | ||
| if evaluation_id in PUBLIC_DEMO_EVALUATIONS: | ||
| return | ||
|
|
||
| # Require auth for non-public evaluations | ||
| eval_user_id = evaluation.get("user_id") | ||
|
|
||
| if eval_user_id == "anonymous": | ||
| return | ||
|
|
||
| if user is None: | ||
| raise CorkedError("Authentication required to view this evaluation") | ||
|
|
||
| if evaluation.get("user_id") != user.id: | ||
| if eval_user_id != user.id: | ||
| raise CorkedError( | ||
| "Access denied: evaluation belongs to another user", status_code=403 | ||
| ) |
There was a problem hiding this comment.
_check_ownership의 접근 제어 로직이 evaluate.py의 stream_evaluation/get_result과 불일치합니다.
여기서는 eval_user_id == "anonymous"이면 누구나(인증 여부 무관) 접근을 허용하지만, evaluate.py에서는 인증된 사용자가 익명 평가에 접근하면 user.id != "anonymous"로 인해 차단됩니다. evaluate.py 쪽을 이 패턴에 맞추거나, 공통 접근 제어 헬퍼를 추출하는 것이 좋습니다.
🧰 Tools
🪛 Ruff (0.14.14)
[warning] 75-75: Avoid specifying long messages outside the exception class
(TRY003)
[warning] 78-80: Avoid specifying long messages outside the exception class
(TRY003)
🤖 Prompt for AI Agents
In `@backend/app/api/routes/graph.py` around lines 55 - 80, The ownership check is
inconsistent with evaluate.py (stream_evaluation/get_result): for evaluations
with evaluation["user_id"] == "anonymous" _check_ownership currently allows
access to anyone, but evaluate.py effectively denies authenticated users; fix by
making the logic consistent—either (A) require a user and only allow when
user.id == "anonymous", or (B) allow unauthenticated/public access—then apply
that choice across both modules; specifically update _check_ownership to
implement the chosen rule for eval_user_id == "anonymous" and refactor the
shared logic into a single helper used by stream_evaluation and get_result to
avoid divergence.
- Use TTLCache for rate limiting to prevent memory leaks - Add TRUSTED_PROXY setting to prevent IP spoofing attacks - Use authenticated GitHub API in verify_public_repo (avoid 60/hr limit) - Merge 404/403 error branches for consistent messaging - Sanitize error messages (don't expose response.text) - Add exception chaining (from None) for cleaner tracebacks - Block custom_criteria for anonymous evaluations - Fix access control: authenticated users can view anonymous evaluations
feat: Enable free public repo evaluation with Six Sommeliers mode
Summary
Enable free evaluation for public GitHub repositories without login, using the Six Sommeliers (6기법) evaluation mode with gemini-3-flash-preview model.
Changes
Backend
POST /api/evaluate/publicfor anonymous evaluationssix_sommeliersmode,gemini-3-flash-previewmodeluser_id = "anonymous"user_id == "anonymous":GET /api/evaluate/{id}/streamGET /api/evaluate/{id}/result/graph,/graph-3d,/graph/timeline,/graph/mode, etc.)Frontend
startPublicEvaluation()API methodTesting
npm run build)Notes
public_tokenrequired - access byevaluation_idonly for anonymous evaluationsSummary by CodeRabbit
릴리스 노트
새 기능
개선 사항