2026-08-26 06:41:07 +08:00
|
|
|
|
package extraction
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"net/url"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
2026-08-29 21:56:51 +08:00
|
|
|
|
"unicode"
|
2026-08-26 06:41:07 +08:00
|
|
|
|
|
|
|
|
|
|
"data-terminal-cloud/internal/contentpolicy"
|
|
|
|
|
|
"data-terminal-cloud/internal/outbox"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type GenerationInput struct {
|
2026-10-08 10:06:44 +08:00
|
|
|
|
TenantID string
|
|
|
|
|
|
Content string
|
|
|
|
|
|
ChatDate string
|
|
|
|
|
|
PreviousStories []PreviousStory
|
2026-08-26 06:41:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type GenerationResult struct {
|
2026-10-08 10:06:44 +08:00
|
|
|
|
Meaningful bool `json:"meaningful"`
|
|
|
|
|
|
StoryWorthy bool `json:"story_worthy"`
|
|
|
|
|
|
DecisionReason string `json:"decision_reason"`
|
|
|
|
|
|
Summary string `json:"summary"`
|
|
|
|
|
|
OpenTopic string `json:"open_topic"`
|
|
|
|
|
|
StoryTitle string `json:"story_title"`
|
|
|
|
|
|
StoryContent string `json:"story_content"`
|
|
|
|
|
|
FactContent string `json:"fact_content"`
|
|
|
|
|
|
Keywords []string `json:"keywords"`
|
|
|
|
|
|
Confidence float64 `json:"confidence"`
|
|
|
|
|
|
ModelRef string `json:"-"`
|
|
|
|
|
|
PromptVersion string `json:"-"`
|
|
|
|
|
|
AllowStory bool `json:"-"`
|
|
|
|
|
|
DailyEvents []DailyEvent `json:"daily_events,omitempty"`
|
|
|
|
|
|
Stories []EventStory `json:"stories,omitempty"`
|
2026-08-26 06:41:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type Generator interface {
|
|
|
|
|
|
Generate(context.Context, GenerationInput) (GenerationResult, error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type DeterministicGenerator struct{}
|
|
|
|
|
|
|
|
|
|
|
|
func (DeterministicGenerator) Generate(_ context.Context, input GenerationInput) (GenerationResult, error) {
|
|
|
|
|
|
content := strings.TrimSpace(input.Content)
|
|
|
|
|
|
if content == "" {
|
|
|
|
|
|
return GenerationResult{}, errors.New("extraction content is required")
|
|
|
|
|
|
}
|
2026-08-30 15:20:08 +08:00
|
|
|
|
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 := "", "", ""
|
2026-09-06 20:16:01 +08:00
|
|
|
|
openTopic := ""
|
2026-08-30 15:20:08 +08:00
|
|
|
|
if storyWorthy {
|
|
|
|
|
|
storyTitle = "关于“" + truncateRunes(content, 24) + "”的回忆"
|
|
|
|
|
|
storyContent = content
|
|
|
|
|
|
factContent = content
|
2026-09-06 20:16:01 +08:00
|
|
|
|
openTopic = "如果长者愿意,下次可以从刚才经历里一个尚未讲清的具体细节自然接着聊。"
|
2026-08-30 15:20:08 +08:00
|
|
|
|
}
|
2026-08-26 06:41:07 +08:00
|
|
|
|
return GenerationResult{
|
2026-08-30 15:20:08 +08:00
|
|
|
|
Meaningful: true,
|
|
|
|
|
|
StoryWorthy: storyWorthy,
|
|
|
|
|
|
DecisionReason: reason,
|
|
|
|
|
|
Summary: "长者在本次通话中谈到:" + content,
|
2026-09-06 20:16:01 +08:00
|
|
|
|
OpenTopic: openTopic,
|
2026-08-30 15:20:08 +08:00
|
|
|
|
StoryTitle: storyTitle,
|
|
|
|
|
|
StoryContent: storyContent,
|
|
|
|
|
|
FactContent: factContent,
|
|
|
|
|
|
Keywords: deterministicKeywords(content),
|
|
|
|
|
|
Confidence: 0.9,
|
|
|
|
|
|
ModelRef: "local-deterministic-extractor",
|
|
|
|
|
|
PromptVersion: "content-value-deterministic-v2",
|
|
|
|
|
|
AllowStory: storyWorthy,
|
2026-08-26 06:41:07 +08:00
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func sensitiveGeneration(decision contentpolicy.Decision) GenerationResult {
|
|
|
|
|
|
modelRef := strings.TrimSpace(decision.ProviderRef)
|
|
|
|
|
|
if modelRef == "" {
|
|
|
|
|
|
modelRef = "local-content-safety-policy"
|
|
|
|
|
|
}
|
|
|
|
|
|
reason := safePolicyToken(decision.Reason)
|
|
|
|
|
|
return GenerationResult{
|
2026-08-30 15:20:08 +08:00
|
|
|
|
Meaningful: true,
|
|
|
|
|
|
StoryWorthy: false,
|
|
|
|
|
|
DecisionReason: "safety_policy_blocked_story",
|
|
|
|
|
|
Summary: "本次通话包含需要人工谨慎处理的敏感信息,系统未生成普通故事卡片。",
|
2026-09-06 20:16:01 +08:00
|
|
|
|
OpenTopic: "",
|
2026-08-30 15:20:08 +08:00
|
|
|
|
Confidence: 1,
|
|
|
|
|
|
ModelRef: modelRef,
|
|
|
|
|
|
PromptVersion: "summary-sensitive-filter-v1:" + reason,
|
|
|
|
|
|
AllowStory: false,
|
2026-08-26 06:41:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func safePolicyToken(value string) string {
|
|
|
|
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
|
|
|
|
if value == "" || len(value) > 64 {
|
|
|
|
|
|
return "policy_block"
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, character := range value {
|
|
|
|
|
|
if (character < 'a' || character > 'z') && (character < '0' || character > '9') && character != '_' && character != '-' {
|
|
|
|
|
|
return "policy_block"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ArkGenerator struct {
|
2026-10-08 10:06:44 +08:00
|
|
|
|
endpoint string
|
|
|
|
|
|
apiKey string
|
|
|
|
|
|
model string
|
|
|
|
|
|
provider string
|
|
|
|
|
|
temperature float64
|
|
|
|
|
|
maxTokens int
|
|
|
|
|
|
jsonMaxTokens int
|
|
|
|
|
|
client *http.Client
|
2026-08-26 06:41:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 10:06:44 +08:00
|
|
|
|
func (*ArkGenerator) SupportsEventStories() bool { return true }
|
|
|
|
|
|
|
2026-08-26 06:41:07 +08:00
|
|
|
|
func NewArkGenerator(baseURL, apiKey, model string, timeout time.Duration) (*ArkGenerator, error) {
|
2026-10-08 10:06:44 +08:00
|
|
|
|
return NewCompatibleGenerator("ark", baseURL, apiKey, model, 0.2, 8192, timeout)
|
2026-08-30 10:51:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func NewCompatibleGenerator(provider, baseURL, apiKey, model string, temperature float64, maxTokens int, timeout time.Duration) (*ArkGenerator, error) {
|
|
|
|
|
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
2026-08-26 06:41:07 +08:00
|
|
|
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
|
|
|
|
|
apiKey = strings.TrimSpace(apiKey)
|
|
|
|
|
|
model = strings.TrimSpace(model)
|
|
|
|
|
|
parsed, err := url.Parse(baseURL)
|
|
|
|
|
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
2026-08-30 19:55:17 +08:00
|
|
|
|
return nil, errors.New("content model base URL must be an absolute HTTP(S) URL")
|
2026-08-26 06:41:07 +08:00
|
|
|
|
}
|
2026-08-30 19:55:17 +08:00
|
|
|
|
if !SupportsCompatibleProvider(provider) {
|
|
|
|
|
|
return nil, errors.New("unsupported content model provider")
|
2026-08-30 10:51:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
if apiKey == "" || model == "" || timeout <= 0 || temperature < 0 || temperature > 2 || maxTokens < 256 || maxTokens > 8192 {
|
|
|
|
|
|
return nil, errors.New("content model credentials and valid generation parameters are required")
|
2026-08-26 06:41:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
return &ArkGenerator{
|
|
|
|
|
|
endpoint: baseURL + "/chat/completions", apiKey: apiKey, model: model,
|
2026-08-30 10:51:32 +08:00
|
|
|
|
provider: provider, temperature: temperature, maxTokens: maxTokens,
|
2026-08-26 06:41:07 +08:00
|
|
|
|
client: &http.Client{Timeout: timeout},
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (GenerationResult, error) {
|
|
|
|
|
|
content := strings.TrimSpace(input.Content)
|
|
|
|
|
|
if content == "" {
|
|
|
|
|
|
return GenerationResult{}, errors.New("extraction content is required")
|
|
|
|
|
|
}
|
|
|
|
|
|
requestBody := map[string]any{
|
|
|
|
|
|
"model": g.model,
|
|
|
|
|
|
"messages": []map[string]string{
|
2026-08-30 15:20:08 +08:00
|
|
|
|
{"role": "system", "content": contentValuePrompt},
|
2026-10-08 10:06:44 +08:00
|
|
|
|
{"role": "user", "content": generationContent(input)},
|
2026-08-26 06:41:07 +08:00
|
|
|
|
},
|
2026-08-30 19:55:17 +08:00
|
|
|
|
"temperature": g.temperature,
|
|
|
|
|
|
"max_tokens": g.maxTokens,
|
|
|
|
|
|
}
|
2026-10-08 10:06:44 +08:00
|
|
|
|
if g.provider == "ark" {
|
|
|
|
|
|
requestBody["thinking"] = map[string]string{"type": "disabled"}
|
|
|
|
|
|
}
|
2026-08-30 19:55:17 +08:00
|
|
|
|
// JSON mode is sent only where the provider documents it or this project
|
|
|
|
|
|
// has exercised it. Other compatible providers still receive an explicit
|
|
|
|
|
|
// JSON-only prompt, followed by strict response extraction and validation.
|
|
|
|
|
|
if providerSupportsJSONMode(g.provider) {
|
|
|
|
|
|
requestBody["response_format"] = map[string]string{"type": "json_object"}
|
2026-08-26 06:41:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
encoded, err := json.Marshal(requestBody)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return GenerationResult{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.endpoint, bytes.NewReader(encoded))
|
|
|
|
|
|
if err != nil {
|
2026-08-30 19:55:17 +08:00
|
|
|
|
return GenerationResult{}, fmt.Errorf("build content model request: %w", err)
|
2026-08-26 06:41:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+g.apiKey)
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
req.Header.Set("User-Agent", "voice-platform-content-extractor/1.0")
|
|
|
|
|
|
resp, err := g.client.Do(req)
|
|
|
|
|
|
if err != nil {
|
2026-08-30 19:55:17 +08:00
|
|
|
|
return GenerationResult{}, outbox.Retryable(fmt.Errorf("call content model: %w", err))
|
2026-08-26 06:41:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
|
|
|
|
if readErr != nil {
|
2026-08-30 19:55:17 +08:00
|
|
|
|
return GenerationResult{}, outbox.Retryable(fmt.Errorf("read content model response: %w", readErr))
|
2026-08-26 06:41:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
2026-08-30 19:55:17 +08:00
|
|
|
|
requestErr := fmt.Errorf("content model returned HTTP %d", resp.StatusCode)
|
2026-08-26 06:41:07 +08:00
|
|
|
|
if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusTooEarly || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
|
|
|
|
|
|
return GenerationResult{}, outbox.Retryable(requestErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
return GenerationResult{}, requestErr
|
|
|
|
|
|
}
|
|
|
|
|
|
var completion struct {
|
|
|
|
|
|
Choices []struct {
|
2026-10-08 10:06:44 +08:00
|
|
|
|
FinishReason string `json:"finish_reason"`
|
|
|
|
|
|
Message struct {
|
2026-08-26 06:41:07 +08:00
|
|
|
|
Content string `json:"content"`
|
|
|
|
|
|
} `json:"message"`
|
|
|
|
|
|
} `json:"choices"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err = json.Unmarshal(responseBody, &completion); err != nil || len(completion.Choices) == 0 {
|
2026-08-30 19:55:17 +08:00
|
|
|
|
return GenerationResult{}, outbox.Retryable(errors.New("content model response has no usable choice"))
|
2026-08-26 06:41:07 +08:00
|
|
|
|
}
|
2026-10-08 10:06:44 +08:00
|
|
|
|
if completion.Choices[0].FinishReason == "length" {
|
|
|
|
|
|
return GenerationResult{}, outbox.Retryable(errors.New("content model result truncated"))
|
|
|
|
|
|
}
|
2026-08-30 19:55:17 +08:00
|
|
|
|
jsonContent := extractJSONObject(completion.Choices[0].Message.Content)
|
2026-08-26 06:41:07 +08:00
|
|
|
|
jsonContent = strings.TrimPrefix(jsonContent, "```json")
|
|
|
|
|
|
jsonContent = strings.TrimPrefix(jsonContent, "```")
|
|
|
|
|
|
jsonContent = strings.TrimSuffix(jsonContent, "```")
|
|
|
|
|
|
var result GenerationResult
|
|
|
|
|
|
if err = json.Unmarshal([]byte(strings.TrimSpace(jsonContent)), &result); err != nil {
|
2026-08-30 19:55:17 +08:00
|
|
|
|
return GenerationResult{}, outbox.Retryable(fmt.Errorf("decode structured content: %w", err))
|
2026-08-26 06:41:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
result.Summary = strings.TrimSpace(result.Summary)
|
2026-08-30 15:20:08 +08:00
|
|
|
|
result.DecisionReason = strings.TrimSpace(result.DecisionReason)
|
2026-08-26 06:41:07 +08:00
|
|
|
|
result.OpenTopic = strings.TrimSpace(result.OpenTopic)
|
|
|
|
|
|
result.StoryTitle = strings.TrimSpace(result.StoryTitle)
|
|
|
|
|
|
result.StoryContent = strings.TrimSpace(result.StoryContent)
|
|
|
|
|
|
result.FactContent = strings.TrimSpace(result.FactContent)
|
2026-08-29 21:56:51 +08:00
|
|
|
|
result.Keywords = normalizedKeywords(result.Keywords)
|
2026-10-08 10:06:44 +08:00
|
|
|
|
if result.DailyEvents != nil {
|
|
|
|
|
|
// Review dates separately from story editing: a past event can be about
|
|
|
|
|
|
// ordinary life, and today's speech can describe a future plan.
|
|
|
|
|
|
raw, marshalErr := json.Marshal(map[string]any{"chat_date": input.ChatDate, "transcript": input.Content})
|
|
|
|
|
|
if marshalErr != nil {
|
|
|
|
|
|
return GenerationResult{}, marshalErr
|
|
|
|
|
|
}
|
|
|
|
|
|
checked, checkErr := g.GenerateJSON(ctx, string(raw), dailyExtractionPrompt)
|
|
|
|
|
|
if checkErr != nil {
|
|
|
|
|
|
return GenerationResult{}, checkErr
|
|
|
|
|
|
}
|
|
|
|
|
|
var daily struct {
|
|
|
|
|
|
Events []DailyEvent `json:"daily_events"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if json.Unmarshal(checked, &daily) != nil || daily.Events == nil {
|
|
|
|
|
|
return GenerationResult{}, errors.New("daily classification failed validation")
|
|
|
|
|
|
}
|
|
|
|
|
|
result.DailyEvents = daily.Events
|
|
|
|
|
|
}
|
|
|
|
|
|
if err = normalizeEvents(&result, input); err != nil {
|
|
|
|
|
|
return GenerationResult{}, outbox.Retryable(err)
|
|
|
|
|
|
}
|
2026-08-30 15:20:08 +08:00
|
|
|
|
normalizeDecisionContent(&result)
|
2026-08-26 06:41:07 +08:00
|
|
|
|
if !validGeneratedContent(result) {
|
2026-08-30 19:55:17 +08:00
|
|
|
|
return GenerationResult{}, outbox.Retryable(errors.New("structured content failed validation"))
|
2026-08-26 06:41:07 +08:00
|
|
|
|
}
|
2026-08-30 10:51:32 +08:00
|
|
|
|
result.ModelRef = g.provider + ":" + g.model
|
2026-10-08 10:06:44 +08:00
|
|
|
|
for _, d := range result.DailyEvents {
|
|
|
|
|
|
if d.TimeScope != "today_actual" || strings.TrimSpace(d.Evidence) == "" || !strings.Contains(input.Content, d.Evidence) {
|
|
|
|
|
|
return GenerationResult{}, errors.New("daily event lacks evidence of an actual event today")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
actual := make([]DailyEvent, 0, len(result.DailyEvents))
|
|
|
|
|
|
for _, d := range result.DailyEvents {
|
|
|
|
|
|
future := false
|
|
|
|
|
|
for _, phrase := range []string{"明天", "明早", "前几天", "打算", "计划", "尚未", "还没动手", "想着", "还想", "一会儿", "以后再"} {
|
|
|
|
|
|
if strings.Contains(d.Content, phrase) {
|
|
|
|
|
|
future = true
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !future {
|
|
|
|
|
|
actual = append(actual, d)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if result.DailyEvents != nil {
|
|
|
|
|
|
result.DailyEvents = actual
|
|
|
|
|
|
}
|
|
|
|
|
|
result.PromptVersion = "content-events-" + g.provider + "-json-v5"
|
2026-08-30 15:20:08 +08:00
|
|
|
|
result.AllowStory = result.Meaningful && result.StoryWorthy
|
2026-08-26 06:41:07 +08:00
|
|
|
|
return result, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 19:55:17 +08:00
|
|
|
|
// SupportsCompatibleProvider is the single backend allowlist used both when a
|
|
|
|
|
|
// model configuration is saved and when a worker resolves that configuration.
|
|
|
|
|
|
func SupportsCompatibleProvider(provider string) bool {
|
|
|
|
|
|
switch provider {
|
|
|
|
|
|
case "ark", "dashscope", "deepseek", "moonshot", "zhipu", "qianfan", "hunyuan", "minimax", "baichuan", "siliconflow", "ollama", "openai_compatible":
|
|
|
|
|
|
return true
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func providerSupportsJSONMode(provider string) bool {
|
|
|
|
|
|
switch provider {
|
|
|
|
|
|
case "ark", "dashscope", "deepseek", "siliconflow", "ollama", "openai_compatible":
|
|
|
|
|
|
return true
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func extractJSONObject(content string) string {
|
|
|
|
|
|
content = strings.TrimSpace(content)
|
|
|
|
|
|
if end := strings.LastIndex(content, "</think>"); end >= 0 {
|
|
|
|
|
|
content = strings.TrimSpace(content[end+len("</think>"):])
|
|
|
|
|
|
}
|
|
|
|
|
|
content = strings.TrimPrefix(content, "```json")
|
|
|
|
|
|
content = strings.TrimPrefix(content, "```")
|
|
|
|
|
|
content = strings.TrimSuffix(content, "```")
|
|
|
|
|
|
content = strings.TrimSpace(content)
|
|
|
|
|
|
start, end := strings.Index(content, "{"), strings.LastIndex(content, "}")
|
|
|
|
|
|
if start >= 0 && end >= start {
|
|
|
|
|
|
return content[start : end+1]
|
|
|
|
|
|
}
|
|
|
|
|
|
return content
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 15:20:08 +08:00
|
|
|
|
func normalizeDecisionContent(result *GenerationResult) {
|
|
|
|
|
|
if !result.Meaningful {
|
2026-10-08 10:06:44 +08:00
|
|
|
|
result.Stories = nil
|
|
|
|
|
|
result.DailyEvents = nil
|
2026-08-30 15:20:08 +08:00
|
|
|
|
result.StoryWorthy = false
|
|
|
|
|
|
result.Summary = ""
|
|
|
|
|
|
result.OpenTopic = ""
|
|
|
|
|
|
result.StoryTitle = ""
|
|
|
|
|
|
result.StoryContent = ""
|
|
|
|
|
|
result.FactContent = ""
|
|
|
|
|
|
result.Keywords = nil
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if !result.StoryWorthy {
|
2026-10-08 10:06:44 +08:00
|
|
|
|
result.Stories = nil
|
2026-08-30 15:20:08 +08:00
|
|
|
|
result.StoryTitle = ""
|
|
|
|
|
|
result.StoryContent = ""
|
|
|
|
|
|
result.FactContent = ""
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-26 06:41:07 +08:00
|
|
|
|
func validGeneratedContent(result GenerationResult) bool {
|
2026-08-30 15:20:08 +08:00
|
|
|
|
if result.Confidence < 0 || result.Confidence > 1 || result.DecisionReason == "" || len([]rune(result.DecisionReason)) > 500 {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
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 ||
|
2026-09-06 20:16:01 +08:00
|
|
|
|
len([]rune(result.OpenTopic)) > 1000 ||
|
2026-08-30 15:20:08 +08:00
|
|
|
|
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 &&
|
2026-08-26 06:41:07 +08:00
|
|
|
|
result.StoryContent != "" && len([]rune(result.StoryContent)) <= 20000 &&
|
|
|
|
|
|
result.FactContent != "" && len([]rune(result.FactContent)) <= 5000 &&
|
|
|
|
|
|
result.Confidence >= 0 && result.Confidence <= 1
|
|
|
|
|
|
}
|
2026-08-29 21:56:51 +08:00
|
|
|
|
|
2026-10-08 10:06:44 +08:00
|
|
|
|
const contentValuePrompt = eventExtractionPrompt
|
2026-08-30 15:20:08 +08:00
|
|
|
|
|
2026-08-29 21:56:51 +08:00
|
|
|
|
func normalizedKeywords(values []string) []string {
|
|
|
|
|
|
result := make([]string, 0, min(len(values), 8))
|
|
|
|
|
|
seen := make(map[string]struct{}, len(values))
|
|
|
|
|
|
for _, value := range values {
|
|
|
|
|
|
value = strings.TrimSpace(value)
|
|
|
|
|
|
if value == "" || len([]rune(value)) > 40 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, exists := seen[value]; exists {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
seen[value] = struct{}{}
|
|
|
|
|
|
result = append(result, value)
|
|
|
|
|
|
if len(result) == 8 {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func deterministicKeywords(content string) []string {
|
|
|
|
|
|
parts := strings.FieldsFunc(content, func(character rune) bool {
|
|
|
|
|
|
return unicode.IsSpace(character) || strings.ContainsRune(",。!?;、,.!?;::\"“”‘’()()", character)
|
|
|
|
|
|
})
|
|
|
|
|
|
candidates := make([]string, 0, len(parts))
|
|
|
|
|
|
for _, part := range parts {
|
|
|
|
|
|
runes := []rune(strings.TrimSpace(part))
|
|
|
|
|
|
if len(runes) < 2 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(runes) > 12 {
|
|
|
|
|
|
runes = runes[:12]
|
|
|
|
|
|
}
|
|
|
|
|
|
candidates = append(candidates, string(runes))
|
|
|
|
|
|
}
|
|
|
|
|
|
keywords := normalizedKeywords(candidates)
|
|
|
|
|
|
if len(keywords) > 0 {
|
|
|
|
|
|
return keywords
|
|
|
|
|
|
}
|
|
|
|
|
|
runes := []rune(content)
|
|
|
|
|
|
if len(runes) > 12 {
|
|
|
|
|
|
runes = runes[:12]
|
|
|
|
|
|
}
|
|
|
|
|
|
return []string{string(runes)}
|
|
|
|
|
|
}
|
2026-08-30 15:20:08 +08:00
|
|
|
|
|
|
|
|
|
|
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, "包含可保留的信息,但尚不足以形成完整故事"
|
|
|
|
|
|
}
|