Spaces:
Runtime error
Runtime error
Convert MCP server submodules to regular directories
Browse files- external/mcp-servers/hf-translation-docs-explorer +0 -1
- external/mcp-servers/hf-translation-docs-explorer/.gitattributes +35 -0
- external/mcp-servers/hf-translation-docs-explorer/README.md +13 -0
- external/mcp-servers/hf-translation-docs-explorer/adapters.py +48 -0
- external/mcp-servers/hf-translation-docs-explorer/app.py +152 -0
- external/mcp-servers/hf-translation-docs-explorer/configs/defaults.yaml +10 -0
- external/mcp-servers/hf-translation-docs-explorer/pyproject.toml +13 -0
- external/mcp-servers/hf-translation-docs-explorer/requirements.txt +11 -0
- external/mcp-servers/hf-translation-docs-explorer/services.py +236 -0
- external/mcp-servers/hf-translation-docs-explorer/setting.py +62 -0
- external/mcp-servers/hf-translation-docs-explorer/tools.py +50 -0
- external/mcp-servers/hf-translation-reviewer +0 -1
- external/mcp-servers/hf-translation-reviewer/.gitattributes +35 -0
- external/mcp-servers/hf-translation-reviewer/README.md +13 -0
- external/mcp-servers/hf-translation-reviewer/adapters.py +153 -0
- external/mcp-servers/hf-translation-reviewer/app.py +219 -0
- external/mcp-servers/hf-translation-reviewer/configs/default.yaml +11 -0
- external/mcp-servers/hf-translation-reviewer/requirements.txt +14 -0
- external/mcp-servers/hf-translation-reviewer/services.py +575 -0
- external/mcp-servers/hf-translation-reviewer/setting.py +64 -0
- external/mcp-servers/hf-translation-reviewer/tools.py +98 -0
external/mcp-servers/hf-translation-docs-explorer
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
Subproject commit 45883c3faf36a9abcf03ede91a12ed4c8f3ab1cc
|
|
|
|
|
|
external/mcp-servers/hf-translation-docs-explorer/.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
external/mcp-servers/hf-translation-docs-explorer/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Traslation File Explorer
|
| 3 |
+
emoji: ⚡
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: pink
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 5.49.1
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
external/mcp-servers/hf-translation-docs-explorer/adapters.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Dict, List
|
| 4 |
+
|
| 5 |
+
import requests
|
| 6 |
+
|
| 7 |
+
from setting import SETTINGS
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _build_auth_headers() -> Dict[str, str]:
|
| 11 |
+
"""
|
| 12 |
+
GitHub 호출용 Authorization 헤더 생성.
|
| 13 |
+
- 우선순위: SETTINGS.github_token → (fallback) 환경변수 GITHUB_TOKEN
|
| 14 |
+
"""
|
| 15 |
+
token = SETTINGS.github_token
|
| 16 |
+
if not token:
|
| 17 |
+
# 환경변수 직접 조회
|
| 18 |
+
import os
|
| 19 |
+
token = os.environ.get("GITHUB_TOKEN", "")
|
| 20 |
+
|
| 21 |
+
if not token:
|
| 22 |
+
return {}
|
| 23 |
+
return {"Authorization": f"token {token}"}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def fetch_document_paths(api_url: str) -> List[str]:
|
| 27 |
+
"""
|
| 28 |
+
GitHub git/trees API에서 blob 경로 목록만 추출.
|
| 29 |
+
|
| 30 |
+
Parameters
|
| 31 |
+
----------
|
| 32 |
+
api_url : str
|
| 33 |
+
예: https://api.github.com/repos/huggingface/transformers/git/trees/main?recursive=1
|
| 34 |
+
"""
|
| 35 |
+
response = requests.get(
|
| 36 |
+
api_url,
|
| 37 |
+
headers=_build_auth_headers(),
|
| 38 |
+
timeout=SETTINGS.request_timeout_seconds,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
if response.status_code == 403 and "rate limit" in response.text.lower():
|
| 42 |
+
raise RuntimeError(
|
| 43 |
+
"GitHub API rate limit exceeded. Provide a GITHUB_TOKEN to continue."
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
response.raise_for_status()
|
| 47 |
+
tree = response.json().get("tree", [])
|
| 48 |
+
return [item["path"] for item in tree if item.get("type") == "blob"]
|
external/mcp-servers/hf-translation-docs-explorer/app.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
|
| 8 |
+
from services import get_available_projects, LANGUAGE_CHOICES
|
| 9 |
+
from tools import list_projects, search_files, list_missing_files
|
| 10 |
+
from setting import SETTINGS
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def ensure_mcp_support() -> None:
|
| 14 |
+
"""Verify that ``gradio[mcp]`` is installed and enable the MCP server flag."""
|
| 15 |
+
try:
|
| 16 |
+
import gradio.mcp # noqa: F401
|
| 17 |
+
except ImportError as exc: # pragma: no cover - runtime guard
|
| 18 |
+
raise RuntimeError("Install gradio[mcp] before launching this module.") from exc
|
| 19 |
+
os.environ.setdefault("GRADIO_MCP_SERVER", "true")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def build_demo() -> gr.Blocks:
|
| 23 |
+
"""Create a lightweight Gradio Blocks UI for exercising the MCP tools."""
|
| 24 |
+
projects = get_available_projects()
|
| 25 |
+
languages = LANGUAGE_CHOICES[:]
|
| 26 |
+
|
| 27 |
+
with gr.Blocks(title=SETTINGS.ui_title) as demo:
|
| 28 |
+
gr.Markdown("# Translation MCP Server\nTry the MCP tools exposed below.")
|
| 29 |
+
|
| 30 |
+
# --- 1) Project catalog ---
|
| 31 |
+
with gr.Tab("Project catalog"):
|
| 32 |
+
catalog_output = gr.JSON(label="catalog")
|
| 33 |
+
gr.Button("Fetch").click(
|
| 34 |
+
fn=list_projects,
|
| 35 |
+
inputs=[], # 인자 없음
|
| 36 |
+
outputs=catalog_output,
|
| 37 |
+
api_name="translation_project_catalog",
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# --- 2) File search (report + candidates) ---
|
| 41 |
+
with gr.Tab("File search"):
|
| 42 |
+
project_input = gr.Dropdown(
|
| 43 |
+
choices=projects,
|
| 44 |
+
label="Project",
|
| 45 |
+
value=projects[0] if projects else "",
|
| 46 |
+
)
|
| 47 |
+
lang_input = gr.Dropdown(
|
| 48 |
+
choices=languages,
|
| 49 |
+
label="Language",
|
| 50 |
+
value=SETTINGS.default_language,
|
| 51 |
+
)
|
| 52 |
+
limit_input = gr.Number(
|
| 53 |
+
label="Limit",
|
| 54 |
+
value=SETTINGS.default_limit,
|
| 55 |
+
minimum=1,
|
| 56 |
+
)
|
| 57 |
+
include_report = gr.Checkbox(
|
| 58 |
+
label="Include status report",
|
| 59 |
+
value=True,
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
search_output = gr.JSON(label="search result")
|
| 63 |
+
gr.Button("Search").click(
|
| 64 |
+
fn=search_files,
|
| 65 |
+
inputs=[project_input, lang_input, limit_input, include_report],
|
| 66 |
+
outputs=search_output,
|
| 67 |
+
api_name="translation_file_search",
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# --- 3) Missing docs only ---
|
| 71 |
+
with gr.Tab("Missing docs"):
|
| 72 |
+
missing_project = gr.Dropdown(
|
| 73 |
+
choices=projects,
|
| 74 |
+
label="Project",
|
| 75 |
+
value=projects[0] if projects else "",
|
| 76 |
+
)
|
| 77 |
+
missing_lang = gr.Dropdown(
|
| 78 |
+
choices=languages,
|
| 79 |
+
label="Language",
|
| 80 |
+
value=SETTINGS.default_language,
|
| 81 |
+
)
|
| 82 |
+
missing_limit = gr.Number(
|
| 83 |
+
label="Limit",
|
| 84 |
+
value=max(SETTINGS.default_limit, 20),
|
| 85 |
+
minimum=1,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
missing_output = gr.JSON(label="missing files")
|
| 89 |
+
gr.Button("List missing").click(
|
| 90 |
+
fn=list_missing_files,
|
| 91 |
+
inputs=[missing_project, missing_lang, missing_limit],
|
| 92 |
+
outputs=missing_output,
|
| 93 |
+
api_name="translation_missing_list",
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
return demo
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _parse_args(argv=None) -> argparse.Namespace:
|
| 100 |
+
"""Parse CLI arguments used for local or Space deployments."""
|
| 101 |
+
parser = argparse.ArgumentParser(description="Launch the translation MCP demo.")
|
| 102 |
+
|
| 103 |
+
parser.add_argument(
|
| 104 |
+
"--as-space",
|
| 105 |
+
action="store_true",
|
| 106 |
+
help="Use Hugging Face Space defaults.",
|
| 107 |
+
)
|
| 108 |
+
parser.add_argument(
|
| 109 |
+
"--share",
|
| 110 |
+
action="store_true",
|
| 111 |
+
help="Create a public share link.",
|
| 112 |
+
)
|
| 113 |
+
parser.add_argument(
|
| 114 |
+
"--no-queue",
|
| 115 |
+
dest="queue",
|
| 116 |
+
action="store_false",
|
| 117 |
+
help="Disable the request queue.",
|
| 118 |
+
)
|
| 119 |
+
parser.set_defaults(queue=True)
|
| 120 |
+
|
| 121 |
+
return parser.parse_args(argv)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def main(argv=None) -> None:
|
| 125 |
+
"""Launch the Gradio app with MCP server support enabled."""
|
| 126 |
+
args = _parse_args(argv)
|
| 127 |
+
|
| 128 |
+
ensure_mcp_support()
|
| 129 |
+
|
| 130 |
+
launch_kwargs = {"mcp_server": True}
|
| 131 |
+
|
| 132 |
+
if args.as_space or os.environ.get("SPACE_ID"):
|
| 133 |
+
launch_kwargs.update(
|
| 134 |
+
{
|
| 135 |
+
"server_name": "0.0.0.0",
|
| 136 |
+
"server_port": int(os.environ.get("PORT", "7860")),
|
| 137 |
+
"show_api": False,
|
| 138 |
+
}
|
| 139 |
+
)
|
| 140 |
+
else:
|
| 141 |
+
launch_kwargs["show_api"] = True
|
| 142 |
+
|
| 143 |
+
if args.share:
|
| 144 |
+
launch_kwargs["share"] = True
|
| 145 |
+
|
| 146 |
+
demo = build_demo()
|
| 147 |
+
app = demo.queue() if args.queue else demo
|
| 148 |
+
app.launch(**launch_kwargs)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
if __name__ == "__main__": # pragma: no cover - manual execution helper
|
| 152 |
+
main()
|
external/mcp-servers/hf-translation-docs-explorer/configs/defaults.yaml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
github:
|
| 2 |
+
token: "" # 기본값: 환경변수 GITHUB_TOKEN 사용 권장
|
| 3 |
+
request_timeout_seconds: 30
|
| 4 |
+
|
| 5 |
+
translation:
|
| 6 |
+
default_language: "ko" # 기본 타겟 언어
|
| 7 |
+
default_limit: 5 # 기본 검색/누락 파일 개수
|
| 8 |
+
|
| 9 |
+
ui:
|
| 10 |
+
title: "Translation Docs Search MCP Server"
|
external/mcp-servers/hf-translation-docs-explorer/pyproject.toml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "translation-file-explorer-mcp"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
requires-python = ">=3.10"
|
| 5 |
+
dependencies = [
|
| 6 |
+
"gradio[mcp]>=5.33.0",
|
| 7 |
+
"pydantic>=2.7.0",
|
| 8 |
+
"requests>=2.31.0",
|
| 9 |
+
"pyyaml>=6.0.1",
|
| 10 |
+
]
|
| 11 |
+
|
| 12 |
+
[tool.ruff]
|
| 13 |
+
line-length = 100
|
external/mcp-servers/hf-translation-docs-explorer/requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio[mcp]==5.33.0
|
| 2 |
+
requests
|
| 3 |
+
pydantic
|
| 4 |
+
langchain-anthropic
|
| 5 |
+
python-dotenv
|
| 6 |
+
langchain
|
| 7 |
+
PyGithub
|
| 8 |
+
langchain-core
|
| 9 |
+
langchain-community
|
| 10 |
+
boto3
|
| 11 |
+
PyYAML
|
external/mcp-servers/hf-translation-docs-explorer/services.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any, Dict, Iterable, List, Tuple
|
| 6 |
+
|
| 7 |
+
from adapters import fetch_document_paths
|
| 8 |
+
from setting import SETTINGS
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# Gradio / UI 에 노출할 언어 선택지
|
| 12 |
+
LANGUAGE_CHOICES: List[str] = [
|
| 13 |
+
"ko",
|
| 14 |
+
"ja",
|
| 15 |
+
"zh",
|
| 16 |
+
"fr",
|
| 17 |
+
"de",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass(frozen=True)
|
| 22 |
+
class Project:
|
| 23 |
+
"""Store the minimum metadata required for documentation lookups."""
|
| 24 |
+
|
| 25 |
+
slug: str
|
| 26 |
+
name: str
|
| 27 |
+
repo_url: str
|
| 28 |
+
docs_path: str
|
| 29 |
+
tree_api_url: str
|
| 30 |
+
|
| 31 |
+
@property
|
| 32 |
+
def repo_path(self) -> str:
|
| 33 |
+
"""Return the ``owner/repo`` identifier for GitHub API requests."""
|
| 34 |
+
return self.repo_url.replace("https://github.com/", "")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# 지원 프로젝트 정의
|
| 38 |
+
PROJECTS: Dict[str, Project] = {
|
| 39 |
+
"transformers": Project(
|
| 40 |
+
slug="transformers",
|
| 41 |
+
name="Transformers",
|
| 42 |
+
repo_url="https://github.com/huggingface/transformers",
|
| 43 |
+
docs_path="docs/source",
|
| 44 |
+
tree_api_url=(
|
| 45 |
+
"https://api.github.com/repos/huggingface/transformers/git/trees/main?recursive=1"
|
| 46 |
+
),
|
| 47 |
+
),
|
| 48 |
+
"smolagents": Project(
|
| 49 |
+
slug="smolagents",
|
| 50 |
+
name="SmolAgents",
|
| 51 |
+
repo_url="https://github.com/huggingface/smolagents",
|
| 52 |
+
docs_path="docs/source",
|
| 53 |
+
tree_api_url=(
|
| 54 |
+
"https://api.github.com/repos/huggingface/smolagents/git/trees/main?recursive=1"
|
| 55 |
+
),
|
| 56 |
+
),
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def get_available_projects() -> List[str]:
|
| 61 |
+
"""Return the list of project slugs supported by this module."""
|
| 62 |
+
return sorted(PROJECTS.keys())
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _iter_english_docs(all_docs: Iterable[str], docs_root: str) -> Iterable[Path]:
|
| 66 |
+
"""Yield English documentation files as ``Path`` objects."""
|
| 67 |
+
english_root = Path(docs_root) / "en"
|
| 68 |
+
|
| 69 |
+
for doc_path in all_docs:
|
| 70 |
+
if not doc_path.endswith(".md"):
|
| 71 |
+
continue
|
| 72 |
+
|
| 73 |
+
path = Path(doc_path)
|
| 74 |
+
try:
|
| 75 |
+
# en/ 아래에 있는지 필터링
|
| 76 |
+
path.relative_to(english_root)
|
| 77 |
+
except ValueError:
|
| 78 |
+
continue
|
| 79 |
+
|
| 80 |
+
yield path
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _compute_missing_translations(
|
| 84 |
+
project_key: str,
|
| 85 |
+
language: str,
|
| 86 |
+
limit: int,
|
| 87 |
+
) -> Tuple[str, List[str], Project]:
|
| 88 |
+
"""
|
| 89 |
+
영어 기준으로 누락 번역 파일을 계산하고,
|
| 90 |
+
마크다운 요약 리포트 + 누락 경로 리스트 + Project 메타데이터를 반환.
|
| 91 |
+
"""
|
| 92 |
+
project = PROJECTS[project_key]
|
| 93 |
+
|
| 94 |
+
all_paths = fetch_document_paths(project.tree_api_url)
|
| 95 |
+
english_docs = list(_iter_english_docs(all_paths, project.docs_path))
|
| 96 |
+
english_total = len(english_docs)
|
| 97 |
+
|
| 98 |
+
missing: List[str] = []
|
| 99 |
+
docs_set = set(all_paths)
|
| 100 |
+
|
| 101 |
+
for english_doc in english_docs:
|
| 102 |
+
relative = english_doc.relative_to(Path(project.docs_path) / "en")
|
| 103 |
+
translated_path = str(Path(project.docs_path) / language / relative)
|
| 104 |
+
|
| 105 |
+
if translated_path not in docs_set:
|
| 106 |
+
# 누락된 경우: 기준은 영어 경로(en/...)
|
| 107 |
+
missing.append(str(english_doc))
|
| 108 |
+
if len(missing) >= limit:
|
| 109 |
+
break
|
| 110 |
+
|
| 111 |
+
missing_count = len(missing)
|
| 112 |
+
percentage = (missing_count / english_total * 100) if english_total else 0.0
|
| 113 |
+
|
| 114 |
+
report = (
|
| 115 |
+
"| Item | Count | Percentage |\n"
|
| 116 |
+
"|------|-------|------------|\n"
|
| 117 |
+
f"| English docs | {english_total} | - |\n"
|
| 118 |
+
f"| Missing translations | {missing_count} | {percentage:.2f}% |"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
return report, missing, project
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def build_project_catalog(default: str | None) -> Dict[str, Any]:
|
| 125 |
+
"""Build the project catalog payload (API-neutral, pure logic)."""
|
| 126 |
+
slugs = get_available_projects()
|
| 127 |
+
default = default if default in slugs else None
|
| 128 |
+
|
| 129 |
+
return {
|
| 130 |
+
"type": "translation.project_list",
|
| 131 |
+
"projects": [
|
| 132 |
+
{
|
| 133 |
+
"slug": slug,
|
| 134 |
+
"display_name": PROJECTS[slug].name,
|
| 135 |
+
"repo_url": PROJECTS[slug].repo_url,
|
| 136 |
+
"docs_path": PROJECTS[slug].docs_path,
|
| 137 |
+
}
|
| 138 |
+
for slug in slugs
|
| 139 |
+
],
|
| 140 |
+
"default_project": default,
|
| 141 |
+
"total_projects": len(slugs),
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def build_search_response(
|
| 146 |
+
project: str,
|
| 147 |
+
lang: str,
|
| 148 |
+
limit: int,
|
| 149 |
+
include_status_report: bool,
|
| 150 |
+
) -> Dict[str, Any]:
|
| 151 |
+
"""
|
| 152 |
+
누락 번역 파일 후보 + (선택) 상태 리포트를 포함한 검색 응답.
|
| 153 |
+
MCP / Gradio 에서 사용 가능한 JSON 형태.
|
| 154 |
+
"""
|
| 155 |
+
project = project.strip()
|
| 156 |
+
lang = lang.strip()
|
| 157 |
+
limit = max(1, int(limit))
|
| 158 |
+
|
| 159 |
+
project_config = PROJECTS[project]
|
| 160 |
+
|
| 161 |
+
status_report, candidate_paths, project_config = _compute_missing_translations(
|
| 162 |
+
project_key=project,
|
| 163 |
+
language=lang,
|
| 164 |
+
limit=limit,
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
repo_url = project_config.repo_url.rstrip("/")
|
| 168 |
+
|
| 169 |
+
return {
|
| 170 |
+
"type": "translation.search.response",
|
| 171 |
+
"request": {
|
| 172 |
+
"type": "translation.search.request",
|
| 173 |
+
"project": project,
|
| 174 |
+
"target_language": lang,
|
| 175 |
+
"limit": limit,
|
| 176 |
+
"include_status_report": include_status_report,
|
| 177 |
+
},
|
| 178 |
+
"files": [
|
| 179 |
+
{
|
| 180 |
+
"rank": index,
|
| 181 |
+
"path": path,
|
| 182 |
+
"repo_url": f"{repo_url}/blob/main/{path}",
|
| 183 |
+
"metadata": {
|
| 184 |
+
"project": project,
|
| 185 |
+
"target_language": lang,
|
| 186 |
+
"docs_path": project_config.docs_path,
|
| 187 |
+
},
|
| 188 |
+
}
|
| 189 |
+
for index, path in enumerate(candidate_paths, start=1)
|
| 190 |
+
],
|
| 191 |
+
"total_candidates": len(candidate_paths),
|
| 192 |
+
"status_report": status_report if include_status_report else None,
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def build_missing_list_response(
|
| 197 |
+
project: str,
|
| 198 |
+
lang: str,
|
| 199 |
+
limit: int,
|
| 200 |
+
) -> Dict[str, Any]:
|
| 201 |
+
"""
|
| 202 |
+
누락 번역 파일 목록만 제공하는 응답(JSON).
|
| 203 |
+
"""
|
| 204 |
+
project = project.strip()
|
| 205 |
+
lang = lang.strip()
|
| 206 |
+
limit_int = max(1, int(limit))
|
| 207 |
+
|
| 208 |
+
status_report, missing_paths, project_config = _compute_missing_translations(
|
| 209 |
+
project_key=project,
|
| 210 |
+
language=lang,
|
| 211 |
+
limit=limit_int,
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
repo_url = project_config.repo_url.rstrip("/")
|
| 215 |
+
|
| 216 |
+
return {
|
| 217 |
+
"type": "translation.missing_list",
|
| 218 |
+
"project": project,
|
| 219 |
+
"target_language": lang,
|
| 220 |
+
"limit": limit_int,
|
| 221 |
+
"count": len(missing_paths),
|
| 222 |
+
"files": [
|
| 223 |
+
{
|
| 224 |
+
"rank": index,
|
| 225 |
+
"path": path,
|
| 226 |
+
"repo_url": f"{repo_url}/blob/main/{path}",
|
| 227 |
+
"metadata": {
|
| 228 |
+
"project": project,
|
| 229 |
+
"target_language": lang,
|
| 230 |
+
"docs_path": project_config.docs_path,
|
| 231 |
+
},
|
| 232 |
+
}
|
| 233 |
+
for index, path in enumerate(missing_paths, start=1)
|
| 234 |
+
],
|
| 235 |
+
"status_report": status_report, # 필요 없다면 제거 가능
|
| 236 |
+
}
|
external/mcp-servers/hf-translation-docs-explorer/setting.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any, Dict
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
try:
|
| 10 |
+
import yaml # type: ignore
|
| 11 |
+
except Exception:
|
| 12 |
+
yaml = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class AppSettings:
|
| 17 |
+
github_token: str = ""
|
| 18 |
+
request_timeout_seconds: int = 30
|
| 19 |
+
default_language: str = "ko"
|
| 20 |
+
default_limit: int = 5
|
| 21 |
+
ui_title: str = "Translation MCP Server"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _load_yaml(path: Path) -> Dict[str, Any]:
|
| 25 |
+
if not path.is_file():
|
| 26 |
+
return {}
|
| 27 |
+
if yaml is None:
|
| 28 |
+
return {}
|
| 29 |
+
with path.open("r", encoding="utf-8") as f:
|
| 30 |
+
data = yaml.safe_load(f) or {}
|
| 31 |
+
return data if isinstance(data, dict) else {}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def load_settings(config_path: str = "configs/default.yaml") -> AppSettings:
|
| 35 |
+
cfg = _load_yaml(Path(config_path))
|
| 36 |
+
|
| 37 |
+
github_cfg = cfg.get("github", {}) if isinstance(cfg.get("github"), dict) else {}
|
| 38 |
+
trans_cfg = cfg.get("translation", {}) if isinstance(cfg.get("translation"), dict) else {}
|
| 39 |
+
ui_cfg = cfg.get("ui", {}) if isinstance(cfg.get("ui"), dict) else {}
|
| 40 |
+
|
| 41 |
+
# ENV > YAML
|
| 42 |
+
github_token = os.getenv("GITHUB_TOKEN", github_cfg.get("token", ""))
|
| 43 |
+
request_timeout_seconds = int(
|
| 44 |
+
os.getenv("REQUEST_TIMEOUT_SECONDS", github_cfg.get("request_timeout_seconds", 30))
|
| 45 |
+
)
|
| 46 |
+
default_language = os.getenv("DEFAULT_LANGUAGE", trans_cfg.get("default_language", "ko"))
|
| 47 |
+
default_limit = int(
|
| 48 |
+
os.getenv("DEFAULT_LIMIT", trans_cfg.get("default_limit", 5))
|
| 49 |
+
)
|
| 50 |
+
ui_title = ui_cfg.get("title", "Translation MCP Server")
|
| 51 |
+
|
| 52 |
+
return AppSettings(
|
| 53 |
+
github_token=github_token,
|
| 54 |
+
request_timeout_seconds=request_timeout_seconds,
|
| 55 |
+
default_language=default_language,
|
| 56 |
+
default_limit=default_limit,
|
| 57 |
+
ui_title=ui_title,
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# 전역 설정 인스턴스
|
| 62 |
+
SETTINGS: AppSettings = load_settings()
|
external/mcp-servers/hf-translation-docs-explorer/tools.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict
|
| 4 |
+
|
| 5 |
+
from services import (
|
| 6 |
+
build_project_catalog,
|
| 7 |
+
build_search_response,
|
| 8 |
+
build_missing_list_response,
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def list_projects() -> Dict[str, Any]:
|
| 13 |
+
"""
|
| 14 |
+
Gradio + MCP에서 사용되는 'translation_project_catalog' 엔드포인트.
|
| 15 |
+
입력값 없이 전체 프로젝트 카탈로그를 반환한다.
|
| 16 |
+
"""
|
| 17 |
+
return build_project_catalog(default=None)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def search_files(
|
| 21 |
+
project: str,
|
| 22 |
+
lang: str,
|
| 23 |
+
limit: float | int,
|
| 24 |
+
include_status_report: bool,
|
| 25 |
+
) -> Dict[str, Any]:
|
| 26 |
+
"""
|
| 27 |
+
Gradio + MCP에서 사용되는 'translation_file_search' 엔드포인트.
|
| 28 |
+
"""
|
| 29 |
+
return build_search_response(
|
| 30 |
+
project=project,
|
| 31 |
+
lang=lang,
|
| 32 |
+
limit=int(limit or 1),
|
| 33 |
+
include_status_report=bool(include_status_report),
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def list_missing_files(
|
| 38 |
+
project: str,
|
| 39 |
+
lang: str,
|
| 40 |
+
limit: float | int,
|
| 41 |
+
) -> Dict[str, Any]:
|
| 42 |
+
"""
|
| 43 |
+
Gradio + MCP에서 사용되는 'translation_missing_list' 엔드포인트.
|
| 44 |
+
누락 파일 리스트만 반환.
|
| 45 |
+
"""
|
| 46 |
+
return build_missing_list_response(
|
| 47 |
+
project=project,
|
| 48 |
+
lang=lang,
|
| 49 |
+
limit=int(limit or 1),
|
| 50 |
+
)
|
external/mcp-servers/hf-translation-reviewer
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
Subproject commit d3b82f5b7eba8b3121d9f49792908edb59758e47
|
|
|
|
|
|
external/mcp-servers/hf-translation-reviewer/.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
external/mcp-servers/hf-translation-reviewer/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: LLM Translation Reviewer
|
| 3 |
+
emoji: 🦀
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: gray
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 5.49.1
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
external/mcp-servers/hf-translation-reviewer/adapters.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
from typing import Dict, List, Optional
|
| 5 |
+
|
| 6 |
+
from urllib.parse import urlparse
|
| 7 |
+
|
| 8 |
+
import requests
|
| 9 |
+
|
| 10 |
+
from setting import SETTINGS
|
| 11 |
+
|
| 12 |
+
# Optional provider SDKs
|
| 13 |
+
try:
|
| 14 |
+
import openai # type: ignore
|
| 15 |
+
except Exception:
|
| 16 |
+
openai = None
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
import anthropic # type: ignore
|
| 20 |
+
except Exception:
|
| 21 |
+
anthropic = None
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
import google.generativeai as genai # type: ignore
|
| 25 |
+
except Exception:
|
| 26 |
+
genai = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ---------------- GitHub HTTP adapters -----------------
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def github_request(
|
| 33 |
+
url: str,
|
| 34 |
+
token: str,
|
| 35 |
+
params: Optional[Dict[str, str]] = None,
|
| 36 |
+
) -> Dict:
|
| 37 |
+
headers = {
|
| 38 |
+
"Accept": "application/vnd.github.v3+json",
|
| 39 |
+
"Authorization": f"token {token}",
|
| 40 |
+
}
|
| 41 |
+
response = requests.get(url, headers=headers, params=params, timeout=30)
|
| 42 |
+
if response.status_code == 404:
|
| 43 |
+
raise FileNotFoundError(f"GitHub resource not found: {url}")
|
| 44 |
+
if response.status_code == 401:
|
| 45 |
+
raise PermissionError("GitHub token is invalid or lacks necessary scopes.")
|
| 46 |
+
if response.status_code >= 400:
|
| 47 |
+
raise RuntimeError(
|
| 48 |
+
f"GitHub API request failed with status {response.status_code}: {response.text}"
|
| 49 |
+
)
|
| 50 |
+
return response.json()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def fetch_file_from_pr(
|
| 54 |
+
repo_name: str,
|
| 55 |
+
pr_number: int,
|
| 56 |
+
path: str,
|
| 57 |
+
head_sha: str,
|
| 58 |
+
github_token: str,
|
| 59 |
+
) -> str:
|
| 60 |
+
url = f"{SETTINGS.github_api_base}/repos/{repo_name}/contents/{path}"
|
| 61 |
+
data = github_request(url, github_token, params={"ref": head_sha})
|
| 62 |
+
content = data.get("content")
|
| 63 |
+
encoding = data.get("encoding")
|
| 64 |
+
if content is None or encoding != "base64":
|
| 65 |
+
raise ValueError(
|
| 66 |
+
f"Unexpected content response for '{path}' (encoding={encoding!r})."
|
| 67 |
+
)
|
| 68 |
+
decoded = base64.b64decode(content)
|
| 69 |
+
try:
|
| 70 |
+
return decoded.decode("utf-8")
|
| 71 |
+
except UnicodeDecodeError as exc:
|
| 72 |
+
raise ValueError(
|
| 73 |
+
f"File '{path}' in PR {pr_number} is not valid UTF-8 text"
|
| 74 |
+
) from exc
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ---------------- LLM provider adapters -----------------
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def call_openai(
|
| 81 |
+
token: str,
|
| 82 |
+
system_prompt: str,
|
| 83 |
+
user_prompt: str,
|
| 84 |
+
model_name: str = "gpt-5",
|
| 85 |
+
) -> str:
|
| 86 |
+
if openai is None:
|
| 87 |
+
raise RuntimeError("openai package not installed. Install with `pip install openai`.")
|
| 88 |
+
client = openai.OpenAI(api_key=token)
|
| 89 |
+
params = {
|
| 90 |
+
"model": model_name,
|
| 91 |
+
"messages": [
|
| 92 |
+
{"role": "system", "content": system_prompt},
|
| 93 |
+
{"role": "user", "content": user_prompt},
|
| 94 |
+
],
|
| 95 |
+
}
|
| 96 |
+
# Some models (e.g., gpt-5) may not allow custom temperature.
|
| 97 |
+
if model_name not in {"gpt-5"}:
|
| 98 |
+
params["temperature"] = 0.2
|
| 99 |
+
response = client.chat.completions.create(**params)
|
| 100 |
+
return response.choices[0].message.content.strip()
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def call_anthropic(
|
| 104 |
+
token: str,
|
| 105 |
+
system_prompt: str,
|
| 106 |
+
user_prompt: str,
|
| 107 |
+
model_name: str = "claude-3-5-sonnet-20240620",
|
| 108 |
+
) -> str:
|
| 109 |
+
if anthropic is None:
|
| 110 |
+
raise RuntimeError("anthropic package not installed. Install with `pip install anthropic`.")
|
| 111 |
+
client = anthropic.Anthropic(api_key=token)
|
| 112 |
+
response = client.messages.create(
|
| 113 |
+
model=model_name,
|
| 114 |
+
system=system_prompt,
|
| 115 |
+
max_tokens=1500,
|
| 116 |
+
temperature=0.2,
|
| 117 |
+
messages=[{"role": "user", "content": user_prompt}],
|
| 118 |
+
)
|
| 119 |
+
return "".join(block.text for block in response.content if hasattr(block, "text")).strip()
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def call_gemini(
|
| 123 |
+
token: str,
|
| 124 |
+
system_prompt: str,
|
| 125 |
+
user_prompt: str,
|
| 126 |
+
model_name: str = "gemini-1.5-pro",
|
| 127 |
+
) -> str:
|
| 128 |
+
if genai is None:
|
| 129 |
+
raise RuntimeError("google-generativeai package not installed. Install with `pip install google-generativeai`.")
|
| 130 |
+
genai.configure(api_key=token)
|
| 131 |
+
model = genai.GenerativeModel(model_name)
|
| 132 |
+
prompt = f"{system_prompt}\n\n{user_prompt}"
|
| 133 |
+
response = model.generate_content(prompt, generation_config={"temperature": 0.2})
|
| 134 |
+
return response.text.strip()
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
PROVIDERS = {
|
| 138 |
+
"openai": call_openai,
|
| 139 |
+
"anthropic": call_anthropic,
|
| 140 |
+
"gemini": call_gemini,
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def dispatch_review(
|
| 145 |
+
provider: str,
|
| 146 |
+
token: str,
|
| 147 |
+
system_prompt: str,
|
| 148 |
+
user_prompt: str,
|
| 149 |
+
model_name: str,
|
| 150 |
+
) -> str:
|
| 151 |
+
if provider not in PROVIDERS:
|
| 152 |
+
raise ValueError(f"Unknown provider '{provider}'. Choose from: {', '.join(PROVIDERS)}")
|
| 153 |
+
return PROVIDERS[provider](token, system_prompt, user_prompt, model_name)
|
external/mcp-servers/hf-translation-reviewer/app.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Gradio + MCP server app for LLM translation review on GitHub PRs.
|
| 4 |
+
|
| 5 |
+
- UI만 담당하고, 실제 로직은 tools/services/adapters 로 분리.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
|
| 12 |
+
import gradio as gr
|
| 13 |
+
|
| 14 |
+
from setting import SETTINGS
|
| 15 |
+
from tools import (
|
| 16 |
+
tool_prepare,
|
| 17 |
+
tool_review_and_emit,
|
| 18 |
+
tool_submit_review,
|
| 19 |
+
tool_end_to_end,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def build_ui() -> gr.Blocks:
|
| 24 |
+
with gr.Blocks(title=SETTINGS.ui_title) as demo:
|
| 25 |
+
gr.Markdown(
|
| 26 |
+
"# LLM Translation Reviewer for GitHub PRs (MCP-enabled)\n"
|
| 27 |
+
"Only **PR URL** + fields below are required. Repo/PR number are parsed."
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
# 공통 입력 영역
|
| 31 |
+
with gr.Row():
|
| 32 |
+
pr_url = gr.Textbox(
|
| 33 |
+
label="PR URL",
|
| 34 |
+
placeholder="https://github.com/owner/repo/pull/123",
|
| 35 |
+
scale=2,
|
| 36 |
+
)
|
| 37 |
+
provider = gr.Dropdown(
|
| 38 |
+
label="Provider",
|
| 39 |
+
choices=["openai", "anthropic", "gemini"],
|
| 40 |
+
value=SETTINGS.default_provider,
|
| 41 |
+
)
|
| 42 |
+
model_name = gr.Textbox(
|
| 43 |
+
label="Model name",
|
| 44 |
+
value=SETTINGS.default_model,
|
| 45 |
+
placeholder=(
|
| 46 |
+
"e.g., gpt-5 / gpt-4o / claude-3-5-sonnet-20240620 / gemini-1.5-pro"
|
| 47 |
+
),
|
| 48 |
+
)
|
| 49 |
+
with gr.Row():
|
| 50 |
+
provider_token = gr.Textbox(
|
| 51 |
+
label="Provider API Token",
|
| 52 |
+
type="password",
|
| 53 |
+
)
|
| 54 |
+
github_token = gr.Textbox(
|
| 55 |
+
label="GitHub Token",
|
| 56 |
+
type="password",
|
| 57 |
+
)
|
| 58 |
+
with gr.Row():
|
| 59 |
+
original_path = gr.Textbox(
|
| 60 |
+
label="Original File Path (in repo)",
|
| 61 |
+
placeholder="docs/source/en/xxx.md",
|
| 62 |
+
)
|
| 63 |
+
translated_path = gr.Textbox(
|
| 64 |
+
label="Translated File Path (in repo)",
|
| 65 |
+
placeholder="docs/source/ko/xxx.md",
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
gr.Markdown("---")
|
| 69 |
+
|
| 70 |
+
# Tool 1: Prepare
|
| 71 |
+
with gr.Accordion(
|
| 72 |
+
"Tool 1: Prepare (Fetch Files + Build Prompts)", open=False
|
| 73 |
+
):
|
| 74 |
+
prepare_btn = gr.Button("tool_prepare")
|
| 75 |
+
prepare_out = gr.JSON(label="Prepare result (files + prompts)")
|
| 76 |
+
|
| 77 |
+
prepare_btn.click(
|
| 78 |
+
fn=tool_prepare,
|
| 79 |
+
inputs=[github_token, pr_url, original_path, translated_path],
|
| 80 |
+
outputs=[prepare_out],
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
# Tool 2: Review + Emit Payload
|
| 84 |
+
with gr.Accordion("Tool 2: Review + Emit Payload", open=False):
|
| 85 |
+
review_btn = gr.Button("tool_review_and_emit")
|
| 86 |
+
|
| 87 |
+
original_text = gr.Textbox(
|
| 88 |
+
label="Original (for review)",
|
| 89 |
+
lines=6,
|
| 90 |
+
)
|
| 91 |
+
translated_text = gr.Textbox(
|
| 92 |
+
label="Translated (for review)",
|
| 93 |
+
lines=10,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
review_out = gr.JSON(
|
| 97 |
+
label="Review result (verdict/summary/comments/event)"
|
| 98 |
+
)
|
| 99 |
+
payload_out = gr.JSON(label="Payload JSON (for GitHub)")
|
| 100 |
+
|
| 101 |
+
def _review_emit_proxy(
|
| 102 |
+
provider_: str,
|
| 103 |
+
provider_token_: str,
|
| 104 |
+
model_name_: str,
|
| 105 |
+
pr_url_: str,
|
| 106 |
+
translated_path_: str,
|
| 107 |
+
original_text_: str,
|
| 108 |
+
translated_text_: str,
|
| 109 |
+
):
|
| 110 |
+
result = tool_review_and_emit(
|
| 111 |
+
provider=provider_,
|
| 112 |
+
provider_token=provider_token_,
|
| 113 |
+
model_name=model_name_,
|
| 114 |
+
pr_url=pr_url_,
|
| 115 |
+
translated_path=translated_path_,
|
| 116 |
+
original=original_text_,
|
| 117 |
+
translated=translated_text_,
|
| 118 |
+
)
|
| 119 |
+
return result, result.get("payload", {})
|
| 120 |
+
|
| 121 |
+
review_btn.click(
|
| 122 |
+
fn=_review_emit_proxy,
|
| 123 |
+
inputs=[
|
| 124 |
+
provider,
|
| 125 |
+
provider_token,
|
| 126 |
+
model_name,
|
| 127 |
+
pr_url,
|
| 128 |
+
translated_path,
|
| 129 |
+
original_text,
|
| 130 |
+
translated_text,
|
| 131 |
+
],
|
| 132 |
+
outputs=[review_out, payload_out],
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
# Tool 3: Submit Review
|
| 136 |
+
with gr.Accordion("Tool 3: Submit Review", open=False):
|
| 137 |
+
submit_btn = gr.Button("tool_submit_review")
|
| 138 |
+
payload_in = gr.Textbox(
|
| 139 |
+
label="Payload or Review JSON (from Tool 2)",
|
| 140 |
+
lines=6,
|
| 141 |
+
)
|
| 142 |
+
submit_out = gr.JSON(label="Submission result")
|
| 143 |
+
|
| 144 |
+
def _submit_proxy(
|
| 145 |
+
github_token_: str,
|
| 146 |
+
pr_url_: str,
|
| 147 |
+
translated_path_: str,
|
| 148 |
+
payload_json_: str,
|
| 149 |
+
):
|
| 150 |
+
try:
|
| 151 |
+
payload_obj = json.loads(payload_json_) if payload_json_ else {}
|
| 152 |
+
except Exception as e:
|
| 153 |
+
raise ValueError(f"Invalid JSON: {e}")
|
| 154 |
+
return tool_submit_review(
|
| 155 |
+
github_token=github_token_,
|
| 156 |
+
pr_url=pr_url_,
|
| 157 |
+
translated_path=translated_path_,
|
| 158 |
+
payload_or_review=payload_obj,
|
| 159 |
+
allow_self_request_changes=True,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
submit_btn.click(
|
| 163 |
+
fn=_submit_proxy,
|
| 164 |
+
inputs=[github_token, pr_url, translated_path, payload_in],
|
| 165 |
+
outputs=[submit_out],
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
gr.Markdown("---")
|
| 169 |
+
|
| 170 |
+
# Tool 4: End-to-End
|
| 171 |
+
with gr.Accordion("Tool 4: End-to-End", open=True):
|
| 172 |
+
e2e_btn = gr.Button("tool_end_to_end")
|
| 173 |
+
save_review = gr.Checkbox(
|
| 174 |
+
label="Save review JSON to file", value=True
|
| 175 |
+
)
|
| 176 |
+
save_path = gr.Textbox(
|
| 177 |
+
label="Save path", value="review.json"
|
| 178 |
+
)
|
| 179 |
+
submit_flag = gr.Checkbox(
|
| 180 |
+
label="Submit to GitHub", value=False
|
| 181 |
+
)
|
| 182 |
+
e2e_out = gr.JSON(label="E2E result")
|
| 183 |
+
|
| 184 |
+
e2e_btn.click(
|
| 185 |
+
fn=tool_end_to_end,
|
| 186 |
+
inputs=[
|
| 187 |
+
provider,
|
| 188 |
+
provider_token,
|
| 189 |
+
model_name,
|
| 190 |
+
github_token,
|
| 191 |
+
pr_url,
|
| 192 |
+
original_path,
|
| 193 |
+
translated_path,
|
| 194 |
+
save_review,
|
| 195 |
+
save_path,
|
| 196 |
+
submit_flag,
|
| 197 |
+
],
|
| 198 |
+
outputs=[e2e_out],
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
gr.Markdown(
|
| 202 |
+
"""
|
| 203 |
+
**Notes**
|
| 204 |
+
- Tool 1: PR에서 파일을 읽고 프롬프트까지 준비합니다.
|
| 205 |
+
- Tool 2: LLM으로 리뷰한 뒤, GitHub 리뷰 payload까지 생성합니다.
|
| 206 |
+
- Tool 3: Tool 2에서 만든 payload JSON을 그대로 넣고 GitHub에 전송합니다.
|
| 207 |
+
- Tool 4: 파일 로드부터 리뷰/저장/제출까지 한 번에 처리하는 end-to-end 툴입니다.
|
| 208 |
+
- `launch(mcp_server=True)` 이므로 각 `tool_*` 버튼은 MCP 툴로도 사용 가능합니다.
|
| 209 |
+
"""
|
| 210 |
+
)
|
| 211 |
+
return demo
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
if __name__ == "__main__":
|
| 215 |
+
ui = build_ui()
|
| 216 |
+
ui.launch(
|
| 217 |
+
share=SETTINGS.ui_share,
|
| 218 |
+
mcp_server=SETTINGS.ui_launch_mcp_server,
|
| 219 |
+
)
|
external/mcp-servers/hf-translation-reviewer/configs/default.yaml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
provider:
|
| 2 |
+
default: "openai"
|
| 3 |
+
model: "gpt-5"
|
| 4 |
+
|
| 5 |
+
github:
|
| 6 |
+
api_base: "https://api.github.com"
|
| 7 |
+
|
| 8 |
+
ui:
|
| 9 |
+
title: "LLM Translation Reviewer (PR) — MCP Tools"
|
| 10 |
+
share: true
|
| 11 |
+
launch_mcp_server: true
|
external/mcp-servers/hf-translation-reviewer/requirements.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core dependencies
|
| 2 |
+
requests>=2.31.0
|
| 3 |
+
gradio>=5.0.0
|
| 4 |
+
|
| 5 |
+
# LLM providers (optional, choose what you use)
|
| 6 |
+
openai>=1.12.0
|
| 7 |
+
anthropic>=0.34.0
|
| 8 |
+
google-generativeai>=0.5.0
|
| 9 |
+
|
| 10 |
+
# Typing helpers (optional, for static analysis)
|
| 11 |
+
typing-extensions>=4.8.0
|
| 12 |
+
|
| 13 |
+
# Python version note
|
| 14 |
+
# Python >=3.9 recommended
|
external/mcp-servers/hf-translation-reviewer/services.py
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import re
|
| 5 |
+
import textwrap
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Dict, List, Optional, Tuple
|
| 8 |
+
|
| 9 |
+
from urllib.parse import urlparse
|
| 10 |
+
|
| 11 |
+
import requests
|
| 12 |
+
|
| 13 |
+
from setting import SETTINGS
|
| 14 |
+
from adapters import github_request, fetch_file_from_pr, dispatch_review
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
PROMPT_TEMPLATE = textwrap.dedent(
|
| 18 |
+
"""
|
| 19 |
+
You are a meticulous bilingual reviewer checking a translation PR.
|
| 20 |
+
|
| 21 |
+
PR number: {pr_number}
|
| 22 |
+
PR URL: {pr_url}
|
| 23 |
+
|
| 24 |
+
Review the translated text against the original and focus on:
|
| 25 |
+
1. Are there any typos or spelling mistakes?
|
| 26 |
+
2. Are any sentences difficult to understand?
|
| 27 |
+
3. Is the overall content hard to comprehend?
|
| 28 |
+
|
| 29 |
+
Always respond with strict JSON using this schema:
|
| 30 |
+
{{
|
| 31 |
+
"verdict": "request_changes" | "comment" | "approve",
|
| 32 |
+
"summary": "<High-level Markdown summary of the review findings>",
|
| 33 |
+
"comments": [
|
| 34 |
+
{{
|
| 35 |
+
"line": <1-based line number in the translated file>,
|
| 36 |
+
"issue": "<Short Markdown description of the problem>",
|
| 37 |
+
"suggested_edit": "<Replacement text for the entire translated line>",
|
| 38 |
+
"context": "<Exact current text of that line for grounding>"
|
| 39 |
+
}},
|
| 40 |
+
...
|
| 41 |
+
]
|
| 42 |
+
}}
|
| 43 |
+
|
| 44 |
+
Guidelines:
|
| 45 |
+
- Only include comments for issues that warrant direct feedback.
|
| 46 |
+
- When a concrete rewrite is possible, populate "suggested_edit" with the full replacement line exactly as it should appear after fixing the issue.
|
| 47 |
+
- Keep edits scoped to the referenced line; do not span multiple lines.
|
| 48 |
+
- Always copy the current text of that line verbatim into "context".
|
| 49 |
+
- Omit the "suggested_edit" field or set it to an empty string if no suggestion is available.
|
| 50 |
+
- Use "request_changes" when the identified problems must be fixed before merging.
|
| 51 |
+
- Use "approve" only when the translation is correct and clear with no changes needed.
|
| 52 |
+
- For optional improvements or general observations, use "comment".
|
| 53 |
+
- Keep suggestions tightly scoped so they can be applied as GitHub suggestions.
|
| 54 |
+
- Do not output partial fragments in "suggested_edit"; always provide the entire replacement line including unchanged portions.
|
| 55 |
+
- Use the line numbers from the "TRANSLATED TEXT WITH LINE NUMBERS" section.
|
| 56 |
+
"""
|
| 57 |
+
).strip()
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# --------------------- Core helpers ------------------
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def parse_pr_url(pr_url: str) -> Tuple[str, int]:
|
| 64 |
+
"""Extract repo (owner/name) and PR number from a GitHub PR URL."""
|
| 65 |
+
if not pr_url:
|
| 66 |
+
raise ValueError("PR URL is required")
|
| 67 |
+
parsed = urlparse(pr_url)
|
| 68 |
+
parts = [p for p in parsed.path.split("/") if p]
|
| 69 |
+
# Expect: [owner, repo, 'pull', pr_number, ...]
|
| 70 |
+
if len(parts) < 4 or parts[2] != "pull":
|
| 71 |
+
raise ValueError(f"Not a valid GitHub PR URL: {pr_url}")
|
| 72 |
+
owner, repo, _, num = parts[0], parts[1], parts[2], parts[3]
|
| 73 |
+
if not num.isdigit():
|
| 74 |
+
raise ValueError(f"PR number not found in URL: {pr_url}")
|
| 75 |
+
return f"{owner}/{repo}", int(num)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def add_line_numbers(text: str) -> str:
|
| 79 |
+
return "\n".join(f"{i:04d}: {line}" for i, line in enumerate(text.splitlines(), 1))
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def load_pr_files(
|
| 83 |
+
github_token: str,
|
| 84 |
+
pr_url: str,
|
| 85 |
+
original_path: str,
|
| 86 |
+
translated_path: str,
|
| 87 |
+
) -> Tuple[str, int, str, str]:
|
| 88 |
+
repo_name, pr_number = parse_pr_url(pr_url)
|
| 89 |
+
pr_api = f"{SETTINGS.github_api_base}/repos/{repo_name}/pulls/{pr_number}"
|
| 90 |
+
pr_data = github_request(pr_api, github_token)
|
| 91 |
+
head_sha = pr_data.get("head", {}).get("sha")
|
| 92 |
+
if not head_sha:
|
| 93 |
+
raise RuntimeError(f"Unable to determine head SHA for PR {pr_number} in {repo_name}.")
|
| 94 |
+
original = fetch_file_from_pr(repo_name, pr_number, original_path, head_sha, github_token)
|
| 95 |
+
translated = fetch_file_from_pr(repo_name, pr_number, translated_path, head_sha, github_token)
|
| 96 |
+
return repo_name, pr_number, original, translated
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def build_messages(
|
| 100 |
+
original: str,
|
| 101 |
+
translated: str,
|
| 102 |
+
pr_number: int,
|
| 103 |
+
pr_url: str,
|
| 104 |
+
) -> Tuple[str, str]:
|
| 105 |
+
system_prompt = (
|
| 106 |
+
"You are an expert translation reviewer ensuring clarity, accuracy, "
|
| 107 |
+
"and readability of localized documentation."
|
| 108 |
+
)
|
| 109 |
+
user_prompt = (
|
| 110 |
+
f"{PROMPT_TEMPLATE}\n\n"
|
| 111 |
+
"----- ORIGINAL TEXT -----\n"
|
| 112 |
+
f"{original}\n\n"
|
| 113 |
+
"----- TRANSLATED TEXT -----\n"
|
| 114 |
+
f"{translated}\n\n"
|
| 115 |
+
"----- TRANSLATED TEXT WITH LINE NUMBERS -----\n"
|
| 116 |
+
f"{add_line_numbers(translated)}"
|
| 117 |
+
)
|
| 118 |
+
return system_prompt, user_prompt
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def normalize_summary_for_body(summary: str) -> str:
|
| 122 |
+
"""
|
| 123 |
+
GitHub review body로 쓸 텍스트 정리.
|
| 124 |
+
"""
|
| 125 |
+
s = (summary or "").strip()
|
| 126 |
+
if not s:
|
| 127 |
+
return "LLM translation review"
|
| 128 |
+
|
| 129 |
+
if s.startswith("{") or s.startswith("["):
|
| 130 |
+
try:
|
| 131 |
+
obj = json.loads(s)
|
| 132 |
+
if isinstance(obj, dict):
|
| 133 |
+
inner = obj.get("summary")
|
| 134 |
+
if isinstance(inner, str) and inner.strip():
|
| 135 |
+
return inner.strip()
|
| 136 |
+
except Exception:
|
| 137 |
+
return s
|
| 138 |
+
|
| 139 |
+
return s
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ----------------------- Parsing & GitHub glue ----------------------
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _extract_json_candidates(raw_response: str) -> List[str]:
|
| 146 |
+
candidates: List[str] = []
|
| 147 |
+
for match in re.finditer(r"```(?:json)?\s*(\{.*?\})\s*```", raw_response, re.DOTALL):
|
| 148 |
+
snippet = match.group(1).strip()
|
| 149 |
+
if snippet:
|
| 150 |
+
candidates.append(snippet)
|
| 151 |
+
stripped = raw_response.strip()
|
| 152 |
+
if stripped:
|
| 153 |
+
candidates.append(stripped)
|
| 154 |
+
return candidates
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def parse_review_response(raw_response: str) -> Tuple[str, str, List[Dict[str, object]]]:
|
| 158 |
+
parsed: Optional[Dict[str, object]] = None
|
| 159 |
+
for candidate in _extract_json_candidates(raw_response):
|
| 160 |
+
try:
|
| 161 |
+
parsed_candidate = json.loads(candidate)
|
| 162 |
+
except json.JSONDecodeError:
|
| 163 |
+
continue
|
| 164 |
+
if isinstance(parsed_candidate, dict):
|
| 165 |
+
parsed = parsed_candidate
|
| 166 |
+
break
|
| 167 |
+
if parsed is None:
|
| 168 |
+
return "comment", raw_response.strip(), []
|
| 169 |
+
|
| 170 |
+
verdict = parsed.get("verdict", "comment")
|
| 171 |
+
summary = parsed.get("summary", "").strip()
|
| 172 |
+
comments = parsed.get("comments", [])
|
| 173 |
+
|
| 174 |
+
if not isinstance(verdict, str):
|
| 175 |
+
verdict = "comment"
|
| 176 |
+
verdict = verdict.lower()
|
| 177 |
+
if verdict not in {"request_changes", "comment", "approve"}:
|
| 178 |
+
verdict = "comment"
|
| 179 |
+
|
| 180 |
+
if not summary:
|
| 181 |
+
summary = raw_response.strip()
|
| 182 |
+
|
| 183 |
+
if not isinstance(comments, list):
|
| 184 |
+
comments = []
|
| 185 |
+
|
| 186 |
+
normalized_comments: List[Dict[str, object]] = []
|
| 187 |
+
for comment in comments:
|
| 188 |
+
if not isinstance(comment, dict):
|
| 189 |
+
continue
|
| 190 |
+
line = comment.get("line")
|
| 191 |
+
issue = comment.get("issue", "").strip()
|
| 192 |
+
suggested_edit = comment.get("suggested_edit", "").strip()
|
| 193 |
+
context = comment.get("context", "").strip()
|
| 194 |
+
if not isinstance(line, int) or line <= 0:
|
| 195 |
+
continue
|
| 196 |
+
if not issue:
|
| 197 |
+
continue
|
| 198 |
+
normalized_comments.append(
|
| 199 |
+
{
|
| 200 |
+
"line": line,
|
| 201 |
+
"issue": issue,
|
| 202 |
+
"suggested_edit": suggested_edit,
|
| 203 |
+
"context": context,
|
| 204 |
+
}
|
| 205 |
+
)
|
| 206 |
+
return verdict, summary, normalized_comments
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def review_event_from_verdict(verdict: str) -> str:
|
| 210 |
+
return {
|
| 211 |
+
"request_changes": "REQUEST_CHANGES",
|
| 212 |
+
"comment": "COMMENT",
|
| 213 |
+
"approve": "APPROVE",
|
| 214 |
+
}.get(verdict, "COMMENT")
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def build_review_comments(
|
| 218 |
+
translated_path: str,
|
| 219 |
+
comments: List[Dict[str, object]],
|
| 220 |
+
) -> List[Dict[str, object]]:
|
| 221 |
+
review_comments: List[Dict[str, object]] = []
|
| 222 |
+
for comment in comments:
|
| 223 |
+
line = int(comment["line"])
|
| 224 |
+
issue = str(comment["issue"]).strip()
|
| 225 |
+
raw_suggested = comment.get("suggested_edit", "")
|
| 226 |
+
if isinstance(raw_suggested, str):
|
| 227 |
+
suggested_edit = raw_suggested.rstrip("\r\n")
|
| 228 |
+
else:
|
| 229 |
+
suggested_edit = str(raw_suggested).rstrip("\r\n") if raw_suggested else ""
|
| 230 |
+
context = str(comment.get("context", "")).rstrip("\n")
|
| 231 |
+
full_line_suggestion = suggested_edit.rstrip("\n") if suggested_edit else ""
|
| 232 |
+
|
| 233 |
+
body_parts = [issue]
|
| 234 |
+
if context:
|
| 235 |
+
body_parts.append(f"> _Current text_: {context}")
|
| 236 |
+
if full_line_suggestion:
|
| 237 |
+
body_parts.append("```suggestion\n" + full_line_suggestion + "\n```")
|
| 238 |
+
|
| 239 |
+
body = "\n\n".join(body_parts).strip()
|
| 240 |
+
review_comments.append(
|
| 241 |
+
{
|
| 242 |
+
"path": translated_path,
|
| 243 |
+
"side": "RIGHT",
|
| 244 |
+
"line": line,
|
| 245 |
+
"body": body,
|
| 246 |
+
}
|
| 247 |
+
)
|
| 248 |
+
return review_comments
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def attach_translated_line_context(
|
| 252 |
+
translated_text: str,
|
| 253 |
+
comments: List[Dict[str, object]],
|
| 254 |
+
) -> None:
|
| 255 |
+
if not comments:
|
| 256 |
+
return
|
| 257 |
+
lines = translated_text.splitlines()
|
| 258 |
+
for comment in comments:
|
| 259 |
+
line_idx = comment.get("line")
|
| 260 |
+
if not isinstance(line_idx, int):
|
| 261 |
+
continue
|
| 262 |
+
list_index = line_idx - 1
|
| 263 |
+
if list_index < 0 or list_index >= len(lines):
|
| 264 |
+
continue
|
| 265 |
+
current_line = lines[list_index].rstrip("\n")
|
| 266 |
+
if not comment.get("context"):
|
| 267 |
+
comment["context"] = current_line
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def build_github_review_payload(
|
| 271 |
+
body: str,
|
| 272 |
+
event: str = "COMMENT",
|
| 273 |
+
comments: Optional[List[Dict[str, object]]] = None,
|
| 274 |
+
) -> Dict[str, object]:
|
| 275 |
+
payload: Dict[str, object] = {"event": event, "body": body}
|
| 276 |
+
if comments:
|
| 277 |
+
payload["comments"] = comments
|
| 278 |
+
return payload
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def submit_pr_review(
|
| 282 |
+
repo_name: str,
|
| 283 |
+
pr_number: int,
|
| 284 |
+
github_token: str,
|
| 285 |
+
body: str,
|
| 286 |
+
event: str,
|
| 287 |
+
comments: Optional[List[Dict[str, object]]] = None,
|
| 288 |
+
allow_self_request_changes: bool = True,
|
| 289 |
+
) -> Tuple[Dict, str]:
|
| 290 |
+
"""
|
| 291 |
+
GitHub PR 리뷰 전송 (self-review REQUEST_CHANGES 우회 포함).
|
| 292 |
+
"""
|
| 293 |
+
url = f"{SETTINGS.github_api_base}/repos/{repo_name}/pulls/{pr_number}/reviews"
|
| 294 |
+
headers = {
|
| 295 |
+
"Accept": "application/vnd.github.v3+json",
|
| 296 |
+
"Authorization": f"token {github_token}",
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
def _post(event_to_use: str, body_to_use: str) -> requests.Response:
|
| 300 |
+
payload = build_github_review_payload(
|
| 301 |
+
body=body_to_use,
|
| 302 |
+
event=event_to_use,
|
| 303 |
+
comments=comments,
|
| 304 |
+
)
|
| 305 |
+
return requests.post(url, headers=headers, json=payload, timeout=30)
|
| 306 |
+
|
| 307 |
+
# 1차 요청
|
| 308 |
+
response = _post(event, body)
|
| 309 |
+
|
| 310 |
+
if response.status_code == 401:
|
| 311 |
+
raise PermissionError(
|
| 312 |
+
"GitHub token is invalid or lacks permission to submit a review."
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
# 본인 PR + REQUEST_CHANGES 케이스 처리
|
| 316 |
+
if response.status_code == 422 and event == "REQUEST_CHANGES":
|
| 317 |
+
try:
|
| 318 |
+
error_payload = response.json()
|
| 319 |
+
except ValueError:
|
| 320 |
+
error_payload = {"message": response.text}
|
| 321 |
+
message = str(error_payload.get("message", ""))
|
| 322 |
+
errors = " ".join(str(item) for item in error_payload.get("errors", []))
|
| 323 |
+
combined_error = f"{message} {errors}".strip()
|
| 324 |
+
|
| 325 |
+
if "own pull request" in combined_error.lower():
|
| 326 |
+
if not allow_self_request_changes:
|
| 327 |
+
raise RuntimeError(
|
| 328 |
+
"GitHub does not allow REQUEST_CHANGES on your own pull request: "
|
| 329 |
+
+ combined_error
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
fallback_event = "COMMENT"
|
| 333 |
+
fallback_body = "[REQUEST_CHANGES (self-review)]\n\n" + (body or "").strip()
|
| 334 |
+
|
| 335 |
+
comment_response = _post(fallback_event, fallback_body)
|
| 336 |
+
if comment_response.status_code >= 400:
|
| 337 |
+
raise RuntimeError(
|
| 338 |
+
"Failed to submit fallback self-review comment: "
|
| 339 |
+
f"HTTP {comment_response.status_code} - {comment_response.text}"
|
| 340 |
+
)
|
| 341 |
+
return comment_response.json(), "REQUEST_CHANGES_SELF"
|
| 342 |
+
|
| 343 |
+
if response.status_code >= 400:
|
| 344 |
+
raise RuntimeError(
|
| 345 |
+
"Failed to submit review: "
|
| 346 |
+
f"HTTP {response.status_code} - {response.text}"
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
return response.json(), event
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
# --------------------- High-level domain services ------------------
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def prepare_translation_context(
|
| 356 |
+
github_token: str,
|
| 357 |
+
pr_url: str,
|
| 358 |
+
original_path: str,
|
| 359 |
+
translated_path: str,
|
| 360 |
+
) -> Dict[str, object]:
|
| 361 |
+
"""
|
| 362 |
+
PR에서 파일을 가져와 system/user prompt까지 구성.
|
| 363 |
+
"""
|
| 364 |
+
repo_name, pr_number, original, translated = load_pr_files(
|
| 365 |
+
github_token=github_token,
|
| 366 |
+
pr_url=pr_url,
|
| 367 |
+
original_path=original_path,
|
| 368 |
+
translated_path=translated_path,
|
| 369 |
+
)
|
| 370 |
+
system_prompt, user_prompt = build_messages(
|
| 371 |
+
original=original,
|
| 372 |
+
translated=translated,
|
| 373 |
+
pr_number=pr_number,
|
| 374 |
+
pr_url=pr_url,
|
| 375 |
+
)
|
| 376 |
+
return {
|
| 377 |
+
"repo": repo_name,
|
| 378 |
+
"pr_number": pr_number,
|
| 379 |
+
"original": original,
|
| 380 |
+
"translated": translated,
|
| 381 |
+
"system_prompt": system_prompt,
|
| 382 |
+
"user_prompt": user_prompt,
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def review_and_emit_payload(
|
| 387 |
+
provider: str,
|
| 388 |
+
provider_token: str,
|
| 389 |
+
model_name: str,
|
| 390 |
+
pr_url: str,
|
| 391 |
+
translated_path: str,
|
| 392 |
+
original: str,
|
| 393 |
+
translated: str,
|
| 394 |
+
) -> Dict[str, object]:
|
| 395 |
+
"""
|
| 396 |
+
LLM 리뷰 수행 후 verdict / summary / comments 및 GitHub payload 생성.
|
| 397 |
+
"""
|
| 398 |
+
_, pr_number = parse_pr_url(pr_url)
|
| 399 |
+
system_prompt, user_prompt = build_messages(
|
| 400 |
+
original=original,
|
| 401 |
+
translated=translated,
|
| 402 |
+
pr_number=pr_number,
|
| 403 |
+
pr_url=pr_url,
|
| 404 |
+
)
|
| 405 |
+
|
| 406 |
+
raw = dispatch_review(
|
| 407 |
+
provider=provider,
|
| 408 |
+
token=provider_token,
|
| 409 |
+
system_prompt=system_prompt,
|
| 410 |
+
user_prompt=user_prompt,
|
| 411 |
+
model_name=model_name,
|
| 412 |
+
)
|
| 413 |
+
verdict, summary, comments = parse_review_response(raw)
|
| 414 |
+
attach_translated_line_context(translated, comments)
|
| 415 |
+
|
| 416 |
+
event = review_event_from_verdict(verdict)
|
| 417 |
+
github_comments = build_review_comments(translated_path, comments)
|
| 418 |
+
payload = build_github_review_payload(
|
| 419 |
+
body=summary,
|
| 420 |
+
event=event,
|
| 421 |
+
comments=github_comments,
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
return {
|
| 425 |
+
"verdict": verdict,
|
| 426 |
+
"summary": summary,
|
| 427 |
+
"comments": comments,
|
| 428 |
+
"event": event,
|
| 429 |
+
"payload": payload,
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def submit_review_to_github(
|
| 434 |
+
github_token: str,
|
| 435 |
+
pr_url: str,
|
| 436 |
+
translated_path: str,
|
| 437 |
+
payload_or_review: Dict[str, object],
|
| 438 |
+
allow_self_request_changes: bool = True,
|
| 439 |
+
) -> Dict[str, object]:
|
| 440 |
+
"""
|
| 441 |
+
payload JSON 또는 review JSON을 입력받아 GitHub 리뷰 제출.
|
| 442 |
+
"""
|
| 443 |
+
repo, pr_number = parse_pr_url(pr_url)
|
| 444 |
+
|
| 445 |
+
event = payload_or_review.get("event")
|
| 446 |
+
body = payload_or_review.get("body")
|
| 447 |
+
comments_obj = payload_or_review.get("comments")
|
| 448 |
+
|
| 449 |
+
comments: Optional[List[Dict[str, object]]] = None
|
| 450 |
+
|
| 451 |
+
if isinstance(event, str) and body:
|
| 452 |
+
# 이미 GitHub payload 형식
|
| 453 |
+
event_str = event
|
| 454 |
+
if isinstance(comments_obj, list):
|
| 455 |
+
comments = comments_obj
|
| 456 |
+
body_str = str(body)
|
| 457 |
+
else:
|
| 458 |
+
# review 형식 (verdict/summary/comments)
|
| 459 |
+
verdict = str(payload_or_review.get("verdict", "comment")).lower()
|
| 460 |
+
summary = str(payload_or_review.get("summary", "")).strip()
|
| 461 |
+
review_comments = payload_or_review.get("comments", [])
|
| 462 |
+
if not isinstance(review_comments, list):
|
| 463 |
+
review_comments = []
|
| 464 |
+
|
| 465 |
+
event_str = review_event_from_verdict(verdict)
|
| 466 |
+
body_str = summary if summary else "LLM translation review"
|
| 467 |
+
comments = build_review_comments(translated_path, review_comments)
|
| 468 |
+
|
| 469 |
+
if event_str == "REQUEST_CHANGES" and not body_str.strip() and not comments:
|
| 470 |
+
raise ValueError(
|
| 471 |
+
"REQUEST_CHANGES를 보내려면 review 본문 또는 코멘트가 하나 이상 필요합니다."
|
| 472 |
+
)
|
| 473 |
+
|
| 474 |
+
response, final_event = submit_pr_review(
|
| 475 |
+
repo_name=repo,
|
| 476 |
+
pr_number=pr_number,
|
| 477 |
+
github_token=github_token,
|
| 478 |
+
body=body_str,
|
| 479 |
+
event=event_str,
|
| 480 |
+
comments=comments,
|
| 481 |
+
allow_self_request_changes=allow_self_request_changes,
|
| 482 |
+
)
|
| 483 |
+
return {
|
| 484 |
+
"final_event": final_event,
|
| 485 |
+
"response": response,
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
def run_end_to_end(
|
| 490 |
+
provider: str,
|
| 491 |
+
provider_token: str,
|
| 492 |
+
model_name: str,
|
| 493 |
+
github_token: str,
|
| 494 |
+
pr_url: str,
|
| 495 |
+
original_path: str,
|
| 496 |
+
translated_path: str,
|
| 497 |
+
save_review: bool = False,
|
| 498 |
+
save_path: str = "review.json",
|
| 499 |
+
submit_review_flag: bool = False,
|
| 500 |
+
) -> Dict[str, object]:
|
| 501 |
+
repo, pr_number, original, translated = load_pr_files(
|
| 502 |
+
github_token=github_token,
|
| 503 |
+
pr_url=pr_url,
|
| 504 |
+
original_path=original_path,
|
| 505 |
+
translated_path=translated_path,
|
| 506 |
+
)
|
| 507 |
+
|
| 508 |
+
system_prompt, user_prompt = build_messages(
|
| 509 |
+
original=original,
|
| 510 |
+
translated=translated,
|
| 511 |
+
pr_number=pr_number,
|
| 512 |
+
pr_url=pr_url,
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
raw = dispatch_review(
|
| 516 |
+
provider=provider,
|
| 517 |
+
token=provider_token,
|
| 518 |
+
system_prompt=system_prompt,
|
| 519 |
+
user_prompt=user_prompt,
|
| 520 |
+
model_name=model_name,
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
verdict, summary, comments = parse_review_response(raw)
|
| 524 |
+
attach_translated_line_context(translated, comments)
|
| 525 |
+
|
| 526 |
+
body_for_github = normalize_summary_for_body(summary)
|
| 527 |
+
|
| 528 |
+
github_comments = build_review_comments(translated_path, comments)
|
| 529 |
+
event = review_event_from_verdict(verdict)
|
| 530 |
+
payload = build_github_review_payload(
|
| 531 |
+
body=body_for_github,
|
| 532 |
+
event=event,
|
| 533 |
+
comments=github_comments,
|
| 534 |
+
)
|
| 535 |
+
|
| 536 |
+
saved_file_path: Optional[str] = None
|
| 537 |
+
if save_review:
|
| 538 |
+
p = Path(save_path).expanduser()
|
| 539 |
+
p.write_text(
|
| 540 |
+
json.dumps(
|
| 541 |
+
{
|
| 542 |
+
"verdict": verdict,
|
| 543 |
+
"summary": summary,
|
| 544 |
+
"comments": comments,
|
| 545 |
+
},
|
| 546 |
+
ensure_ascii=False,
|
| 547 |
+
indent=2,
|
| 548 |
+
),
|
| 549 |
+
encoding="utf-8",
|
| 550 |
+
)
|
| 551 |
+
saved_file_path = str(p)
|
| 552 |
+
|
| 553 |
+
submission = None
|
| 554 |
+
if submit_review_flag:
|
| 555 |
+
resp, final_event = submit_pr_review(
|
| 556 |
+
repo_name=repo,
|
| 557 |
+
pr_number=pr_number,
|
| 558 |
+
github_token=github_token,
|
| 559 |
+
body=body_for_github,
|
| 560 |
+
event=event,
|
| 561 |
+
comments=github_comments,
|
| 562 |
+
allow_self_request_changes=True,
|
| 563 |
+
)
|
| 564 |
+
submission = {"final_event": final_event, "response": resp}
|
| 565 |
+
|
| 566 |
+
return {
|
| 567 |
+
"repo": repo,
|
| 568 |
+
"pr_number": pr_number,
|
| 569 |
+
"verdict": verdict,
|
| 570 |
+
"summary": summary,
|
| 571 |
+
"comments": comments,
|
| 572 |
+
"payload": payload,
|
| 573 |
+
"saved_file": saved_file_path,
|
| 574 |
+
"submission": submission,
|
| 575 |
+
}
|
external/mcp-servers/hf-translation-reviewer/setting.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any, Dict, Optional
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
try:
|
| 10 |
+
import yaml # type: ignore
|
| 11 |
+
except Exception:
|
| 12 |
+
yaml = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class AppSettings:
|
| 17 |
+
default_provider: str = "openai"
|
| 18 |
+
default_model: str = "gpt-5"
|
| 19 |
+
github_api_base: str = "https://api.github.com"
|
| 20 |
+
ui_title: str = "LLM Translation Reviewer (PR) — MCP Tools"
|
| 21 |
+
ui_share: bool = True
|
| 22 |
+
ui_launch_mcp_server: bool = True
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _load_yaml(path: Path) -> Dict[str, Any]:
|
| 26 |
+
if not path.is_file():
|
| 27 |
+
return {}
|
| 28 |
+
if yaml is None:
|
| 29 |
+
# yaml 없으면 config 없이 동작
|
| 30 |
+
return {}
|
| 31 |
+
with path.open("r", encoding="utf-8") as f:
|
| 32 |
+
data = yaml.safe_load(f) or {}
|
| 33 |
+
if not isinstance(data, dict):
|
| 34 |
+
return {}
|
| 35 |
+
return data
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def load_settings(config_path: str = "configs/default.yaml") -> AppSettings:
|
| 39 |
+
cfg_path = Path(config_path)
|
| 40 |
+
data = _load_yaml(cfg_path)
|
| 41 |
+
|
| 42 |
+
provider_cfg = data.get("provider", {}) if isinstance(data.get("provider"), dict) else {}
|
| 43 |
+
github_cfg = data.get("github", {}) if isinstance(data.get("github"), dict) else {}
|
| 44 |
+
ui_cfg = data.get("ui", {}) if isinstance(data.get("ui"), dict) else {}
|
| 45 |
+
|
| 46 |
+
default_provider = os.getenv("DEFAULT_PROVIDER", provider_cfg.get("default", "openai"))
|
| 47 |
+
default_model = os.getenv("DEFAULT_MODEL", provider_cfg.get("model", "gpt-5"))
|
| 48 |
+
github_api_base = os.getenv("GITHUB_API_BASE", github_cfg.get("api_base", "https://api.github.com"))
|
| 49 |
+
ui_title = ui_cfg.get("title", "LLM Translation Reviewer (PR) — MCP Tools")
|
| 50 |
+
ui_share = bool(ui_cfg.get("share", True))
|
| 51 |
+
ui_launch_mcp_server = bool(ui_cfg.get("launch_mcp_server", True))
|
| 52 |
+
|
| 53 |
+
return AppSettings(
|
| 54 |
+
default_provider=default_provider,
|
| 55 |
+
default_model=default_model,
|
| 56 |
+
github_api_base=github_api_base,
|
| 57 |
+
ui_title=ui_title,
|
| 58 |
+
ui_share=ui_share,
|
| 59 |
+
ui_launch_mcp_server=ui_launch_mcp_server,
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# 전역 설정 인스턴스
|
| 64 |
+
SETTINGS: AppSettings = load_settings()
|
external/mcp-servers/hf-translation-reviewer/tools.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Dict
|
| 4 |
+
|
| 5 |
+
from services import (
|
| 6 |
+
prepare_translation_context,
|
| 7 |
+
review_and_emit_payload,
|
| 8 |
+
submit_review_to_github,
|
| 9 |
+
run_end_to_end,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def tool_prepare(
|
| 14 |
+
github_token: str,
|
| 15 |
+
pr_url: str,
|
| 16 |
+
original_path: str,
|
| 17 |
+
translated_path: str,
|
| 18 |
+
) -> Dict[str, object]:
|
| 19 |
+
"""
|
| 20 |
+
Tool 1: Fetch Files + Build Prompts
|
| 21 |
+
"""
|
| 22 |
+
return prepare_translation_context(
|
| 23 |
+
github_token=github_token,
|
| 24 |
+
pr_url=pr_url,
|
| 25 |
+
original_path=original_path,
|
| 26 |
+
translated_path=translated_path,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def tool_review_and_emit(
|
| 31 |
+
provider: str,
|
| 32 |
+
provider_token: str,
|
| 33 |
+
model_name: str,
|
| 34 |
+
pr_url: str,
|
| 35 |
+
translated_path: str,
|
| 36 |
+
original: str,
|
| 37 |
+
translated: str,
|
| 38 |
+
) -> Dict[str, object]:
|
| 39 |
+
"""
|
| 40 |
+
Tool 2: LLM Review + Emit Payload
|
| 41 |
+
"""
|
| 42 |
+
return review_and_emit_payload(
|
| 43 |
+
provider=provider,
|
| 44 |
+
provider_token=provider_token,
|
| 45 |
+
model_name=model_name,
|
| 46 |
+
pr_url=pr_url,
|
| 47 |
+
translated_path=translated_path,
|
| 48 |
+
original=original,
|
| 49 |
+
translated=translated,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def tool_submit_review(
|
| 54 |
+
github_token: str,
|
| 55 |
+
pr_url: str,
|
| 56 |
+
translated_path: str,
|
| 57 |
+
payload_or_review: Dict[str, object],
|
| 58 |
+
allow_self_request_changes: bool = True,
|
| 59 |
+
) -> Dict[str, object]:
|
| 60 |
+
"""
|
| 61 |
+
Tool 3: Submit Review
|
| 62 |
+
"""
|
| 63 |
+
return submit_review_to_github(
|
| 64 |
+
github_token=github_token,
|
| 65 |
+
pr_url=pr_url,
|
| 66 |
+
translated_path=translated_path,
|
| 67 |
+
payload_or_review=payload_or_review,
|
| 68 |
+
allow_self_request_changes=allow_self_request_changes,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def tool_end_to_end(
|
| 73 |
+
provider: str,
|
| 74 |
+
provider_token: str,
|
| 75 |
+
model_name: str,
|
| 76 |
+
github_token: str,
|
| 77 |
+
pr_url: str,
|
| 78 |
+
original_path: str,
|
| 79 |
+
translated_path: str,
|
| 80 |
+
save_review: bool = False,
|
| 81 |
+
save_path: str = "review.json",
|
| 82 |
+
submit_review_flag: bool = False,
|
| 83 |
+
) -> Dict[str, object]:
|
| 84 |
+
"""
|
| 85 |
+
Tool 4: End-to-End
|
| 86 |
+
"""
|
| 87 |
+
return run_end_to_end(
|
| 88 |
+
provider=provider,
|
| 89 |
+
provider_token=provider_token,
|
| 90 |
+
model_name=model_name,
|
| 91 |
+
github_token=github_token,
|
| 92 |
+
pr_url=pr_url,
|
| 93 |
+
original_path=original_path,
|
| 94 |
+
translated_path=translated_path,
|
| 95 |
+
save_review=save_review,
|
| 96 |
+
save_path=save_path,
|
| 97 |
+
submit_review_flag=submit_review_flag,
|
| 98 |
+
)
|