feat(ollama): Phase 31-34 ADR-067 — Log摘要/PR審查/RAG知識庫/圖片分析
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:
OG T
2026-04-10 01:50:22 +08:00
parent 89015d4527
commit 63e840ae42
8 changed files with 1031 additions and 1 deletions

View 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