feat: 添加 Token 费用估算与自定义忽略规则
- 在 LLM 客户端中捕获流式响应的 Token 使用量 - 内置多模型官方定价表,支持自定义输入/输出单价覆盖 - 新增 `--price-input` 和 `--price-output` 配置项 - 支持通过 `.airignore` 文件自定义排除评审文件 - 更新中英日文文档,补充费用估算与忽略规则说明
This commit is contained in:
1 parent
02e2e4edd3
commit
28c29e69df
13 files changed
+691
-82
No files matched your search
@@ -43,6 +43,9 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
|
||||
return "", ErrNotGitRepo
|
||||
}
|
||||
|
||||
repoRoot, _ := GetRepoRoot(ctx)
|
||||
customIgnores := LoadAirIgnore(repoRoot)
|
||||
|
||||
var cmdArgs []string
|
||||
if len(args) == 0 {
|
||||
// 1. 默认先提取暂存区代码 (准备 commit 的代码)
|
||||
@@ -51,7 +54,7 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(stagedDiff) != "" {
|
||||
return truncateDiffIfNeeded(FilterDiff(stagedDiff)), nil
|
||||
return truncateDiffIfNeeded(FilterDiff(stagedDiff, customIgnores...)), nil
|
||||
}
|
||||
// 2. 如果暂存区为空,回退提取工作区未暂存的修改
|
||||
cmdArgs = []string{}
|
||||
@@ -64,7 +67,7 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
filteredDiff := FilterDiff(rawDiff)
|
||||
filteredDiff := FilterDiff(rawDiff, customIgnores...)
|
||||
if strings.TrimSpace(filteredDiff) == "" {
|
||||
return "", ErrEmptyDiff
|
||||
}
|
||||
|
||||
+72
-14
@@ -1,13 +1,14 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 默认需要忽略的文件名与后缀列表
|
||||
var ignoredPatterns = []string{
|
||||
var defaultIgnoredPatterns = []string{
|
||||
// 依赖与锁文件
|
||||
"go.sum",
|
||||
"package-lock.json",
|
||||
@@ -36,19 +37,45 @@ var ignoredPatterns = []string{
|
||||
"*.pdf",
|
||||
}
|
||||
|
||||
// isIgnoredFile 判断文件路径是否应该在 Code Review 中被忽略
|
||||
func isIgnoredFile(filePath string) bool {
|
||||
cleanPath := filepath.ToSlash(filePath)
|
||||
// LoadAirIgnore 从仓库根目录加载 .airignore 文件中的自定义忽略规则
|
||||
func LoadAirIgnore(repoRoot string) []string {
|
||||
if repoRoot == "" {
|
||||
return nil
|
||||
}
|
||||
ignoreFile := filepath.Join(repoRoot, ".airignore")
|
||||
file, err := os.Open(ignoreFile)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var patterns []string
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
trimmed := strings.TrimSpace(scanner.Text())
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
patterns = append(patterns, trimmed)
|
||||
}
|
||||
return patterns
|
||||
}
|
||||
|
||||
// isIgnoredFile 判断文件路径是否命中忽略规则(内置规则 + 用户自定义规则)
|
||||
func isIgnoredFile(filePath string, customPatterns []string) bool {
|
||||
cleanPath := filepath.ToSlash(strings.TrimSpace(filePath))
|
||||
baseName := filepath.Base(cleanPath)
|
||||
|
||||
for _, pattern := range ignoredPatterns {
|
||||
// 精确匹配文件名(如 go.sum)
|
||||
if pattern == baseName {
|
||||
// 1. 先匹配用户自定义规则 (.airignore)
|
||||
for _, pattern := range customPatterns {
|
||||
if matchPattern(pattern, cleanPath, baseName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 通配符匹配(如 *.pb.go)
|
||||
if matched, _ := filepath.Match(pattern, baseName); matched {
|
||||
// 2. 再匹配内置默认规则
|
||||
for _, pattern := range defaultIgnoredPatterns {
|
||||
if matchPattern(pattern, cleanPath, baseName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -56,10 +83,41 @@ func isIgnoredFile(filePath string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
var diffHeaderRegex = regexp.MustCompile(`(?m)^diff --git a/(.*?) b/(.*?)$`)
|
||||
// matchPattern 单条规则智能匹配(支持目录/、通配符*、精确匹配)
|
||||
func matchPattern(pattern, cleanPath, baseName string) bool {
|
||||
pattern = filepath.ToSlash(strings.TrimSpace(pattern))
|
||||
if pattern == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// FilterDiff 过滤掉 diff 中无意义的锁文件和自动生成文件的区块
|
||||
func FilterDiff(rawDiff string) string {
|
||||
// 目录规则:如 "docs/" 或 "vendor/"
|
||||
if strings.HasSuffix(pattern, "/") {
|
||||
dirPrefix := strings.TrimSuffix(pattern, "/")
|
||||
if strings.HasPrefix(cleanPath, dirPrefix+"/") || cleanPath == dirPrefix {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 精确匹配文件名或相对路径
|
||||
if pattern == baseName || pattern == cleanPath {
|
||||
return true
|
||||
}
|
||||
|
||||
// 通配符匹配文件名(如 *.pb.go)
|
||||
if matched, _ := filepath.Match(pattern, baseName); matched {
|
||||
return true
|
||||
}
|
||||
|
||||
// 通配符匹配全路径(如 mock/*.go)
|
||||
if matched, _ := filepath.Match(pattern, cleanPath); matched {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// FilterDiff 过滤掉 diff 中命中忽略规则(如锁文件、生成的代码、.airignore 排除项)的文件区块
|
||||
func FilterDiff(rawDiff string, customPatterns ...string) string {
|
||||
if strings.TrimSpace(rawDiff) == "" {
|
||||
return ""
|
||||
}
|
||||
@@ -85,7 +143,7 @@ func FilterDiff(rawDiff string) string {
|
||||
parts := strings.Fields(firstLine)
|
||||
if len(parts) >= 2 {
|
||||
filePath := strings.TrimPrefix(parts[1], "b/")
|
||||
if isIgnoredFile(filePath) {
|
||||
if isIgnoredFile(filePath, customPatterns) {
|
||||
continue // 命中忽略规则,跳过这个文件的 diff
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsIgnoredFile(t *testing.T) {
|
||||
customPatterns := []string{
|
||||
"docs/",
|
||||
"tests/mock/*.json",
|
||||
"secrets.env",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
filePath string
|
||||
expected bool
|
||||
}{
|
||||
// 默认内置忽略规则
|
||||
{"go.sum", true},
|
||||
{"package-lock.json", true},
|
||||
{"api/user.pb.go", true},
|
||||
{"web/dist/app.min.js", true},
|
||||
{"assets/logo.png", true},
|
||||
|
||||
// 用户自定义 .airignore 规则
|
||||
{"docs/readme.md", true},
|
||||
{"docs/api/v1.md", true},
|
||||
{"tests/mock/user.json", true},
|
||||
{"secrets.env", true},
|
||||
|
||||
// 不应被忽略的正常业务代码
|
||||
{"cmd/root.go", false},
|
||||
{"internal/service/user.go", false},
|
||||
{"pkg/utils/helper.go", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := isIgnoredFile(tt.filePath, customPatterns)
|
||||
if got != tt.expected {
|
||||
t.Errorf("isIgnoredFile(%q) = %v, expected %v", tt.filePath, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAirIgnore(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "git-air-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("创建临时目录失败: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
content := `# 忽略文档
|
||||
docs/
|
||||
# 忽略临时文件
|
||||
*.tmp
|
||||
*.log
|
||||
`
|
||||
ignoreFile := filepath.Join(tempDir, ".airignore")
|
||||
if err := os.WriteFile(ignoreFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("写入 .airignore 失败: %v", err)
|
||||
}
|
||||
|
||||
patterns := LoadAirIgnore(tempDir)
|
||||
expected := []string{"docs/", "*.tmp", "*.log"}
|
||||
|
||||
if len(patterns) != len(expected) {
|
||||
t.Fatalf("LoadAirIgnore 返回数量不符: got %v, expected %v", patterns, expected)
|
||||
}
|
||||
|
||||
for i, p := range patterns {
|
||||
if p != expected[i] {
|
||||
t.Errorf("Pattern[%d] = %q, expected %q", i, p, expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterDiff(t *testing.T) {
|
||||
rawDiff := `diff --git a/go.sum b/go.sum
|
||||
index 111..222 100644
|
||||
--- a/go.sum
|
||||
+++ b/go.sum
|
||||
@@ -1 +1 @@
|
||||
+some-package v1.0.0 h1:...
|
||||
diff --git a/internal/service/user.go b/internal/service/user.go
|
||||
index 333..444 100644
|
||||
--- a/internal/service/user.go
|
||||
+++ b/internal/service/user.go
|
||||
@@ -1 +1 @@
|
||||
+func HandleUser() {}
|
||||
diff --git a/docs/spec.md b/docs/spec.md
|
||||
index 555..666 100644
|
||||
--- a/docs/spec.md
|
||||
+++ b/docs/spec.md
|
||||
@@ -1 +1 @@
|
||||
+# API Spec
|
||||
`
|
||||
customPatterns := []string{"docs/"}
|
||||
filtered := FilterDiff(rawDiff, customPatterns...)
|
||||
|
||||
if strings.Contains(filtered, "go.sum") {
|
||||
t.Errorf("FilterDiff 应当过滤掉 go.sum")
|
||||
}
|
||||
if strings.Contains(filtered, "docs/spec.md") {
|
||||
t.Errorf("FilterDiff 应当过滤掉 docs/spec.md")
|
||||
}
|
||||
if !strings.Contains(filtered, "internal/service/user.go") {
|
||||
t.Errorf("FilterDiff 应当保留正常的业务代码 internal/service/user.go")
|
||||
}
|
||||
}
|
||||
+150
-14
@@ -12,16 +12,20 @@ import (
|
||||
|
||||
// Config 大模型统一配置
|
||||
type Config struct {
|
||||
Provider string // "gemini", "deepseek", "openai", "ollama", "custom"
|
||||
APIKey string // 密钥 (Ollama 本地可填任意字符或留空)
|
||||
BaseURL string // 自定义接口地址 (非必填,会自动根据 Provider 设置默认值)
|
||||
Model string // 模型名称 (非必填,会自动设置默认主力模型)
|
||||
Provider string // "gemini", "deepseek", "openai", "ollama", "custom"
|
||||
APIKey string // 密钥 (Ollama 本地可填任意字符或留空)
|
||||
BaseURL string // 自定义接口地址 (非必填,会自动根据 Provider 设置默认值)
|
||||
Model string // 模型名称 (非必填,会自动设置默认主力模型)
|
||||
PriceInput float64 // 用户自定义每 1M 输入 Token 美元价格 (可选,覆盖内置价格表)
|
||||
PriceOutput float64 // 用户自定义每 1M 输出 Token 美元价格 (可选,覆盖内置价格表)
|
||||
}
|
||||
|
||||
// Client 统一大模型客户端
|
||||
type Client struct {
|
||||
client *openai.Client
|
||||
model string
|
||||
client *openai.Client
|
||||
model string
|
||||
priceInput float64
|
||||
priceOutput float64
|
||||
}
|
||||
|
||||
// NewClient 初始化客户端
|
||||
@@ -227,8 +231,10 @@ func NewClient(cfg Config) (*Client, error) {
|
||||
clientConfig.BaseURL = baseURL
|
||||
|
||||
return &Client{
|
||||
client: openai.NewClientWithConfig(clientConfig),
|
||||
model: model,
|
||||
client: openai.NewClientWithConfig(clientConfig),
|
||||
model: model,
|
||||
priceInput: cfg.PriceInput,
|
||||
priceOutput: cfg.PriceOutput,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -254,8 +260,122 @@ func (c *Client) Review(ctx context.Context, systemPrompt, diff string) (string,
|
||||
return resp.Choices[0].Message.Content, nil
|
||||
}
|
||||
|
||||
// ReviewStream 流式逐字返回评审结果(打字机效果)
|
||||
func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, onChunk func(chunk string)) error {
|
||||
// Usage 记录一次调用的 Token 消耗
|
||||
type Usage struct {
|
||||
InputTokens int
|
||||
OutputTokens int
|
||||
Model string
|
||||
Provider string
|
||||
CustomPriceInput float64 // 用户自定义输入单价(每 1M tokens 美元,>0 优先使用)
|
||||
CustomPriceOutput float64 // 用户自定义输出单价(每 1M tokens 美元,>0 优先使用)
|
||||
}
|
||||
|
||||
// reviewPriceTable 各模型每 1M token 的美元单价(输入/输出)
|
||||
// 数据来源:各厂商官方定价页面,更新于 2026-08
|
||||
// 如实际价格有变动,可在 config.yaml 中通过 price_input/price_output 字段自定义覆盖
|
||||
var reviewPriceTable = map[string][2]float64{
|
||||
|
||||
// ── Google Gemini ─────────────────────────────────────────────
|
||||
// 3.x 系列(当前主力)
|
||||
"gemini-3.7-flash": {0.75, 3.75}, // 推介价至 2026-12-31,之后 1.50/7.50
|
||||
"gemini-3.6-flash": {1.50, 7.50},
|
||||
"gemini-3.5-flash": {1.50, 9.00},
|
||||
"gemini-3.5-flash-lite": {0.30, 2.50},
|
||||
"gemini-3.1-flash-lite": {0.25, 1.50},
|
||||
"gemini-3.1-pro": {2.00, 12.00},
|
||||
// 2.5 系列(2026-10 退役)
|
||||
"gemini-2.5-pro": {1.25, 10.00},
|
||||
"gemini-2.5-flash": {0.30, 2.50},
|
||||
"gemini-2.5-flash-lite": {0.10, 0.40},
|
||||
// 旧款(兼容)
|
||||
"gemini-2.0-flash": {0.10, 0.40},
|
||||
|
||||
// ── DeepSeek ─────────────────────────────────────────────────
|
||||
// 注意:DeepSeek 已启用峰谷定价,以下为非高峰标准价
|
||||
"deepseek-v4-flash": {0.22, 0.66},
|
||||
"deepseek-chat": {0.22, 0.66}, // V4-Flash 别名
|
||||
"deepseek-reasoner": {0.55, 2.19},
|
||||
|
||||
// ── Anthropic Claude ──────────────────────────────────────────
|
||||
"claude-opus-5": {5.00, 25.00},
|
||||
"claude-sonnet-5": {2.00, 10.00}, // 推介价至 2026-08-31
|
||||
"anthropic/claude-sonnet-5": {2.00, 10.00},
|
||||
"claude-haiku-4-5": {1.00, 5.00},
|
||||
"anthropic/claude-haiku-4-5": {1.00, 5.00},
|
||||
// 旧款(兼容)
|
||||
"anthropic/claude-3.7-sonnet": {3.00, 15.00},
|
||||
"anthropic/claude-3.5-sonnet": {3.00, 15.00},
|
||||
"anthropic/claude-3.5-haiku": {0.80, 4.00},
|
||||
|
||||
// ── xAI Grok ─────────────────────────────────────────────────
|
||||
"grok-4.6": {2.00, 6.00}, // >200k tokens 翻倍
|
||||
"grok-4.3": {1.25, 2.50},
|
||||
"grok-2-latest": {2.00, 6.00}, // 旧款映射
|
||||
|
||||
// ── OpenAI ───────────────────────────────────────────────────
|
||||
"gpt-5.6-sol": {5.00, 30.00},
|
||||
"gpt-5.6-terra": {2.00, 12.00},
|
||||
"gpt-5.6-luna": {0.20, 1.20},
|
||||
"gpt-4o": {2.50, 10.00}, // 旧款保留
|
||||
"gpt-4o-mini": {0.15, 0.60},
|
||||
"o3-mini": {1.10, 4.40},
|
||||
|
||||
// ── SiliconFlow(聚合平台,按 DeepSeek V4 主力估算)────────────
|
||||
"deepseek-ai/DeepSeek-V3": {0.22, 0.66},
|
||||
"deepseek-ai/DeepSeek-R1": {0.55, 2.19},
|
||||
|
||||
// ── Qwen / 阿里通义千问 ───────────────────────────────────────
|
||||
"qwen-3.8-max": {2.00, 6.00},
|
||||
"qwen-max": {2.00, 6.00},
|
||||
"qwen-plus": {0.40, 1.20},
|
||||
"qwen-3.7-flash": {0.03, 0.13},
|
||||
|
||||
// ── Kimi / Moonshot AI ────────────────────────────────────────
|
||||
"kimi-k3": {3.00, 15.00},
|
||||
"moonshot-v1-8k": {0.95, 4.00},
|
||||
"moonshot-v1-32k": {1.90, 8.00},
|
||||
"moonshot-v1-128k": {3.50, 15.00},
|
||||
|
||||
// ── 智谱 GLM ─────────────────────────────────────────────────
|
||||
"glm-5.3": {1.40, 4.40},
|
||||
"glm-5.2": {1.40, 4.40},
|
||||
"glm-4-plus": {1.40, 4.40},
|
||||
|
||||
// ── Doubao / 字节跳动 ─────────────────────────────────────────
|
||||
"doubao-seed-2.1-pro": {0.88, 4.42},
|
||||
"doubao-pro-32k": {0.88, 4.42},
|
||||
|
||||
// ── Groq(超快推理,按量计费)────────────────────────────────
|
||||
"llama-3.3-70b-versatile": {0.59, 0.79},
|
||||
"llama-3.1-8b-instant": {0.05, 0.08},
|
||||
|
||||
// ── 本地模型(完全免费)──────────────────────────────────────
|
||||
"ollama": {0, 0},
|
||||
"local-model": {0, 0},
|
||||
}
|
||||
|
||||
// EstimateCost 估算费用(美元),返回 -1 表示无定价数据
|
||||
func (u *Usage) EstimateCost() float64 {
|
||||
// 1. 优先使用用户在 config.yaml 中配置的自定义单价(每 1M tokens 美元)
|
||||
if u.CustomPriceInput > 0 || u.CustomPriceOutput > 0 {
|
||||
inputCost := float64(u.InputTokens) / 1_000_000 * u.CustomPriceInput
|
||||
outputCost := float64(u.OutputTokens) / 1_000_000 * u.CustomPriceOutput
|
||||
return inputCost + outputCost
|
||||
}
|
||||
|
||||
// 2. 回退使用内置官方模型价格表
|
||||
prices, ok := reviewPriceTable[u.Model]
|
||||
if !ok {
|
||||
return -1
|
||||
}
|
||||
// Ollama / 本地模型 price = 0,直接返回 0
|
||||
inputCost := float64(u.InputTokens) / 1_000_000 * prices[0]
|
||||
outputCost := float64(u.OutputTokens) / 1_000_000 * prices[1]
|
||||
return inputCost + outputCost
|
||||
}
|
||||
|
||||
// ReviewStream 流式逐字返回评审结果(打字机效果),同时返回 Token 使用量
|
||||
func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, onChunk func(chunk string)) (*Usage, error) {
|
||||
stream, err := c.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
|
||||
Model: c.model,
|
||||
Messages: []openai.ChatCompletionMessage{
|
||||
@@ -264,18 +384,27 @@ func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, on
|
||||
},
|
||||
Temperature: 0.2,
|
||||
Stream: true,
|
||||
StreamOptions: &openai.StreamOptions{
|
||||
IncludeUsage: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建流式请求失败: %w", err)
|
||||
return nil, fmt.Errorf("创建流式请求失败: %w", err)
|
||||
}
|
||||
if stream != nil {
|
||||
defer stream.Close()
|
||||
}
|
||||
|
||||
usage := &Usage{
|
||||
Model: c.model,
|
||||
CustomPriceInput: c.priceInput,
|
||||
CustomPriceOutput: c.priceOutput,
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
@@ -284,8 +413,15 @@ func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, on
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("流式读取异常: %w", err)
|
||||
return nil, fmt.Errorf("流式读取异常: %w", err)
|
||||
}
|
||||
|
||||
// 捕获最后一个 chunk 中携带的 Usage 信息
|
||||
if response.Usage.TotalTokens > 0 {
|
||||
usage.InputTokens = response.Usage.PromptTokens
|
||||
usage.OutputTokens = response.Usage.CompletionTokens
|
||||
}
|
||||
|
||||
if len(response.Choices) > 0 {
|
||||
chunk := response.Choices[0].Delta.Content
|
||||
if chunk != "" {
|
||||
@@ -294,5 +430,5 @@ func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, on
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return usage, nil
|
||||
}
|
||||
+33
-19
@@ -5,29 +5,43 @@ import (
|
||||
)
|
||||
|
||||
// DefaultBaseSystemPrompt 默认内置的资深架构师评审准则
|
||||
const DefaultBaseSystemPrompt = `你是一位拥有 15 年高并发系统设计经验的资深架构师与代码安全专家。
|
||||
你的任务是对开发者提交的 Git Diff 增量代码进行工业级、严苛、精炼的代码评审(Code Review)。
|
||||
### 评审原则:
|
||||
1. 严禁任何客套话与无意义吹捧,直奔技术实质。
|
||||
2. 聚焦于逻辑缺陷、并发竞态、安全漏洞、内存与协程泄漏、异常处理等实质性问题,不要纠结纯粹的代码缩进或格式。
|
||||
3. 发现具体问题时,必须直接给出修改前后的对比例子或修复代码块。
|
||||
4. 如果代码质量优秀且无缺陷,直接返回 "LGTM: 代码质量良好,未发现明显缺陷",不要强行挑刺。
|
||||
### 重点审查维度:
|
||||
- 并发与竞态安全:Goroutine 泄露、Channel 阻塞、锁竞争与未释放、共享变量无锁并发读写。
|
||||
- 安全与权限:SQL 注入风险、敏感密钥/Token 硬编码、未校验的外部输入、未做权限校验。
|
||||
- 资源与错误处理:连接/文件未 defer Close()、错误被盲目下划线 _ 忽略、Context 未向后传递。
|
||||
- 性能与边界:大循环内的频繁内存分配或数据库查询、切片越界、空指针异常(NIL Dereference)。
|
||||
### 输出格式规范(使用清晰严谨的纯文本标签):
|
||||
const DefaultBaseSystemPrompt = `你是一位拥有 15 年以上高可用架构、高并发底层设计与代码安全审计经验的顶级首席架构师(Principal Engineer)。
|
||||
你的核心使命是对开发者提交的 Git Diff 增量代码进行工业级、严苛、实证化、高信噪比的代码审查(Code Review)。
|
||||
### 一、 核心审查原则(铁律)
|
||||
1. 【零客套、纯硬核】:严禁任何寒暄、恭维或废话,直接输出技术审查实质。
|
||||
2. 【实证主义与防幻觉】:指出缺陷时,必须明确指出【触发路径/前置条件】(如:“当 input 为空且缓存未命中时…”),严禁脱离 Diff 凭空臆测未改动外部逻辑。
|
||||
3. 【最小侵入修复】:给出的修复方案必须语法正确、开箱即用,优先给出最小改动补丁,严禁使用包含大量省略号的伪代码。
|
||||
4. 【无缺陷免打扰】:若本次变更逻辑严谨且无隐患,直接输出 "#### 评审结论\n- 结论: [PASS]\n- 评分: 95 / 100\nLGTM: 代码质量良好,未发现阻断性与潜在缺陷。"。
|
||||
### 二、 缺陷严重级别定义(严格执行,不得随意跨级)
|
||||
- [BLOCKER] (阻断级/必须拦截):
|
||||
- 必然或高概率导致:线上 Panic/Crash、死锁/活锁、Goroutine/内存泄露、并发竞态读写破坏、SQL 注入/越权/密钥硬编码、事务脏读/数据丢失、未释放资源句柄。
|
||||
- 【结论强制为 [REJECT],评分 < 60】
|
||||
- [WARNING] (告警级/潜在隐患):
|
||||
- 潜在风险或性能退化:缺乏超时控制/重试风暴、大循环内频繁堆分配/N+1 查询、静默吞掉底层 Error、边界条件未覆盖、缺少 defer 兜底。
|
||||
- 【结论为 [WARN],评分 60 ~ 85】
|
||||
- [SUGGESTION] (优化级/可读性与习惯):
|
||||
- 不影响逻辑正确性:更优雅的语言原生 Idiomatic 写法、冗余代码精简、命名规范。
|
||||
- 【结论为 [PASS],评分 85 ~ 100】
|
||||
### 三、 重点技术维度专项排查清单
|
||||
1. 并发与竞态:Goroutine 无退出机制(泄露)、Channel 永久阻塞、Mutex 跨函数未释放、共享变量无原子/锁保护并发读写、sync.WaitGroup 传值而非指针。
|
||||
2. 资源与内存:文件/连接/DB rows 未立即 defer Close()、time.After 在循环/select 中堆积内存、大切片底层数组无法 GC。
|
||||
3. 异常与边界:下划线 _ 忽略关键 error、多层调用缺少 Context 传递/超时、空指针解引用(Nil Dereference)、数组/切片越界。
|
||||
4. 安全与权限:动态拼接 SQL 语句、未转义外部输入、硬编码敏感凭证、缺少幂等性保障。
|
||||
### 四、 输出格式规范(必须严格遵循 Markdown 格式)
|
||||
#### 变更概述
|
||||
(用 1-2 句话总结本次代码改动核心内容)
|
||||
(用 1-2 句话客观概括本次变更的业务逻辑与技术影响)
|
||||
#### 详细审查意见
|
||||
(如果有问题,按严重程度列出;若无问题则省略此段)
|
||||
- [BLOCKER] 文件路径:行号 - 问题描述及潜在事故危害(必须附带修复代码块)
|
||||
- [WARNING] 文件路径:行号 - 潜在隐患或性能优化点
|
||||
- [SUGGESTION] 文件路径:行号 - 更优雅的 Idiomatic 写法
|
||||
(若存在问题,按严重程度递减排列;若无缺陷则此段直接省略)
|
||||
- [BLOCKER] 文件路径:行号 - 缺陷描述:说明触发场景、事故危害及调用路径
|
||||
// 建议修复代码:
|
||||
` + "```go" + `
|
||||
// 修复后的代码块
|
||||
` + "```" + `
|
||||
- [WARNING] 文件路径:行号 - 潜在隐患:说明风险点及性能优化建议
|
||||
- [SUGGESTION] 文件路径:行号 - 优化建议:更地道的代码组织方式
|
||||
#### 评审结论
|
||||
- 结论: [PASS / WARN / REJECT]
|
||||
- 评分: X / 100
|
||||
- 评分: X / 100 (0-100分)
|
||||
`
|
||||
|
||||
// BuildSystemPrompt 构建最终 Prompt(合并 Base Prompt 与 rules.go 加载的项目规则)
|
||||
|
||||
@@ -3,8 +3,9 @@ package reviewer
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/unclesam-ly/git-air/internal/llm"
|
||||
"strings"
|
||||
|
||||
"github.com/unclesam-ly/git-air/internal/llm"
|
||||
)
|
||||
|
||||
type Reviewer struct {
|
||||
@@ -23,18 +24,19 @@ func NewReviewer(client *llm.Client, repoRoot string, customPrompt string) *Revi
|
||||
}
|
||||
}
|
||||
|
||||
// Execute 执行代码评审
|
||||
func (r *Reviewer) Execute(ctx context.Context, diff string, stream bool, onChunk func(chunk string)) (string, error) {
|
||||
// Execute 执行代码评审,返回 (result, usage, error)
|
||||
func (r *Reviewer) Execute(ctx context.Context, diff string, stream bool, onChunk func(chunk string)) (string, *llm.Usage, error) {
|
||||
if strings.TrimSpace(diff) == "" {
|
||||
return "", fmt.Errorf("diff 内容为空,无需评审")
|
||||
return "", nil, fmt.Errorf("diff 内容为空,无需评审")
|
||||
}
|
||||
|
||||
// 动态组装最终 System Prompt
|
||||
systemPrompt := BuildSystemPrompt(r.customPrompt, r.repoRoot)
|
||||
if stream && onChunk != nil {
|
||||
err := r.client.ReviewStream(ctx, systemPrompt, diff, onChunk)
|
||||
return "", err
|
||||
usage, err := r.client.ReviewStream(ctx, systemPrompt, diff, onChunk)
|
||||
return "", usage, err
|
||||
}
|
||||
|
||||
return r.client.Review(ctx, systemPrompt, diff)
|
||||
result, err := r.client.Review(ctx, systemPrompt, diff)
|
||||
return result, nil, err
|
||||
}
|
||||
@@ -30,6 +30,28 @@ func PrintFooter() {
|
||||
fmt.Println(colorGray + strings.Repeat("─", 65) + colorReset)
|
||||
}
|
||||
|
||||
// PrintUsage 打印 Token 消耗与费用估算
|
||||
func PrintUsage(inputTokens, outputTokens int, costUSD float64) {
|
||||
if inputTokens == 0 && outputTokens == 0 {
|
||||
return
|
||||
}
|
||||
costStr := ""
|
||||
if costUSD < 0 {
|
||||
costStr = "费用未知"
|
||||
} else if costUSD == 0 {
|
||||
costStr = "本地免费"
|
||||
} else {
|
||||
costStr = fmt.Sprintf("≈ $%.4f", costUSD)
|
||||
}
|
||||
|
||||
fmt.Printf("\n- %sToken: 输入 %s%d%s / 输出 %s%d%s | %s%s%s\n",
|
||||
colorBold,
|
||||
colorCyan, inputTokens, colorGray,
|
||||
colorCyan, outputTokens, colorGray,
|
||||
colorCyan, costStr, colorReset,
|
||||
)
|
||||
}
|
||||
|
||||
// PrintError 打印错误信息
|
||||
func PrintError(format string, a ...interface{}) {
|
||||
msg := fmt.Sprintf(format, a...)
|
||||
|
||||
Reference in new issue
Block a user