171 lines
5.8 KiB
Go
171 lines
5.8 KiB
Go
package ctxmgr
|
||
|
||
import (
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// ConversationTaskState 会话级任务状态(DST,对话状态跟踪)
|
||
// 与项目级黑板的分工:黑板是全项目多方共享的全局 SSOT;
|
||
// task_state 是记录“当前这场会话的任务推进到哪一步”的会话私有工作记忆。
|
||
type ConversationTaskState struct {
|
||
ActiveTask string `json:"active_task,omitempty"` // 当前对话任务类型(如 intake_consult / case_compare / doc_reject_consult)
|
||
TopicAnchor string `json:"topic_anchor,omitempty"` // 主题锚点:最近一轮有效核心问句
|
||
TopicEntities []string `json:"topic_entities,omitempty"` // 锚点实体(如项目名、器械分类、国家标准编号)
|
||
CollectedSlots map[string]string `json:"collected_slots,omitempty"` // 已收集参数槽位
|
||
MissingSlots []string `json:"missing_slots,omitempty"` // 待补充关键槽位
|
||
OpenQuestions []string `json:"open_questions,omitempty"` // 未决技术/商务问题
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
// NewConversationTaskState 创建初始对话状态
|
||
func NewConversationTaskState() *ConversationTaskState {
|
||
return &ConversationTaskState{
|
||
CollectedSlots: make(map[string]string),
|
||
MissingSlots: make([]string, 0),
|
||
OpenQuestions: make([]string, 0),
|
||
TopicEntities: make([]string, 0),
|
||
UpdatedAt: time.Now(),
|
||
}
|
||
}
|
||
|
||
// UpdateFromTurn 根据当轮输入、识别槽位与分支流转刷新状态机
|
||
func (ts *ConversationTaskState) UpdateFromTurn(userInput string, intentName string, filledSlots map[string]string, branch string, stage string) {
|
||
if ts.CollectedSlots == nil {
|
||
ts.CollectedSlots = make(map[string]string)
|
||
}
|
||
ts.UpdatedAt = time.Now()
|
||
|
||
// 1. 更新活跃任务与主题锚点
|
||
if intentName != "" && intentName != "CASUAL_CHAT" {
|
||
ts.ActiveTask = intentName
|
||
}
|
||
trimmedInput := strings.TrimSpace(userInput)
|
||
if isCompleteAnchorCandidate(trimmedInput) {
|
||
ts.TopicAnchor = trimmedInput
|
||
}
|
||
|
||
// 2. 合入已识别槽位
|
||
for k, v := range filledSlots {
|
||
if strings.TrimSpace(v) != "" {
|
||
ts.CollectedSlots[k] = strings.TrimSpace(v)
|
||
}
|
||
}
|
||
|
||
// 3. 提取常见主题实体(器械类型、强标号等)
|
||
ts.extractTopicEntities(trimmedInput)
|
||
|
||
// 4. 计算当前阶段缺失槽位
|
||
ts.computeMissingSlots(stage)
|
||
|
||
// 5. 若分支为极速兑现(EXPECTATION_HIT),若已完成某项挂起待办,相应清理未决问题
|
||
if branch == "EXPECTATION_HIT" && len(ts.OpenQuestions) > 0 {
|
||
ts.OpenQuestions = ts.OpenQuestions[1:]
|
||
}
|
||
}
|
||
|
||
func (ts *ConversationTaskState) extractTopicEntities(input string) {
|
||
entityCandidates := []string{
|
||
"超声手术设备", "超声刀", "高频超声", "掌上超声", "内窥镜", "腹腔镜", "医用电气设备",
|
||
"GB 9706.1-2020", "YY 9706.102", "YY 0505", "GB/T 42062", "CE认证", "NMPA", "FDA 510(k)",
|
||
}
|
||
for _, cand := range entityCandidates {
|
||
if strings.Contains(input, cand) && !containsString(ts.TopicEntities, cand) {
|
||
ts.TopicEntities = append(ts.TopicEntities, cand)
|
||
if len(ts.TopicEntities) > 6 {
|
||
ts.TopicEntities = ts.TopicEntities[len(ts.TopicEntities)-6:]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func (ts *ConversationTaskState) computeMissingSlots(stage string) {
|
||
required := []string{"device_type", "target_market"}
|
||
if stage == "V0.1_triage" || stage == "intake" {
|
||
required = append(required, "budget")
|
||
}
|
||
|
||
missing := make([]string, 0)
|
||
for _, req := range required {
|
||
if _, ok := ts.CollectedSlots[req]; !ok {
|
||
missing = append(missing, req)
|
||
}
|
||
}
|
||
ts.MissingSlots = missing
|
||
}
|
||
|
||
// RenderPromptSnippet 渲染为 Prompt 中的【工作记忆】段
|
||
func (ts *ConversationTaskState) RenderPromptSnippet() string {
|
||
if ts == nil {
|
||
return ""
|
||
}
|
||
|
||
var sb strings.Builder
|
||
sb.WriteString("【本轮任务状态 (Working Memory)】\n")
|
||
if ts.ActiveTask != "" {
|
||
sb.WriteString(fmt.Sprintf("- 活跃任务: %s\n", ts.ActiveTask))
|
||
}
|
||
if ts.TopicAnchor != "" {
|
||
sb.WriteString(fmt.Sprintf("- 主题锚点: \"%s\"\n", ts.TopicAnchor))
|
||
}
|
||
if len(ts.TopicEntities) > 0 {
|
||
sb.WriteString(fmt.Sprintf("- 核心实体: %s\n", strings.Join(ts.TopicEntities, "、")))
|
||
}
|
||
|
||
if len(ts.CollectedSlots) > 0 {
|
||
var slotStrs []string
|
||
for k, v := range ts.CollectedSlots {
|
||
slotStrs = append(slotStrs, fmt.Sprintf("%s=%s", k, v))
|
||
}
|
||
sb.WriteString(fmt.Sprintf("- 已收集参数: %s\n", strings.Join(slotStrs, "; ")))
|
||
}
|
||
|
||
if len(ts.MissingSlots) > 0 {
|
||
sb.WriteString(fmt.Sprintf("- 待补充槽位: %s\n", strings.Join(ts.MissingSlots, "、")))
|
||
}
|
||
|
||
if len(ts.OpenQuestions) > 0 {
|
||
sb.WriteString(fmt.Sprintf("- 未决问题: %s\n", strings.Join(ts.OpenQuestions, ";")))
|
||
}
|
||
|
||
return sb.String()
|
||
}
|
||
|
||
func isGenericAffirmative(s string) bool {
|
||
s = strings.TrimSpace(s)
|
||
return s == "好" || s == "好的" || s == "行" || s == "可以" || s == "1" || s == "同意" || s == "推进"
|
||
}
|
||
|
||
var (
|
||
ellipsisAnchorFilterRe = regexp.MustCompile(`(?:呢|也|还是|怎么样|怎样|如何)[??]*$`)
|
||
pronounAnchorFilterRe = regexp.MustCompile(`^(?:那|另外|还有|同时|那关于)?\s*(?:这个|该|这|这份|那份|上次说的|之前说的|它|它们)`)
|
||
)
|
||
|
||
func isCompleteAnchorCandidate(s string) bool {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" || isGenericAffirmative(s) {
|
||
return false
|
||
}
|
||
runes := []rune(s)
|
||
// 短于 12 个字符且含有省略或代词特征,不能覆盖已有完整主题锚点
|
||
if len(runes) < 12 && (ellipsisAnchorFilterRe.MatchString(s) || pronounAnchorFilterRe.MatchString(s)) {
|
||
return false
|
||
}
|
||
// 即使达到或超过 12 个字符,如果末尾是纯疑问助词(例如单纯追问标准),也尽量避免冲刷
|
||
if ellipsisAnchorFilterRe.MatchString(s) && len(runes) < 16 {
|
||
return false
|
||
}
|
||
return len(runes) >= 6
|
||
}
|
||
|
||
func containsString(arr []string, s string) bool {
|
||
for _, item := range arr {
|
||
if item == s {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|