feat: filter meaningful conversations with local model
This commit is contained in:
@@ -209,15 +209,19 @@ func (s *Service) Test(ctx context.Context, principal identity.Principal, tenant
|
||||
testStatus, errorCode = "failed", normalizedErrorCode(err)
|
||||
}
|
||||
var result Model
|
||||
auditResult := "success"
|
||||
if testStatus != "passed" {
|
||||
auditResult = "failure"
|
||||
}
|
||||
updateErr := database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error {
|
||||
row := tx.QueryRow(ctx, modelUpdateReturning+` WHERE cm.tenant_id=$1::uuid AND cm.public_id=$2::uuid`, current.tenantID, publicID, testStatus, errorCode, current.userID, current.tenantPublicID)
|
||||
row := tx.QueryRow(ctx, modelUpdateReturning, current.tenantID, publicID, testStatus, errorCode, current.userID, current.tenantPublicID)
|
||||
updated, scanErr := scanModel(row)
|
||||
if scanErr != nil {
|
||||
return scanErr
|
||||
}
|
||||
result = updated.Model
|
||||
resourceID := result.ID
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.userPublicID, Action: "content_model.test", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "验证总结模型连通性", Result: testStatus, Detail: map[string]any{"error_code": errorCode}})
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.userPublicID, Action: "content_model.test", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "验证总结模型连通性", Result: auditResult, Detail: map[string]any{"test_status": testStatus, "error_code": errorCode}})
|
||||
})
|
||||
if updateErr != nil {
|
||||
return Model{}, updateErr
|
||||
@@ -359,13 +363,18 @@ func normalizeInput(input *CreateInput) {
|
||||
func validateInput(input CreateInput) error {
|
||||
parsed, err := url.Parse(input.BaseURL)
|
||||
validProvider := input.Provider == "ark" || input.Provider == "openai_compatible"
|
||||
validURL := err == nil && parsed.Host != "" && (parsed.Scheme == "https" || (parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")))
|
||||
validURL := err == nil && parsed.Host != "" && (parsed.Scheme == "https" || (parsed.Scheme == "http" && isApprovedLocalModelHost(parsed.Hostname())))
|
||||
if !validUUID(input.TenantID) || len([]rune(input.DisplayName)) < 1 || len([]rune(input.DisplayName)) > 120 || !validProvider || !validURL || input.ModelID == "" || len(input.APIKey) < 8 || input.Temperature < 0 || input.Temperature > 2 || input.MaxTokens < 256 || input.MaxTokens > 8192 {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isApprovedLocalModelHost(host string) bool {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
return host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "ollama"
|
||||
}
|
||||
|
||||
func credentialHint(key string) string {
|
||||
runes := []rune(strings.TrimSpace(key))
|
||||
if len(runes) <= 4 {
|
||||
@@ -398,7 +407,7 @@ func validUUID(value string) bool { _, err := uuid.Parse(value); return err == n
|
||||
|
||||
const modelSelect = `SELECT cm.public_id::text,t.public_id::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version FROM content_model_config cm JOIN tenant t ON t.id=cm.tenant_id`
|
||||
const modelReturning = `cm.public_id::text,$4::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version`
|
||||
const modelUpdateReturning = `UPDATE content_model_config cm SET test_status=$3,last_tested_at=now(),last_error_code=NULLIF($4,''),updated_at=now(),version=version+1,updated_by=$5::uuid RETURNING cm.public_id::text,$6::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version`
|
||||
const modelUpdateReturning = `UPDATE content_model_config cm SET test_status=$3,last_tested_at=now(),last_error_code=NULLIF($4,''),updated_at=now(),version=version+1,updated_by=$5::uuid WHERE cm.tenant_id=$1::uuid AND cm.public_id=$2::uuid RETURNING cm.public_id::text,$6::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version`
|
||||
|
||||
type rowScanner interface{ Scan(...any) error }
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package contentmodel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/extraction"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestServiceTestsAndActivatesCompatibleModel(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": `{"meaningful":false,"story_worthy":false,"decision_reason":"连通性测试内容不形成故事","summary":"","keywords":[],"open_topic":"","story_title":"","story_content":"","fact_content":"","confidence":0.99}`}}}})
|
||||
}))
|
||||
defer server.Close()
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
registry, err := NewRegistry(pool, "1f1e1d1c1b1a191817161514131211100f0e0d0c0b0a09080706050403020100", extraction.DeterministicGenerator{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := NewService(registry, audit.NewRepository())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||||
created, err := service.Create(ctx, principal, CreateInput{
|
||||
TenantID: "10000000-0000-0000-0000-000000000010", DisplayName: "集成测试模型", Provider: "openai_compatible",
|
||||
BaseURL: server.URL + "/v1", ModelID: "test-model", APIKey: "integration-key", Temperature: 0.1, MaxTokens: 512,
|
||||
}, "content-model-integration-create")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, created.ID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM content_model_config WHERE public_id=$1::uuid`, created.ID)
|
||||
}()
|
||||
tested, err := service.Test(ctx, principal, created.TenantID, created.ID, "content-model-integration-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tested.TestStatus != "passed" || tested.Version != created.Version+1 {
|
||||
t.Fatalf("tested model=%+v", tested)
|
||||
}
|
||||
activated, err := service.Activate(ctx, principal, created.TenantID, created.ID, "content-model-integration-activate", tested.Version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if activated.Status != "active" || activated.Version != tested.Version+1 {
|
||||
t.Fatalf("activated model=%+v", activated)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package contentmodel
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateInputAllowsComposeOllamaEndpoint(t *testing.T) {
|
||||
input := CreateInput{
|
||||
TenantID: "10000000-0000-0000-0000-000000000001", DisplayName: "本地 Qwen",
|
||||
Provider: "openai_compatible", BaseURL: "http://ollama:11434/v1", ModelID: "qwen2.5:7b",
|
||||
APIKey: "ollama-local-key", Temperature: 0.1, MaxTokens: 1200,
|
||||
}
|
||||
if err := validateInput(input); err != nil {
|
||||
t.Fatalf("compose Ollama endpoint was rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInputRejectsUnapprovedPlainHTTPEndpoint(t *testing.T) {
|
||||
input := CreateInput{
|
||||
TenantID: "10000000-0000-0000-0000-000000000001", DisplayName: "不安全端点",
|
||||
Provider: "openai_compatible", BaseURL: "http://metadata.internal/v1", ModelID: "model",
|
||||
APIKey: "test-api-key", Temperature: 0.1, MaxTokens: 1200,
|
||||
}
|
||||
if err := validateInput(input); err == nil {
|
||||
t.Fatal("unapproved plaintext endpoint was accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user