All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 12m56s
- 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>
246 lines
9.3 KiB
Python
246 lines
9.3 KiB
Python
"""
|
||
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
|