Files
awoooi/apps/api/src/services/local_code_review_service.py
OG T f2c18c4e63
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 12m56s
feat(D1): models.json 集中化 — ADR-067 五大 Ollama 應用 hardcode 消除
- models.json v1.3.0: providers.ollama.models 新增 9 個 purpose keys
  (drift_summary/drift_intent/log_anomaly/nemoclaw/playbook_draft/
   code_review/embedding/rag_generate/image_analysis)
- drift_narrator_service: NARRATOR_MODEL → get_model("ollama","drift_summary")
- drift_interpreter: MODEL → get_model("ollama","drift_intent")
- log_summary_service: SUMMARY_MODEL → get_model("ollama","log_anomaly")
- local_code_review_service: _MODEL_OLLAMA → get_model("ollama","code_review")
- image_analysis_service: _MODEL → get_model("ollama","image_analysis")
- decision_manager: nemoclaw + playbook_draft 兩處 → get_model()
- embedding_service: get_embedding_service() factory → get_model("ollama","embedding")
- knowledge_service: OllamaEmbeddingService(model=...) → get_model()

所有模型名稱現在統一由 models.json 管理,修改模型只需改一個檔案。
LOGBOOK 更新:D1 完成 + B2 已完成確認

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 20:45:53 +08:00

246 lines
9.3 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
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())
use_gemini = diff_size > _MAX_DIFF_BYTES
if use_gemini:
result = await self._review_with_gemini(pr_id, repo, title, diff)
else:
result = await self._review_with_ollama(pr_id, repo, title, diff)
if result is None:
# Ollama 失敗 → fallback Gemini
result = await self._review_with_gemini(pr_id, repo, title, diff)
if result is None:
return None
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=== 結束 ==="
)
try:
http = await self._get_http()
resp = await http.post(
f"{settings.OLLAMA_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)
return {"review_text": text, "issues_count": issues, "model": _MODEL_OLLAMA, "provider": "ollama"}
except httpx.TimeoutException:
logger.warning("pr_review_ollama_timeout", pr_id=pr_id)
except Exception as e:
logger.error("pr_review_ollama_failed", pr_id=pr_id, error=str(e))
return None
async def _review_with_gemini(
self, pr_id: str, repo: str, title: str, diff: str
) -> dict[str, Any] | 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
async def _save_to_db(
self, pr_id: str, repo: str, title: str, diff_size: int, result: dict
) -> None:
try:
from src.db.base import get_db_context
from sqlalchemy import text
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 行以內,繁體中文。"
)
try:
http = await self._get_http()
resp = await http.post(
f"{settings.OLLAMA_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)
return {"review_text": text, "issues_count": issues, "model": _MODEL_OLLAMA, "provider": "ollama"}
except httpx.TimeoutException:
logger.warning("push_review_ollama_timeout", repo=repo_name)
except Exception as e:
logger.error("push_review_ollama_failed", repo=repo_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