feat(ollama): Phase 31-34 ADR-067 — Log摘要/PR審查/RAG知識庫/圖片分析
Some checks are pending
CD Pipeline / build-and-deploy (push) Has started running
Some checks are pending
CD Pipeline / build-and-deploy (push) Has started running
Phase 31: log_summary_service.py — deepseek-r1:14b K8s Pod日誌異常摘要
- 觸發: signoz_webhook 告警時背景呼叫
- Redis快取 log_summary:{pod}:{date} TTL 24h
- 敏感資料regex遮蔽
Phase 32: local_code_review_service.py — qwen2.5-coder:7b PR自動審查
- Fallback: Gemini (diff > 50KB 或 Ollama超時)
- semaphore 最多2個同時審查
- 雙寫: Redis TTL 7d + pr_reviews表 (phase29 migration)
Phase 33: knowledge_rag_service.py — nomic-embed-text 768維 pgvector RAG
- 向量化(188) + 生成(111) 雙Ollama
- rag_chunks表 (phase28 migration)
- 初期線性搜尋,>100筆啟用ivfflat索引
Phase 34: image_analysis_service.py — llava:latest Telegram圖片分析
- download_and_analyze: Bot API getFile → 下載 → llava → 回應
- Rate limit: 每chat_id每分鐘3次 (Redis sliding window)
- telegram.py webhook新增photo分支
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
200
apps/api/src/services/local_code_review_service.py
Normal file
200
apps/api/src/services/local_code_review_service.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
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
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
_MODEL_OLLAMA = "qwen2.5-coder:7b"
|
||||
_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 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
|
||||
Reference in New Issue
Block a user