package dialoguectx import ( "encoding/json" "fmt" "strings" "time" "unicode/utf8" ) func renderTurnsMD(turns []RichDialogueTurn, from, to time.Time) string { var sb strings.Builder sb.WriteString("# 项目成员对话近窗(带元数据原文)\n\n") if len(turns) == 0 { sb.WriteString("本窗口内无授权可见的共享对话。\n") return sb.String() } sb.WriteString(fmt.Sprintf("- 覆盖窗口: %s → %s\n", from.Format("2006-01-02 15:04"), to.Format("2006-01-02 15:04"))) sb.WriteString(fmt.Sprintf("- 条数: %d\n\n", len(turns))) for _, t := range turns { roleLabel := "用户" if t.Role == "assistant" { roleLabel = "助手" } identity := t.SpeakerName parts := make([]string, 0, 3) if t.SystemRole != "" { parts = append(parts, t.SystemRole) } if t.ProjectRole != "" { parts = append(parts, t.ProjectRole) } if t.DocDuty != "" { parts = append(parts, "duty:"+t.DocDuty) } if len(parts) > 0 { identity = fmt.Sprintf("%s(%s)", t.SpeakerName, strings.Join(parts, "/")) } stage := "" if t.StageKey != "" { stage = " stage=" + t.StageKey } sb.WriteString(fmt.Sprintf("[%s] %s %s:%s %s\n", t.CreatedAt.Format("2006-01-02 15:04"), identity, roleLabel, stage, t.Content)) } return sb.String() } func renderDigestMD(turns []RichDialogueTurn, pkg *Package) string { var sb strings.Builder sb.WriteString("# 项目对话纪要\n\n") sb.WriteString("权威度:`collab_dialogue`(低于黑板事实与已批准文档;冲突时不得静默采信对话版本)\n\n") if pkg != nil { sb.WriteString(fmt.Sprintf("- 装配时间: %s\n", pkg.AssembledAt.Format(time.RFC3339))) sb.WriteString(fmt.Sprintf("- 纳入轮次: %d(私有排除 %d / 阶段不可见 %d / 噪声 %d)\n", pkg.TurnCount, pkg.ExcludedPrivate, pkg.ExcludedNoAccess, pkg.ExcludedNoise)) if !pkg.CoveredFrom.IsZero() { sb.WriteString(fmt.Sprintf("- 覆盖窗口: %s → %s\n", pkg.CoveredFrom.Format("2006-01-02"), pkg.CoveredTo.Format("2006-01-02"))) } sb.WriteString("\n") } if len(turns) == 0 { sb.WriteString("暂无可用共享对话纪要。\n") return sb.String() } // Day buckets: take first user utterance snippet per day as headline proxy. byDay := map[string][]string{} dayOrder := []string{} for _, t := range turns { day := t.DayKey if day == "" { day = t.CreatedAt.Format("2006-01-02") } if _, ok := byDay[day]; !ok { dayOrder = append(dayOrder, day) } if t.Role != "user" { continue } if len(byDay[day]) >= 3 { continue } byDay[day] = append(byDay[day], fmt.Sprintf("%s: %s", t.SpeakerName, truncateRunes(t.Content, 64))) } sb.WriteString("## 按日要点\n\n") for _, day := range dayOrder { pts := byDay[day] if len(pts) == 0 { sb.WriteString(fmt.Sprintf("- **%s**: (仅助手/系统可见消息)\n", day)) continue } sb.WriteString(fmt.Sprintf("- **%s**: %s\n", day, strings.Join(pts, ";"))) } out := sb.String() if utf8.RuneCountInString(out) > MaxDigestRunes { out = truncateRunes(out, MaxDigestRunes) } return out } // Known blackboard-style keys we try to extract from user utterances. var factKeyPatterns = []struct { Key string Markers []string }{ {"device_type", []string{"器械类型", "产品类型", "device_type", "设备类型"}}, {"target_market", []string{"目标市场", "target_market", "市场定位"}}, {"intended_use", []string{"预期用途", "intended_use", "适用场景"}}, {"customer_name", []string{"客户名称", "客户是", "customer_name"}}, {"budget", []string{"预算", "budget"}}, {"classification", []string{"注册分类", "管理类别", "分类界定"}}, } func extractFacts(turns []RichDialogueTurn) []DialogueFact { out := make([]DialogueFact, 0) seen := map[string]struct{}{} for _, t := range turns { if t.Role != "user" { continue } content := strings.TrimSpace(t.Content) for _, pat := range factKeyPatterns { val, ok := extractAfterMarker(content, pat.Markers) if !ok { continue } dedupe := pat.Key + "=" + val if _, exists := seen[dedupe]; exists { continue } seen[dedupe] = struct{}{} out = append(out, DialogueFact{ Key: pat.Key, Value: val, SourceMessageID: t.MessageID, SpeakerID: t.SpeakerID, SpeakerName: t.SpeakerName, StatedAt: t.CreatedAt, Authority: AuthorityCollabDialogue, ExplicitEvidence: strings.Contains(content, "材料") || strings.Contains(content, "〔") || strings.Contains(content, "决定"), }) } } return out } func extractAfterMarker(content string, markers []string) (string, bool) { for _, m := range markers { idx := strings.Index(content, m) if idx < 0 { continue } rest := strings.TrimSpace(content[idx+len(m):]) rest = strings.TrimLeft(rest, "::=是为 ") rest = strings.TrimSpace(rest) if rest == "" { continue } // Cut at sentence boundary. for _, sep := range []string{"。", ";", ";", "\n", ",且", "并"} { if i := strings.Index(rest, sep); i > 0 { rest = rest[:i] break } } rest = truncateRunes(strings.TrimSpace(rest), 80) if rest == "" { continue } return rest, true } return "", false } // FactsJSON returns pretty-printed facts payload. func FactsJSON(facts []DialogueFact) []byte { if facts == nil { facts = []DialogueFact{} } b, _ := json.MarshalIndent(facts, "", " ") return b } // RenderAssistantSnippet builds a short MemoryComposer fragment (≤400 runes). func RenderAssistantSnippet(pkg *Package) string { if pkg == nil || pkg.TurnCount == 0 { return "" } var sb strings.Builder sb.WriteString("【项目成员协同近况】(权威度低于黑板事实;冲突时以黑板为准)\n") // First ~3 digest day lines. lines := strings.Split(pkg.DigestMD, "\n") n := 0 for _, line := range lines { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "- **") { continue } sb.WriteString(line) sb.WriteString("\n") n++ if n >= 3 { break } } if len(pkg.Facts) > 0 { sb.WriteString("- 对话沉淀事实候选: ") parts := make([]string, 0, 4) for i, f := range pkg.Facts { if i >= 4 { break } parts = append(parts, fmt.Sprintf("%s=%s(%s)", f.Key, f.Value, f.SpeakerName)) } sb.WriteString(strings.Join(parts, "; ")) sb.WriteString("\n") } out := sb.String() if utf8.RuneCountInString(out) > 400 { out = truncateRunes(out, 400) } return out }