修复:
- message_service.go: 附件Metadata零值字符串''→'{}'(PG jsonb列报错)
- config.dev.yaml/config.development.yaml: search.engine改为db
(避免依赖未运行的MeiliSearch导致搜索500)
新增:
- fake_provider.go: Captain/AI fake LLM Provider
- test-captain-llm-provider.sh: 测试脚本
- QA测试报告: 93项CDP功能点覆盖验证
文档:
- CDP_TESTING_HANDOFF.md: 进度更新(89%->92%->95%)
- 报告: 11.846~11.897 完整测试日志
140 lines
3.4 KiB
Go
140 lines
3.4 KiB
Go
// Package llm provides FakeLLMProvider — a zero-dependency LLM provider that
|
|
// returns canned responses for ChatCompletion, embeddings, and streaming.
|
|
//
|
|
// Usage: set FAKE_AI_ENABLED=true in the environment to activate; it overrides
|
|
// the standard provider manager so Captain/Copilot endpoints respond without a
|
|
// real LLM API key.
|
|
package llm
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"sync/atomic"
|
|
)
|
|
|
|
// FakeLLMProvider implements Provider with canned responses.
|
|
// Useful for integration testing, staging, and demo environments.
|
|
type FakeLLMProvider struct {
|
|
// CallCount tracks how many ChatCompletion calls were made.
|
|
callCount atomic.Int64
|
|
}
|
|
|
|
// NewFakeLLMProvider creates a new FakeLLMProvider.
|
|
// It is only active when the FAKE_AI_ENABLED env var is set to "true".
|
|
func NewFakeLLMProvider() *FakeLLMProvider {
|
|
return &FakeLLMProvider{}
|
|
}
|
|
|
|
// IsFakeAIEnabled returns true when the environment variable FAKE_AI_ENABLED=true.
|
|
func IsFakeAIEnabled() bool {
|
|
return os.Getenv("FAKE_AI_ENABLED") == "true"
|
|
}
|
|
|
|
// ChatCompletion returns a canned response echoing the last user message.
|
|
func (f *FakeLLMProvider) ChatCompletion(_ context.Context, req ChatRequest) (*ChatResponse, error) {
|
|
n := f.callCount.Add(1)
|
|
|
|
// Build a reply from the last user message.
|
|
userMsg := ""
|
|
for i := len(req.Messages) - 1; i >= 0; i-- {
|
|
if req.Messages[i].Role == "user" {
|
|
userMsg = req.Messages[i].Content
|
|
break
|
|
}
|
|
}
|
|
if userMsg == "" {
|
|
userMsg = "Hello! I'm a fake AI assistant. How can I help you today?"
|
|
}
|
|
|
|
reply := fmt.Sprintf("[FakeAI #%d] Received your message. Here is a simulated response: %s", n, userMsg)
|
|
|
|
return &ChatResponse{
|
|
ID: "fake-" + fmt.Sprint(n),
|
|
Object: "chat.completion",
|
|
Created: 1700000000,
|
|
Model: "fake-ai-model",
|
|
Choices: []ChatChoice{
|
|
{
|
|
Index: 0,
|
|
Message: ChatMessage{
|
|
Role: "assistant",
|
|
Content: reply,
|
|
},
|
|
FinishReason: "stop",
|
|
},
|
|
},
|
|
Usage: TokenUsage{
|
|
PromptTokens: 10,
|
|
CompletionTokens: 5,
|
|
TotalTokens: 15,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// CreateEmbedding returns a zero-vector embedding of dimension 384.
|
|
func (f *FakeLLMProvider) CreateEmbedding(_ context.Context, req EmbeddingRequest) (*EmbeddingResponse, error) {
|
|
if len(req.Input) == 0 {
|
|
return &EmbeddingResponse{
|
|
Object: "list",
|
|
Data: []EmbeddingData{},
|
|
Model: "fake-embedding-model",
|
|
}, nil
|
|
}
|
|
|
|
dim := req.Dimensions
|
|
if dim <= 0 {
|
|
dim = 384
|
|
}
|
|
|
|
embeddings := make([]EmbeddingData, len(req.Input))
|
|
for i := range req.Input {
|
|
embeddings[i] = EmbeddingData{
|
|
Object: "embedding",
|
|
Index: i,
|
|
Embedding: make([]float64, dim),
|
|
}
|
|
}
|
|
|
|
return &EmbeddingResponse{
|
|
Object: "list",
|
|
Data: embeddings,
|
|
Model: "fake-embedding-model",
|
|
Usage: TokenUsage{
|
|
PromptTokens: len(req.Input),
|
|
CompletionTokens: 0,
|
|
TotalTokens: len(req.Input),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// ChatCompletionStream simulates streaming by delivering a single chunk.
|
|
func (f *FakeLLMProvider) ChatCompletionStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk) error) error {
|
|
resp, err := f.ChatCompletion(ctx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(resp.Choices) == 0 {
|
|
return nil
|
|
}
|
|
|
|
chunk := StreamChunk{
|
|
ID: resp.ID,
|
|
Object: "chat.completion.chunk",
|
|
Created: resp.Created,
|
|
Model: resp.Model,
|
|
Choices: []StreamChoice{
|
|
{
|
|
Index: 0,
|
|
Delta: StreamDelta{
|
|
Role: "assistant",
|
|
Content: resp.Choices[0].Message.Content,
|
|
},
|
|
FinishReason: "stop",
|
|
},
|
|
},
|
|
}
|
|
|
|
return onChunk(chunk)
|
|
}
|