Files
awoooi/apps/api/src/services/local_code_review_service.py
Your Name 45cd55b2da
All checks were successful
Code Review / ai-code-review (push) Successful in 11s
CD Pipeline / tests (push) Successful in 5m13s
CD Pipeline / build-and-deploy (push) Successful in 3m31s
CD Pipeline / post-deploy-checks (push) Successful in 1m18s
fix(api): enforce global ollama endpoint order
2026-05-19 12:32:19 +08:00

343 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
AWOOOI — Local Code Review Service (Phase 32, ADR-067)
======================================================
使用 qwen2.5-coder:7b 對 Gitea PR diff 進行自動審查
Fallback: Gemini (diff > 50KB 或 Ollama 超時)
觸發: Gitea webhook push event (PR opened/updated)
雙寫: Redis TTL 7d + PostgreSQL pr_reviews 表永久儲存
並發: semaphore 最多 2 個同時審查
2026-04-10 Claude Sonnet 4.6 Asia/Taipei
"""
from __future__ import annotations
import asyncio
from typing import Any
import httpx
import structlog
from src.core.config import get_settings
from src.services.model_registry import get_model
from src.services.ollama_endpoint_resolver import resolve_ollama_order
logger = structlog.get_logger(__name__)
settings = get_settings()
# D1 集中化 2026-04-11: 從 models.json providers.ollama.models.code_review 讀取
_MODEL_OLLAMA = get_model("ollama", "code_review")
_TIMEOUT_OLLAMA = 120.0
_MAX_DIFF_BYTES = 50 * 1024 # 50KB → fallback to Gemini
_SEMAPHORE = asyncio.Semaphore(2) # 最多 2 個同時審查
# Redis cache TTL: 7 days
_CACHE_TTL = 7 * 86400
class LocalCodeReviewService:
"""PR 自動審查服務"""
def __init__(self) -> None:
self._http: httpx.AsyncClient | None = None
async def _get_http(self) -> httpx.AsyncClient:
if self._http is None or self._http.is_closed:
self._http = httpx.AsyncClient(
timeout=httpx.Timeout(_TIMEOUT_OLLAMA, connect=10.0)
)
return self._http
async def review_pr(
self,
pr_id: str,
repo: str,
title: str,
diff: str,
) -> dict[str, Any] | None:
"""
審查 PR diff回傳審查結果 dict
{review_text, issues_count, model, provider}
"""
async with _SEMAPHORE:
cache_key = f"pr_review:{repo}:{pr_id}"
# 快取命中
try:
from src.core.redis_client import get_redis
redis = await get_redis()
if redis:
cached = await redis.get(cache_key)
if cached:
logger.info("pr_review_cache_hit", pr_id=pr_id)
import json
return json.loads(cached)
except Exception:
redis = None
diff_size = len(diff.encode())
allow_cloud_fallback = settings.LOCAL_CODE_REVIEW_ALLOW_GEMINI_FALLBACK
if diff_size > _MAX_DIFF_BYTES and allow_cloud_fallback:
result = await self._review_with_gemini(pr_id, repo, title, diff)
else:
if diff_size > _MAX_DIFF_BYTES:
logger.info(
"pr_review_large_diff_using_ollama_truncated",
pr_id=pr_id,
diff_size_bytes=diff_size,
cloud_fallback_enabled=allow_cloud_fallback,
)
result = await self._review_with_ollama(pr_id, repo, title, diff)
if result is None and allow_cloud_fallback:
result = await self._review_with_gemini(pr_id, repo, title, diff)
if result is None:
result = self._cloud_fallback_disabled_result(pr_id, repo, title)
result["diff_size_bytes"] = diff_size
# 寫入快取
if redis:
try:
import json
await redis.set(cache_key, json.dumps(result, ensure_ascii=False), ex=_CACHE_TTL)
except Exception:
pass
# 寫入 DB
await self._save_to_db(pr_id, repo, title, diff_size, result)
return result
async def _review_with_ollama(
self, pr_id: str, repo: str, title: str, diff: str
) -> dict[str, Any] | None:
prompt = (
f"你是資深程式審查員,請用繁體中文審查以下 Pull Request。\n"
f"PR: {repo}#{pr_id}{title}\n\n"
"請找出1) 潛在 Bug 或邏輯錯誤 2) 安全問題 3) 效能問題 4) 代碼風格問題\n"
"格式:每個問題獨立一行,以「⚠️」開頭。如果沒有問題,說「✅ 程式碼品質良好」\n\n"
f"=== Diff ===\n{diff[:40000]}\n=== 結束 ==="
)
http = await self._get_http()
for endpoint in resolve_ollama_order("code_review"):
if not endpoint.url:
continue
try:
resp = await http.post(
f"{endpoint.url}/api/generate",
json={
"model": _MODEL_OLLAMA,
"prompt": prompt,
"stream": False,
"options": {"num_predict": 1024, "temperature": 0.1},
},
)
if resp.status_code == 200:
text = resp.json().get("response", "").strip()
issues = text.count("⚠️")
logger.info(
"pr_review_ollama_done",
pr_id=pr_id,
issues=issues,
provider=endpoint.provider_name,
)
return {
"review_text": text,
"issues_count": issues,
"model": _MODEL_OLLAMA,
"provider": endpoint.provider_name,
}
logger.warning(
"pr_review_ollama_http_error",
pr_id=pr_id,
provider=endpoint.provider_name,
status=resp.status_code,
)
except httpx.TimeoutException:
logger.warning(
"pr_review_ollama_timeout",
pr_id=pr_id,
provider=endpoint.provider_name,
)
except Exception as e:
logger.error(
"pr_review_ollama_failed",
pr_id=pr_id,
provider=endpoint.provider_name,
error=str(e),
)
return None
async def _review_with_gemini(
self, pr_id: str, repo: str, title: str, diff: str
) -> dict[str, Any] | None:
if not settings.LOCAL_CODE_REVIEW_ALLOW_GEMINI_FALLBACK:
logger.warning(
"pr_review_gemini_fallback_disabled",
pr_id=pr_id,
repo=repo,
)
return None
try:
from src.services.openclaw import get_openclaw
openclaw = get_openclaw()
prompt = (
f"PR Code Review: {repo}#{pr_id}{title}\n"
"繁體中文,找出 Bug/安全/效能/風格問題,每問題以⚠️開頭\n\n"
f"Diff:\n{diff[:60000]}"
)
# 直接呼叫 Gemini
result = await openclaw._call_gemini(prompt)
if result and result[0]:
text = result[0]
issues = text.count("⚠️")
return {"review_text": text, "issues_count": issues, "model": "gemini", "provider": "gemini"}
except Exception as e:
logger.error("pr_review_gemini_failed", pr_id=pr_id, error=str(e))
return None
def _cloud_fallback_disabled_result(
self,
pr_id: str,
repo: str,
title: str,
) -> dict[str, Any]:
logger.warning(
"pr_review_cloud_fallback_skipped",
pr_id=pr_id,
repo=repo,
title=title,
reason="LOCAL_CODE_REVIEW_ALLOW_GEMINI_FALLBACK=false",
)
return {
"review_text": (
"⚠️ Code Review本地 Ollama 審查未完成,"
"已依成本策略跳過 Gemini fallback。"
),
"issues_count": 1,
"model": _MODEL_OLLAMA,
"provider": "ollama_unavailable",
"cloud_fallback_skipped": True,
}
async def _save_to_db(
self, pr_id: str, repo: str, title: str, diff_size: int, result: dict
) -> None:
try:
from sqlalchemy import text
from src.db.base import get_db_context
async with get_db_context() as db:
await db.execute(
text("""
INSERT INTO pr_reviews
(pr_id, repo, title, diff_size_bytes, model, provider, review_text, issues_count)
VALUES
(:pr_id, :repo, :title, :diff_size, :model, :provider, :review_text, :issues_count)
"""),
{
"pr_id": pr_id, "repo": repo, "title": title,
"diff_size": diff_size,
"model": result.get("model", "unknown"),
"provider": result.get("provider", "unknown"),
"review_text": result.get("review_text", ""),
"issues_count": result.get("issues_count", 0),
},
)
except Exception as e:
logger.warning("pr_review_db_save_failed", error=str(e))
async def review_push(
self,
repo_name: str,
branch: str,
commit_summary: str,
files_summary: str,
) -> dict[str, Any] | None:
"""
審查 push event非 PR用 qwen2.5-coder:7b 快速掃描
2026-04-10 Claude Sonnet 4.6: Phase 32 ADR-067 — gitea_webhook_service 呼叫
"""
async with _SEMAPHORE:
prompt = (
f"你是資深程式審查員,請用繁體中文快速審查以下 Git Push。\n"
f"Repository: {repo_name} Branch: {branch}\n\n"
f"=== Commits ===\n{commit_summary}\n\n"
f"=== Changed Files ===\n{files_summary}\n\n"
"請簡要說明1) 有無明顯安全風險 2) 有無破壞性變更 3) 整體品質評估\n"
"格式:每個問題以「⚠️」開頭,如無問題說「✅ Push 品質正常」\n"
"5 行以內,繁體中文。"
)
http = await self._get_http()
for endpoint in resolve_ollama_order("code_review"):
if not endpoint.url:
continue
try:
resp = await http.post(
f"{endpoint.url}/api/generate",
json={
"model": _MODEL_OLLAMA,
"prompt": prompt,
"stream": False,
"options": {"num_predict": 512, "temperature": 0.1},
},
)
if resp.status_code == 200:
text = resp.json().get("response", "").strip()
issues = text.count("⚠️")
logger.info(
"push_review_ollama_done",
repo=repo_name,
branch=branch,
issues=issues,
provider=endpoint.provider_name,
)
return {
"review_text": text,
"issues_count": issues,
"model": _MODEL_OLLAMA,
"provider": endpoint.provider_name,
}
logger.warning(
"push_review_ollama_http_error",
repo=repo_name,
provider=endpoint.provider_name,
status=resp.status_code,
)
except httpx.TimeoutException:
logger.warning(
"push_review_ollama_timeout",
repo=repo_name,
provider=endpoint.provider_name,
)
except Exception as e:
logger.error(
"push_review_ollama_failed",
repo=repo_name,
provider=endpoint.provider_name,
error=str(e),
)
return None
async def close(self) -> None:
if self._http and not self._http.is_closed:
await self._http.aclose()
_instance: LocalCodeReviewService | None = None
def get_local_code_review_service() -> LocalCodeReviewService:
global _instance
if _instance is None:
_instance = LocalCodeReviewService()
return _instance
def set_local_code_review_service(svc: LocalCodeReviewService) -> None:
global _instance
_instance = svc