feat: filter meaningful conversations with local model
This commit is contained in:
@@ -4,6 +4,10 @@
|
|||||||
|
|
||||||
## 未发布
|
## 未发布
|
||||||
|
|
||||||
|
- 聊天总结改为大模型两级内容价值判断:先判断是否值得保留,再判断是否值得形成故事;纯寒暄不再生成摘要或故事。
|
||||||
|
- 新增内容价值判断记录,保留模型、提示词版本、置信度和舍弃原因,支持任务幂等与隐私删除。
|
||||||
|
- 本地开发新增 GPU Ollama/Qwen2.5 7B 部署方式,并通过大模型管理的测试、激活和 worker 生成链路完成端到端验证。
|
||||||
|
- 修复大模型“测试连接”更新语句和审计结果枚举错误,测试通过的模型现在可以正常激活。
|
||||||
- 维护端默认人设升级为老人陪伴对话规范,覆盖倾听、单问题追问、沉默与重复处理、事实边界、敏感话题和医疗安全。
|
- 维护端默认人设升级为老人陪伴对话规范,覆盖倾听、单问题追问、沉默与重复处理、事实边界、敏感话题和医疗安全。
|
||||||
- 家庭端长者档案新增日常陪伴、回忆往事、兴趣聊天三个模板,并补充兴趣、优先话题和避免话题的创建入口。
|
- 家庭端长者档案新增日常陪伴、回忆往事、兴趣聊天三个模板,并补充兴趣、优先话题和避免话题的创建入口。
|
||||||
- Agent固定评测加强单问题追问、先回应再追问、沉默不施压、尊重重复表达和情绪优先回应检查。
|
- Agent固定评测加强单问题追问、先回应再追问、沉默不施压、尊重重复表达和情绪优先回应检查。
|
||||||
|
|||||||
@@ -155,6 +155,26 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./deploy/keycloak:/opt/keycloak/data/import:ro
|
- ./deploy/keycloak:/opt/keycloak/data/import:ro
|
||||||
|
|
||||||
|
ollama:
|
||||||
|
profiles: [local-model]
|
||||||
|
image: ollama/ollama:latest
|
||||||
|
ports:
|
||||||
|
- "${OLLAMA_PORT:-11434}:11434"
|
||||||
|
volumes:
|
||||||
|
- ollama-data:/root/.ollama
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "ollama", "list"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
|
||||||
otel-collector:
|
otel-collector:
|
||||||
image: otel/opentelemetry-collector-contrib:latest
|
image: otel/opentelemetry-collector-contrib:latest
|
||||||
command: ["--config=/etc/otelcol-contrib/config.yaml"]
|
command: ["--config=/etc/otelcol-contrib/config.yaml"]
|
||||||
@@ -325,3 +345,4 @@ volumes:
|
|||||||
postgres-data:
|
postgres-data:
|
||||||
redis-data:
|
redis-data:
|
||||||
minio-data:
|
minio-data:
|
||||||
|
ollama-data:
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 本地聊天总结模型
|
||||||
|
|
||||||
|
本地开发环境使用 Ollama 提供 OpenAI 兼容接口,默认验证模型为 `qwen2.5:7b`。模型只监听本机 `11434` 端口,应用容器通过 `http://ollama:11434/v1` 访问。
|
||||||
|
|
||||||
|
## 启动与下载模型
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose --profile local-model up -d ollama
|
||||||
|
docker compose --profile local-model exec ollama ollama pull qwen2.5:7b
|
||||||
|
```
|
||||||
|
|
||||||
|
随后在客户门户的“大模型管理”中创建并激活配置:
|
||||||
|
|
||||||
|
- 服务类型:`OpenAI 兼容接口`
|
||||||
|
- 接口基础地址:`http://ollama:11434/v1`
|
||||||
|
- 模型 ID:`qwen2.5:7b`
|
||||||
|
- API Key:本地 Ollama 不校验密钥,可填写长度至少 8 位的本地占位值
|
||||||
|
- 建议温度:`0.1`
|
||||||
|
- 建议最大输出:`1200`
|
||||||
|
|
||||||
|
必须先执行“测试连接”,通过后才能“设为当前模型”。配置激活后,worker 会在每个总结任务开始时读取当前模型,不需要重启。
|
||||||
|
|
||||||
|
## 真实模型回归测试
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:LOCAL_MODEL_BASE_URL='http://localhost:11434/v1'
|
||||||
|
$env:LOCAL_MODEL_ID='qwen2.5:7b'
|
||||||
|
go test ./internal/extraction -run TestLocalModelContentValueDecisions -count=1 -v
|
||||||
|
```
|
||||||
|
|
||||||
|
测试覆盖三类输入:纯寒暄应舍弃、有用近况只生成摘要、完整个人回忆生成摘要和故事候选。
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# 设备 API 兼容性约定
|
# 设备 API 兼容性约定
|
||||||
|
|
||||||
> 当前设备接口主版本:`/device/v2`
|
> 当前设备接口主版本:`/device/v2`
|
||||||
> 当前接口契约:OpenAPI 0.70.0
|
> 当前接口契约:OpenAPI 0.73.0
|
||||||
> 适用对象:云端 API、终端固件、模拟终端和设备联调工具
|
> 适用对象:云端 API、终端固件、模拟终端和设备联调工具
|
||||||
|
|
||||||
## 1. 兼容目标
|
## 1. 兼容目标
|
||||||
|
|||||||
@@ -209,15 +209,19 @@ func (s *Service) Test(ctx context.Context, principal identity.Principal, tenant
|
|||||||
testStatus, errorCode = "failed", normalizedErrorCode(err)
|
testStatus, errorCode = "failed", normalizedErrorCode(err)
|
||||||
}
|
}
|
||||||
var result Model
|
var result Model
|
||||||
|
auditResult := "success"
|
||||||
|
if testStatus != "passed" {
|
||||||
|
auditResult = "failure"
|
||||||
|
}
|
||||||
updateErr := database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error {
|
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)
|
updated, scanErr := scanModel(row)
|
||||||
if scanErr != nil {
|
if scanErr != nil {
|
||||||
return scanErr
|
return scanErr
|
||||||
}
|
}
|
||||||
result = updated.Model
|
result = updated.Model
|
||||||
resourceID := result.ID
|
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 {
|
if updateErr != nil {
|
||||||
return Model{}, updateErr
|
return Model{}, updateErr
|
||||||
@@ -359,13 +363,18 @@ func normalizeInput(input *CreateInput) {
|
|||||||
func validateInput(input CreateInput) error {
|
func validateInput(input CreateInput) error {
|
||||||
parsed, err := url.Parse(input.BaseURL)
|
parsed, err := url.Parse(input.BaseURL)
|
||||||
validProvider := input.Provider == "ark" || input.Provider == "openai_compatible"
|
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 {
|
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 ErrInvalid
|
||||||
}
|
}
|
||||||
return nil
|
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 {
|
func credentialHint(key string) string {
|
||||||
runes := []rune(strings.TrimSpace(key))
|
runes := []rune(strings.TrimSpace(key))
|
||||||
if len(runes) <= 4 {
|
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 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 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 }
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,16 +23,19 @@ type GenerationInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GenerationResult struct {
|
type GenerationResult struct {
|
||||||
Summary string `json:"summary"`
|
Meaningful bool `json:"meaningful"`
|
||||||
OpenTopic string `json:"open_topic"`
|
StoryWorthy bool `json:"story_worthy"`
|
||||||
StoryTitle string `json:"story_title"`
|
DecisionReason string `json:"decision_reason"`
|
||||||
StoryContent string `json:"story_content"`
|
Summary string `json:"summary"`
|
||||||
FactContent string `json:"fact_content"`
|
OpenTopic string `json:"open_topic"`
|
||||||
Keywords []string `json:"keywords"`
|
StoryTitle string `json:"story_title"`
|
||||||
Confidence float64 `json:"confidence"`
|
StoryContent string `json:"story_content"`
|
||||||
ModelRef string `json:"-"`
|
FactContent string `json:"fact_content"`
|
||||||
PromptVersion string `json:"-"`
|
Keywords []string `json:"keywords"`
|
||||||
AllowStory bool `json:"-"`
|
Confidence float64 `json:"confidence"`
|
||||||
|
ModelRef string `json:"-"`
|
||||||
|
PromptVersion string `json:"-"`
|
||||||
|
AllowStory bool `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Generator interface {
|
type Generator interface {
|
||||||
@@ -46,17 +49,33 @@ func (DeterministicGenerator) Generate(_ context.Context, input GenerationInput)
|
|||||||
if content == "" {
|
if content == "" {
|
||||||
return GenerationResult{}, errors.New("extraction content is required")
|
return GenerationResult{}, errors.New("extraction content is required")
|
||||||
}
|
}
|
||||||
|
meaningful, storyWorthy, reason := deterministicContentDecision(content)
|
||||||
|
if !meaningful {
|
||||||
|
return GenerationResult{
|
||||||
|
Meaningful: false, StoryWorthy: false, DecisionReason: reason,
|
||||||
|
Confidence: 0.9, ModelRef: "local-deterministic-extractor", PromptVersion: "content-value-v1",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
storyTitle, storyContent, factContent := "", "", ""
|
||||||
|
if storyWorthy {
|
||||||
|
storyTitle = "关于“" + truncateRunes(content, 24) + "”的回忆"
|
||||||
|
storyContent = content
|
||||||
|
factContent = content
|
||||||
|
}
|
||||||
return GenerationResult{
|
return GenerationResult{
|
||||||
Summary: "长者在本次通话中谈到:" + content,
|
Meaningful: true,
|
||||||
OpenTopic: "下次可以继续询问长者刚才提到的经历,并让长者自行补充人物、时间和地点。",
|
StoryWorthy: storyWorthy,
|
||||||
StoryTitle: "关于“" + truncateRunes(content, 24) + "”的回忆",
|
DecisionReason: reason,
|
||||||
StoryContent: content,
|
Summary: "长者在本次通话中谈到:" + content,
|
||||||
FactContent: content,
|
OpenTopic: "下次可以继续询问长者刚才提到的经历,并让长者自行补充人物、时间和地点。",
|
||||||
Keywords: deterministicKeywords(content),
|
StoryTitle: storyTitle,
|
||||||
Confidence: 0.9,
|
StoryContent: storyContent,
|
||||||
ModelRef: "local-deterministic-extractor",
|
FactContent: factContent,
|
||||||
PromptVersion: "story-v1",
|
Keywords: deterministicKeywords(content),
|
||||||
AllowStory: true,
|
Confidence: 0.9,
|
||||||
|
ModelRef: "local-deterministic-extractor",
|
||||||
|
PromptVersion: "content-value-deterministic-v2",
|
||||||
|
AllowStory: storyWorthy,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,12 +86,15 @@ func sensitiveGeneration(decision contentpolicy.Decision) GenerationResult {
|
|||||||
}
|
}
|
||||||
reason := safePolicyToken(decision.Reason)
|
reason := safePolicyToken(decision.Reason)
|
||||||
return GenerationResult{
|
return GenerationResult{
|
||||||
Summary: "本次通话包含需要人工谨慎处理的敏感信息,系统未生成普通故事卡片。",
|
Meaningful: true,
|
||||||
OpenTopic: "如确有必要处理本次敏感内容,应由有权限人员在受控流程中单独核对。",
|
StoryWorthy: false,
|
||||||
Confidence: 1,
|
DecisionReason: "safety_policy_blocked_story",
|
||||||
ModelRef: modelRef,
|
Summary: "本次通话包含需要人工谨慎处理的敏感信息,系统未生成普通故事卡片。",
|
||||||
PromptVersion: "summary-sensitive-filter-v1:" + reason,
|
OpenTopic: "如确有必要处理本次敏感内容,应由有权限人员在受控流程中单独核对。",
|
||||||
AllowStory: false,
|
Confidence: 1,
|
||||||
|
ModelRef: modelRef,
|
||||||
|
PromptVersion: "summary-sensitive-filter-v1:" + reason,
|
||||||
|
AllowStory: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +155,7 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen
|
|||||||
requestBody := map[string]any{
|
requestBody := map[string]any{
|
||||||
"model": g.model,
|
"model": g.model,
|
||||||
"messages": []map[string]string{
|
"messages": []map[string]string{
|
||||||
{"role": "system", "content": "你是长者口述内容整理助手。只依据输入字幕生成可人工核对的候选,不补充未出现的事实,不把推断写成事实。必须返回JSON对象,字段为summary、keywords、open_topic、story_title、story_content、fact_content、confidence。summary概括本次内容,keywords是1至8个简短关键词字符串组成的数组,open_topic给出一个温和的后续话题,story和fact保留原意,confidence取0到1。"},
|
{"role": "system", "content": contentValuePrompt},
|
||||||
{"role": "user", "content": content},
|
{"role": "user", "content": content},
|
||||||
},
|
},
|
||||||
"temperature": g.temperature,
|
"temperature": g.temperature,
|
||||||
@@ -186,30 +208,75 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen
|
|||||||
return GenerationResult{}, outbox.Retryable(fmt.Errorf("decode Ark structured content: %w", err))
|
return GenerationResult{}, outbox.Retryable(fmt.Errorf("decode Ark structured content: %w", err))
|
||||||
}
|
}
|
||||||
result.Summary = strings.TrimSpace(result.Summary)
|
result.Summary = strings.TrimSpace(result.Summary)
|
||||||
|
result.DecisionReason = strings.TrimSpace(result.DecisionReason)
|
||||||
result.OpenTopic = strings.TrimSpace(result.OpenTopic)
|
result.OpenTopic = strings.TrimSpace(result.OpenTopic)
|
||||||
result.StoryTitle = strings.TrimSpace(result.StoryTitle)
|
result.StoryTitle = strings.TrimSpace(result.StoryTitle)
|
||||||
result.StoryContent = strings.TrimSpace(result.StoryContent)
|
result.StoryContent = strings.TrimSpace(result.StoryContent)
|
||||||
result.FactContent = strings.TrimSpace(result.FactContent)
|
result.FactContent = strings.TrimSpace(result.FactContent)
|
||||||
result.Keywords = normalizedKeywords(result.Keywords)
|
result.Keywords = normalizedKeywords(result.Keywords)
|
||||||
|
normalizeDecisionContent(&result)
|
||||||
if !validGeneratedContent(result) {
|
if !validGeneratedContent(result) {
|
||||||
return GenerationResult{}, outbox.Retryable(errors.New("Ark structured content failed validation"))
|
return GenerationResult{}, outbox.Retryable(errors.New("Ark structured content failed validation"))
|
||||||
}
|
}
|
||||||
result.ModelRef = g.provider + ":" + g.model
|
result.ModelRef = g.provider + ":" + g.model
|
||||||
result.PromptVersion = "story-" + g.provider + "-json-v2"
|
result.PromptVersion = "content-value-" + g.provider + "-json-v3"
|
||||||
result.AllowStory = true
|
result.AllowStory = result.Meaningful && result.StoryWorthy
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeDecisionContent(result *GenerationResult) {
|
||||||
|
if !result.Meaningful {
|
||||||
|
result.StoryWorthy = false
|
||||||
|
result.Summary = ""
|
||||||
|
result.OpenTopic = ""
|
||||||
|
result.StoryTitle = ""
|
||||||
|
result.StoryContent = ""
|
||||||
|
result.FactContent = ""
|
||||||
|
result.Keywords = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !result.StoryWorthy {
|
||||||
|
result.StoryTitle = ""
|
||||||
|
result.StoryContent = ""
|
||||||
|
result.FactContent = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validGeneratedContent(result GenerationResult) bool {
|
func validGeneratedContent(result GenerationResult) bool {
|
||||||
return result.Summary != "" && len([]rune(result.Summary)) <= 2000 &&
|
if result.Confidence < 0 || result.Confidence > 1 || result.DecisionReason == "" || len([]rune(result.DecisionReason)) > 500 {
|
||||||
result.OpenTopic != "" && len([]rune(result.OpenTopic)) <= 1000 &&
|
return false
|
||||||
result.StoryTitle != "" && len([]rune(result.StoryTitle)) <= 300 &&
|
}
|
||||||
|
if !result.Meaningful {
|
||||||
|
return !result.StoryWorthy && result.Summary == "" && result.OpenTopic == "" && result.StoryTitle == "" && result.StoryContent == "" && result.FactContent == "" && len(result.Keywords) == 0
|
||||||
|
}
|
||||||
|
if result.Summary == "" || len([]rune(result.Summary)) > 2000 ||
|
||||||
|
result.OpenTopic == "" || len([]rune(result.OpenTopic)) > 1000 ||
|
||||||
|
len(result.Keywords) < 1 || len(result.Keywords) > 8 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !result.StoryWorthy {
|
||||||
|
return result.StoryTitle == "" && result.StoryContent == "" && result.FactContent == ""
|
||||||
|
}
|
||||||
|
return result.StoryTitle != "" && len([]rune(result.StoryTitle)) <= 300 &&
|
||||||
result.StoryContent != "" && len([]rune(result.StoryContent)) <= 20000 &&
|
result.StoryContent != "" && len([]rune(result.StoryContent)) <= 20000 &&
|
||||||
result.FactContent != "" && len([]rune(result.FactContent)) <= 5000 &&
|
result.FactContent != "" && len([]rune(result.FactContent)) <= 5000 &&
|
||||||
len(result.Keywords) >= 1 && len(result.Keywords) <= 8 &&
|
|
||||||
result.Confidence >= 0 && result.Confidence <= 1
|
result.Confidence >= 0 && result.Confidence <= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contentValuePrompt = `你是长者聊天内容筛选与整理助手。只依据输入对话判断和整理,不补充未出现的事实,不把推断写成事实。
|
||||||
|
|
||||||
|
先做两级判断:
|
||||||
|
1. meaningful:对话是否包含值得保留的信息。只有问候、寒暄、嗯啊、测试设备、重复句、零散且无法理解的短句,必须判为false并舍弃,不生成摘要。
|
||||||
|
2. story_worthy:仅在meaningful为true时判断是否值得形成故事。具体的个人经历或回忆,且人物、时间、地点、事件、行动、感受中至少出现两类,或形成了清楚的事件脉络,才判为true。普通偏好、单个事实或简短近况可以总结,但story_worthy应为false。
|
||||||
|
|
||||||
|
必须只返回JSON对象,字段为meaningful、story_worthy、decision_reason、summary、keywords、open_topic、story_title、story_content、fact_content、confidence。
|
||||||
|
- decision_reason用一句简短中文说明判断依据。
|
||||||
|
- meaningful=false时,除decision_reason和confidence外,其余文本必须为空字符串,keywords必须为空数组,story_worthy必须为false。
|
||||||
|
- meaningful=true时,summary概括有意义内容,keywords为1至8个简短关键词,open_topic给出一个温和且不重复追问的后续话题。
|
||||||
|
- story_worthy=false时,story_title、story_content、fact_content必须为空字符串。
|
||||||
|
- story_worthy=true时,story_title和story_content忠实整理原话,fact_content只写对话中可核对的事实。
|
||||||
|
- confidence取0到1。`
|
||||||
|
|
||||||
func normalizedKeywords(values []string) []string {
|
func normalizedKeywords(values []string) []string {
|
||||||
result := make([]string, 0, min(len(values), 8))
|
result := make([]string, 0, min(len(values), 8))
|
||||||
seen := make(map[string]struct{}, len(values))
|
seen := make(map[string]struct{}, len(values))
|
||||||
@@ -255,3 +322,32 @@ func deterministicKeywords(content string) []string {
|
|||||||
}
|
}
|
||||||
return []string{string(runes)}
|
return []string{string(runes)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deterministicContentDecision(content string) (meaningful, storyWorthy bool, reason string) {
|
||||||
|
compact := strings.Map(func(character rune) rune {
|
||||||
|
if unicode.IsSpace(character) || strings.ContainsRune(",。!?;、,.!?;::\"“”‘’()()-", character) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return character
|
||||||
|
}, content)
|
||||||
|
for _, label := range []string{"长者", "豆包", "助手", "用户", "agent", "subject"} {
|
||||||
|
compact = strings.ReplaceAll(compact, label, "")
|
||||||
|
}
|
||||||
|
for _, phrase := range []string{"你好", "您好", "在吗", "嗯", "啊", "哦", "好的", "知道了", "没事", "再见", "拜拜", "听得到吗", "能听见吗", "测试"} {
|
||||||
|
compact = strings.ReplaceAll(compact, phrase, "")
|
||||||
|
}
|
||||||
|
if len([]rune(compact)) < 12 {
|
||||||
|
return false, false, "内容仅为寒暄、确认或零散短句"
|
||||||
|
}
|
||||||
|
storySignals := 0
|
||||||
|
for _, phrase := range []string{"小时候", "年轻时", "那年", "当时", "后来", "以前", "记得", "有一次", "我们", "我和", "去了", "发生", "因为", "结果", "高兴", "难过", "想起"} {
|
||||||
|
if strings.Contains(content, phrase) {
|
||||||
|
storySignals++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
storyWorthy = len([]rune(compact)) >= 48 && storySignals >= 2
|
||||||
|
if storyWorthy {
|
||||||
|
return true, true, "包含较完整的个人经历与事件脉络"
|
||||||
|
}
|
||||||
|
return true, false, "包含可保留的信息,但尚不足以形成完整故事"
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package extraction
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLocalModelContentValueDecisions(t *testing.T) {
|
||||||
|
baseURL := os.Getenv("LOCAL_MODEL_BASE_URL")
|
||||||
|
if baseURL == "" {
|
||||||
|
t.Skip("LOCAL_MODEL_BASE_URL is not set")
|
||||||
|
}
|
||||||
|
modelID := os.Getenv("LOCAL_MODEL_ID")
|
||||||
|
if modelID == "" {
|
||||||
|
modelID = "qwen2.5:7b"
|
||||||
|
}
|
||||||
|
generator, err := NewCompatibleGenerator("openai_compatible", baseURL, "ollama-local-key", modelID, 0.1, 1200, 2*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name, content string
|
||||||
|
meaningful, storyWorth bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "discard small talk",
|
||||||
|
content: "豆包:您好,今天过得怎么样?\n长者:还行。\n豆包:吃饭了吗?\n长者:嗯,吃了。\n豆包:那您好好休息。\n长者:好,再见。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "keep useful update without story",
|
||||||
|
content: "豆包:今天胃口怎么样?\n长者:今天胃口挺好,中午吃了一碗西红柿鸡蛋面,晚上想吃点清淡的。",
|
||||||
|
meaningful: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "create story candidate from coherent memory",
|
||||||
|
content: "豆包:您还记得第一次当老师的情形吗?\n长者:记得。那是1978年秋天,我二十二岁,被分到河边小学。第一天是姐姐骑自行车送我去的,教室里有三十多个孩子。我紧张得把粉笔掉在地上,一个小姑娘帮我捡起来,全班都笑了,我也跟着笑。后来那个小姑娘一直读到大学,很多年后还回来看过我。",
|
||||||
|
meaningful: true, storyWorth: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
result, generateErr := generator.Generate(ctx, GenerationInput{Content: test.content})
|
||||||
|
if generateErr != nil {
|
||||||
|
t.Fatal(generateErr)
|
||||||
|
}
|
||||||
|
if result.Meaningful != test.meaningful || result.StoryWorthy != test.storyWorth || result.AllowStory != test.storyWorth {
|
||||||
|
t.Fatalf("decision mismatch: meaningful=%v story_worthy=%v allow_story=%v reason=%q summary=%q", result.Meaningful, result.StoryWorthy, result.AllowStory, result.DecisionReason, result.Summary)
|
||||||
|
}
|
||||||
|
if !result.Meaningful && (result.Summary != "" || result.StoryContent != "") {
|
||||||
|
t.Fatalf("discarded conversation produced content: %+v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ func TestArkGeneratorProducesValidatedContent(t *testing.T) {
|
|||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["model"] != "doubao-test" {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["model"] != "doubao-test" {
|
||||||
t.Fatalf("request body=%v err=%v", body, err)
|
t.Fatalf("request body=%v err=%v", body, err)
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": `{"summary":"谈到河边小学","keywords":["河边小学","童年"],"open_topic":"下次可以聊聊同学","story_title":"河边小学的回忆","story_content":"小时候在河边小学读书。","fact_content":"曾在河边小学读书","confidence":0.82}`}}}})
|
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": `{"meaningful":true,"story_worthy":true,"decision_reason":"包含具体的童年求学回忆","summary":"谈到河边小学","keywords":["河边小学","童年"],"open_topic":"下次可以聊聊同学","story_title":"河边小学的回忆","story_content":"小时候在河边小学读书。","fact_content":"曾在河边小学读书","confidence":0.82}`}}}})
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
generator, err := NewArkGenerator(server.URL+"/api/v3", "test-key", "doubao-test", time.Second)
|
generator, err := NewArkGenerator(server.URL+"/api/v3", "test-key", "doubao-test", time.Second)
|
||||||
@@ -33,6 +33,51 @@ func TestArkGeneratorProducesValidatedContent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompatibleGeneratorDiscardsSmallTalk(t *testing.T) {
|
||||||
|
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.96}`}}}})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
generator, err := NewCompatibleGenerator("openai_compatible", server.URL+"/v1", "local-key", "qwen-test", 0.1, 512, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := generator.Generate(context.Background(), GenerationInput{Content: "豆包:您好。\n长者:嗯,你好。\n豆包:今天怎么样?\n长者:还行。"})
|
||||||
|
if err != nil || result.Meaningful || result.StoryWorthy || result.AllowStory || result.DecisionReason == "" {
|
||||||
|
t.Fatalf("result=%+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompatibleGeneratorKeepsMeaningfulNonStoryWithoutDraft(t *testing.T) {
|
||||||
|
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":true,"story_worthy":false,"decision_reason":"有明确近况但没有事件脉络","summary":"长者今天胃口不错","keywords":["胃口","近况"],"open_topic":"下次可以聊聊最近喜欢的饭菜","story_title":"","story_content":"","fact_content":"","confidence":0.87}`}}}})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
generator, err := NewCompatibleGenerator("openai_compatible", server.URL+"/v1", "local-key", "qwen-test", 0.1, 512, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := generator.Generate(context.Background(), GenerationInput{Content: "长者:我今天胃口挺好的,中午吃了面条。"})
|
||||||
|
if err != nil || !result.Meaningful || result.StoryWorthy || result.AllowStory || result.Summary == "" {
|
||||||
|
t.Fatalf("result=%+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompatibleGeneratorClearsContradictoryStoryFields(t *testing.T) {
|
||||||
|
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":true,"story_worthy":false,"decision_reason":"只是近况","summary":"今天胃口不错","keywords":["胃口"],"open_topic":"最近喜欢吃什么?","story_title":"模型误填标题","story_content":"模型误填故事","fact_content":"模型误填事实","confidence":0.8}`}}}})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
generator, err := NewCompatibleGenerator("openai_compatible", server.URL+"/v1", "local-key", "qwen-test", 0.1, 512, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := generator.Generate(context.Background(), GenerationInput{Content: "长者:今天胃口不错。"})
|
||||||
|
if err != nil || result.AllowStory || result.StoryTitle != "" || result.StoryContent != "" || result.FactContent != "" {
|
||||||
|
t.Fatalf("result=%+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestArkGeneratorRetriesTemporaryFailure(t *testing.T) {
|
func TestArkGeneratorRetriesTemporaryFailure(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
http.Error(w, "busy", http.StatusTooManyRequests)
|
http.Error(w, "busy", http.StatusTooManyRequests)
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ func (p *Processor) Handle(ctx context.Context, event outbox.Event) error {
|
|||||||
if len(sourceTurns) == 0 {
|
if len(sourceTurns) == 0 {
|
||||||
return outbox.Retryable(ErrConversationContentNotReady)
|
return outbox.Retryable(ErrConversationContentNotReady)
|
||||||
}
|
}
|
||||||
content := joinedTurnContent(sourceTurns)
|
content := joinedConversationContent(snapshot.turns)
|
||||||
generated, err := p.generateSafely(ctx, content, snapshot.tenantID)
|
generated, err := p.generateSafely(ctx, content, snapshot.tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -247,12 +247,16 @@ func subjectTurns(turns []turn) []turn {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
func joinedTurnContent(turns []turn) string {
|
func joinedConversationContent(turns []turn) string {
|
||||||
parts := make([]string, 0, len(turns))
|
parts := make([]string, 0, len(turns))
|
||||||
for _, item := range turns {
|
for _, item := range turns {
|
||||||
parts = append(parts, item.text)
|
label := "豆包"
|
||||||
|
if item.speaker == "subject" {
|
||||||
|
label = "长者"
|
||||||
|
}
|
||||||
|
parts = append(parts, label+":"+item.text)
|
||||||
}
|
}
|
||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func storeGeneration(ctx context.Context, tx pgx.Tx, conversationID string, snapshot extractionSnapshot, generated GenerationResult, generationVersion int64) error {
|
func storeGeneration(ctx context.Context, tx pgx.Tx, conversationID string, snapshot extractionSnapshot, generated GenerationResult, generationVersion int64) error {
|
||||||
@@ -262,6 +266,15 @@ func storeGeneration(ctx context.Context, tx pgx.Tx, conversationID string, snap
|
|||||||
sourcePublicIDs = append(sourcePublicIDs, item.publicID)
|
sourcePublicIDs = append(sourcePublicIDs, item.publicID)
|
||||||
}
|
}
|
||||||
sourceJSON, _ := json.Marshal(sourcePublicIDs)
|
sourceJSON, _ := json.Marshal(sourcePublicIDs)
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO conversation_content_assessment (tenant_id,subject_id,conversation_id,generation_version,meaningful,story_worthy,decision_reason,model_ref,prompt_version,confidence)
|
||||||
|
VALUES ($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,$8,$9,$10)
|
||||||
|
ON CONFLICT (conversation_id,generation_version) DO NOTHING`, snapshot.tenantID, snapshot.subjectID, conversationID, generationVersion, generated.Meaningful, generated.StoryWorthy, generated.DecisionReason, generated.ModelRef, generated.PromptVersion, generated.Confidence); err != nil {
|
||||||
|
return fmt.Errorf("store conversation content assessment: %w", err)
|
||||||
|
}
|
||||||
|
if !generated.Meaningful {
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
if _, err := tx.Exec(ctx, `
|
if _, err := tx.Exec(ctx, `
|
||||||
INSERT INTO conversation_summary (tenant_id,subject_id,conversation_id,summary_type,content,source_turn_ids,model_ref,prompt_version,generation_version,confidence,status)
|
INSERT INTO conversation_summary (tenant_id,subject_id,conversation_id,summary_type,content,source_turn_ids,model_ref,prompt_version,generation_version,confidence,status)
|
||||||
VALUES ($1::uuid,$2::uuid,$3::uuid,'conversation_summary',$4,$5::jsonb,$6,$7,$8,$9,'READY')
|
VALUES ($1::uuid,$2::uuid,$3::uuid,'conversation_summary',$4,$5::jsonb,$6,$7,$8,$9,'READY')
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ func TestProcessorExtractionIdempotencyAndSensitiveContent(t *testing.T) {
|
|||||||
contextID, contextPublicID := uuid.NewString(), uuid.NewString()
|
contextID, contextPublicID := uuid.NewString(), uuid.NewString()
|
||||||
conversationID, conversationPublicID := uuid.NewString(), uuid.NewString()
|
conversationID, conversationPublicID := uuid.NewString(), uuid.NewString()
|
||||||
defer func() {
|
defer func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM conversation_content_assessment WHERE conversation_id=$1::uuid`, conversationID)
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM fact_candidate WHERE story_id IN (SELECT id FROM story_draft WHERE conversation_id=$1::uuid)`, conversationID)
|
_, _ = pool.Exec(ctx, `DELETE FROM fact_candidate WHERE story_id IN (SELECT id FROM story_draft WHERE conversation_id=$1::uuid)`, conversationID)
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM story_source WHERE story_revision_id IN (SELECT sr.id FROM story_revision sr JOIN story_draft sd ON sd.id=sr.story_id WHERE sd.conversation_id=$1::uuid)`, conversationID)
|
_, _ = pool.Exec(ctx, `DELETE FROM story_source WHERE story_revision_id IN (SELECT sr.id FROM story_revision sr JOIN story_draft sd ON sd.id=sr.story_id WHERE sd.conversation_id=$1::uuid)`, conversationID)
|
||||||
_, _ = pool.Exec(ctx, `UPDATE story_draft SET current_revision_id=NULL WHERE conversation_id=$1::uuid`, conversationID)
|
_, _ = pool.Exec(ctx, `UPDATE story_draft SET current_revision_id=NULL WHERE conversation_id=$1::uuid`, conversationID)
|
||||||
@@ -97,7 +98,7 @@ func TestProcessorExtractionIdempotencyAndSensitiveContent(t *testing.T) {
|
|||||||
http.Error(w, "database lock", http.StatusServiceUnavailable)
|
http.Error(w, "database lock", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, _ = io.WriteString(w, `{"choices":[{"message":{"content":"{\"summary\":\"长者谈到在河边小学教书\",\"keywords\":[\"河边小学\",\"教书经历\"],\"open_topic\":\"下次可以聊聊当年的学生\",\"story_title\":\"河边小学的回忆\",\"story_content\":\"我年轻时在河边小学教书。\",\"fact_content\":\"年轻时曾在河边小学教书\",\"confidence\":0.88}"}}]}`)
|
_, _ = io.WriteString(w, `{"choices":[{"message":{"content":"{\"meaningful\":true,\"story_worthy\":true,\"decision_reason\":\"包含具体的教书经历\",\"summary\":\"长者谈到在河边小学教书\",\"keywords\":[\"河边小学\",\"教书经历\"],\"open_topic\":\"下次可以聊聊当年的学生\",\"story_title\":\"河边小学的回忆\",\"story_content\":\"我年轻时在河边小学教书。\",\"fact_content\":\"年轻时曾在河边小学教书\",\"confidence\":0.88}"}}]}`)
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
arkGenerator, err := NewArkGenerator(server.URL+"/api/v3", "integration-key", "doubao-integration", 2*time.Second)
|
arkGenerator, err := NewArkGenerator(server.URL+"/api/v3", "integration-key", "doubao-integration", 2*time.Second)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type safetyTestGenerator struct{ calls int }
|
|||||||
func (g *safetyTestGenerator) Generate(context.Context, GenerationInput) (GenerationResult, error) {
|
func (g *safetyTestGenerator) Generate(context.Context, GenerationInput) (GenerationResult, error) {
|
||||||
g.calls++
|
g.calls++
|
||||||
return GenerationResult{
|
return GenerationResult{
|
||||||
|
Meaningful: true, StoryWorthy: true, DecisionReason: "测试故事",
|
||||||
Summary: "模型摘要", OpenTopic: "开放话题", StoryTitle: "标题",
|
Summary: "模型摘要", OpenTopic: "开放话题", StoryTitle: "标题",
|
||||||
StoryContent: "模型输出风险", FactContent: "普通事实", AllowStory: true,
|
StoryContent: "模型输出风险", FactContent: "普通事实", AllowStory: true,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ func (s *Server) sendFinalTranscript(input voiceprovider.StartRequest) {
|
|||||||
TaskRef: input.TaskRef, ConversationID: input.ConversationID,
|
TaskRef: input.TaskRef, ConversationID: input.ConversationID,
|
||||||
EventType: "voice_chat.transcript.final", Status: "FINAL", OccurredAt: s.now().UTC(),
|
EventType: "voice_chat.transcript.final", Status: "FINAL", OccurredAt: s.now().UTC(),
|
||||||
Turns: []voiceprovider.FinalTurn{
|
Turns: []voiceprovider.FinalTurn{
|
||||||
{TurnKey: "turn-1", SequenceNo: 1, Speaker: "subject", StartAt: base, EndAt: base.Add(6 * time.Second), FinalText: "我年轻时常在家乡河边散步,春天的柳树特别好看。", ProviderRef: "mock-turn-1"},
|
{TurnKey: "turn-1", SequenceNo: 1, Speaker: "subject", StartAt: base, EndAt: base.Add(6 * time.Second), FinalText: "我年轻时常和姐姐在家乡河边散步。有一次春天突然下雨,我们躲在老柳树下,后来一路笑着跑回家,现在想起来还是很高兴。", ProviderRef: "mock-turn-1"},
|
||||||
{TurnKey: "turn-2", SequenceNo: 2, Speaker: "agent", StartAt: base.Add(7 * time.Second), EndAt: base.Add(13 * time.Second), FinalText: "听起来那是一段很温暖的回忆,下次我们可以继续聊聊家乡的春天。", ProviderRef: "mock-turn-2"},
|
{TurnKey: "turn-2", SequenceNo: 2, Speaker: "agent", StartAt: base.Add(7 * time.Second), EndAt: base.Add(13 * time.Second), FinalText: "听起来那是一段很温暖的回忆,下次我们可以继续聊聊家乡的春天。", ProviderRef: "mock-turn-2"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ func (p *DeleteProcessor) redactPostgreSQL(ctx context.Context, request deletion
|
|||||||
`UPDATE subject_fact SET content='[已按隐私请求删除]',source_ref=NULL,confirmation_status='superseded',valid_to=COALESCE(valid_to,now()),version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
`UPDATE subject_fact SET content='[已按隐私请求删除]',source_ref=NULL,confirmation_status='superseded',valid_to=COALESCE(valid_to,now()),version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||||
`UPDATE conversation_turn SET final_text='[已按隐私请求删除]',provider_ref=NULL WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
`UPDATE conversation_turn SET final_text='[已按隐私请求删除]',provider_ref=NULL WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||||
`UPDATE conversation_summary SET content='[已按隐私请求删除]',source_turn_ids='["privacy-redacted"]'::jsonb,status='SUPERSEDED',last_error=NULL WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
`UPDATE conversation_summary SET content='[已按隐私请求删除]',source_turn_ids='["privacy-redacted"]'::jsonb,status='SUPERSEDED',last_error=NULL WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||||
|
`UPDATE conversation_content_assessment SET decision_reason='[privacy-redacted]',model_ref='privacy-redacted',prompt_version='privacy-redacted' WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||||
`UPDATE story_revision sr SET content='[已按隐私请求删除]',edit_reason=NULL,model_ref=NULL,prompt_version=NULL FROM story_draft sd WHERE sr.story_id=sd.id AND sd.tenant_id=$1::uuid AND sd.subject_id=$2::uuid`,
|
`UPDATE story_revision sr SET content='[已按隐私请求删除]',edit_reason=NULL,model_ref=NULL,prompt_version=NULL FROM story_draft sd WHERE sr.story_id=sd.id AND sd.tenant_id=$1::uuid AND sd.subject_id=$2::uuid`,
|
||||||
`UPDATE story_draft SET title='[已按隐私请求删除]',status='SUPERSEDED',review_reason=NULL,version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
`UPDATE story_draft SET title='[已按隐私请求删除]',status='SUPERSEDED',review_reason=NULL,version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||||
`UPDATE fact_candidate SET content='[已按隐私请求删除]',time_range='{}'::jsonb,source_turn_ids='["privacy-redacted"]'::jsonb,status='SUPERSEDED',review_reason=NULL,version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
`UPDATE fact_candidate SET content='[已按隐私请求删除]',time_range='{}'::jsonb,source_turn_ids='["privacy-redacted"]'::jsonb,status='SUPERSEDED',review_reason=NULL,version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS conversation_content_assessment;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE conversation_content_assessment (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
public_id uuid NOT NULL UNIQUE DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id uuid NOT NULL REFERENCES tenant(id),
|
||||||
|
subject_id uuid NOT NULL,
|
||||||
|
conversation_id uuid NOT NULL,
|
||||||
|
generation_version bigint NOT NULL CHECK (generation_version > 0),
|
||||||
|
meaningful boolean NOT NULL,
|
||||||
|
story_worthy boolean NOT NULL,
|
||||||
|
decision_reason varchar(500) NOT NULL CHECK (length(decision_reason) > 0),
|
||||||
|
model_ref varchar(160) NOT NULL,
|
||||||
|
prompt_version varchar(80) NOT NULL,
|
||||||
|
confidence numeric(5,4) NOT NULL CHECK (confidence BETWEEN 0 AND 1),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT conversation_content_assessment_decision_check CHECK (meaningful OR NOT story_worthy),
|
||||||
|
CONSTRAINT conversation_content_assessment_conversation_tenant_fk FOREIGN KEY (conversation_id, tenant_id)
|
||||||
|
REFERENCES conversation (id, tenant_id),
|
||||||
|
CONSTRAINT conversation_content_assessment_subject_tenant_fk FOREIGN KEY (subject_id, tenant_id)
|
||||||
|
REFERENCES subject_profile (id, tenant_id),
|
||||||
|
CONSTRAINT conversation_content_assessment_generation_key UNIQUE (conversation_id, generation_version)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX conversation_content_assessment_conversation_idx
|
||||||
|
ON conversation_content_assessment (conversation_id, generation_version DESC);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -98,6 +98,7 @@ DELETE FROM story_source WHERE story_revision_id IN (SELECT id FROM story_revisi
|
|||||||
UPDATE story_draft SET current_revision_id=NULL WHERE id IN (SELECT id FROM smoke_story_cleanup);
|
UPDATE story_draft SET current_revision_id=NULL WHERE id IN (SELECT id FROM smoke_story_cleanup);
|
||||||
DELETE FROM story_revision WHERE story_id IN (SELECT id FROM smoke_story_cleanup);
|
DELETE FROM story_revision WHERE story_id IN (SELECT id FROM smoke_story_cleanup);
|
||||||
DELETE FROM story_draft WHERE id IN (SELECT id FROM smoke_story_cleanup);
|
DELETE FROM story_draft WHERE id IN (SELECT id FROM smoke_story_cleanup);
|
||||||
|
DELETE FROM conversation_content_assessment WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
|
||||||
DELETE FROM conversation_summary WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
|
DELETE FROM conversation_summary WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
|
||||||
DELETE FROM conversation_turn WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
|
DELETE FROM conversation_turn WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
|
||||||
DELETE FROM provider_event WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
|
DELETE FROM provider_event WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
|
||||||
|
|||||||
Reference in New Issue
Block a user