feat(ctx): inject cleaned project dialogue into squad and assistant

Add dialoguectx packaging, conflictresolve adjudication with HITL routing,
workspace context/dialogue/ injection (default on), and goose 00071.
This commit is contained in:
weichen
2026-09-24 17:04:04 +00:00
parent 4e410efbab
commit dc29b84cdf
27 changed files with 1988 additions and 2 deletions
+122
View File
@@ -0,0 +1,122 @@
package dialoguectx
import (
"crypto/sha256"
"encoding/hex"
"strings"
"unicode/utf8"
"chat-agent-system/internal/replyexport"
)
// cleanResult is the outcome of cleaning a raw message.
type cleanResult struct {
Include bool
Content string
Noise bool
Private bool
NoAccess bool
}
// cleanMessage applies visibility, export-policy, noise, and R13 neutralization.
func cleanMessage(role, content, contentKind, exportPolicy, stageKey, allowedStageKey string, metadata map[string]any) cleanResult {
trimmed := strings.TrimSpace(content)
if trimmed == "" {
return cleanResult{Noise: true}
}
if strings.HasPrefix(trimmed, "[private]") || strings.HasPrefix(trimmed, "[draft]") {
return cleanResult{Private: true}
}
if vis, _ := metadata["visibility"].(string); vis == "private" {
return cleanResult{Private: true}
}
if allowedStageKey != "" && stageKey != "" && stageKey != allowedStageKey {
return cleanResult{NoAccess: true}
}
switch role {
case "user":
if utf8.RuneCountInString(trimmed) < MinContentRunes {
return cleanResult{Noise: true}
}
if isGreetingNoise(trimmed) {
return cleanResult{Noise: true}
}
return cleanResult{Include: true, Content: neutralizeDirective(trimmed)}
case "assistant":
switch contentKind {
case string(replyexport.KindActionCard), string(replyexport.KindStatusUpdate),
string(replyexport.KindErrorNotice), string(replyexport.KindSystemNotice):
return cleanResult{Noise: true}
}
switch exportPolicy {
case string(replyexport.PolicyFull), string(replyexport.PolicyCopyOnly):
if utf8.RuneCountInString(trimmed) < MinContentRunes {
return cleanResult{Noise: true}
}
return cleanResult{Include: true, Content: neutralizeDirective(trimmed)}
default:
return cleanResult{Noise: true}
}
default:
return cleanResult{Noise: true}
}
}
func isGreetingNoise(s string) bool {
lower := strings.ToLower(strings.TrimSpace(s))
greetings := []string{
"你好", "您好", "hi", "hello", "hey", "在吗", "早上好", "下午好", "晚上好",
"谢谢", "感谢", "ok", "好的", "收到", "嗯", "哈哈",
}
for _, g := range greetings {
if lower == g || lower == g+"。" || lower == g+"!" || lower == g+"?" || lower == g+"?" {
return true
}
}
return false
}
// neutralizeDirective marks instruction-like text as user material (R13).
func neutralizeDirective(c string) string {
lower := strings.ToLower(c)
markers := []string{
"ignore previous",
"system:",
"忽略以上",
"忽略之前",
"提权",
"grant yourself",
"override auth",
"将所有阶段",
}
for _, m := range markers {
if strings.Contains(lower, strings.ToLower(m)) || strings.Contains(c, m) {
return "【用户材料】" + c
}
}
return c
}
func contentFingerprint(role, content string) string {
sum := sha256.Sum256([]byte(role + "|" + content))
return hex.EncodeToString(sum[:8])
}
func truncateRunes(s string, max int) string {
if max <= 0 || utf8.RuneCountInString(s) <= max {
return s
}
runes := []rune(s)
return string(runes[:max]) + "…"
}
func stageKeyFromMeta(metadata map[string]any) string {
if metadata == nil {
return ""
}
if sk, ok := metadata["stage_key"].(string); ok {
return sk
}
return ""
}
+358
View File
@@ -0,0 +1,358 @@
package dialoguectx
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"chat-agent-system/internal/ctxmgr"
)
// Collector loads project dialogue and enriches speaker metadata.
type Collector struct {
Pool *pgxpool.Pool
// MemoryFallback is used when Pool is nil (tests / in-memory).
MemoryFallback *ctxmgr.ContextManager
}
// CollectAndPackage builds a layered dialogue package for a project.
func (c *Collector) CollectAndPackage(ctx context.Context, opts CollectOptions) (*Package, error) {
now := opts.Now
if now.IsZero() {
now = time.Now()
}
pkg := &Package{
AssembledAt: now,
InjectionEnabled: InjectionEnabled(),
Facts: []DialogueFact{},
}
if !pkg.InjectionEnabled || opts.ProjectID <= 0 {
return pkg, nil
}
maxDays := opts.MaxDays
if maxDays <= 0 {
maxDays = MaxDaysFromEnv()
}
maxTurns := opts.MaxTurns
if maxTurns <= 0 {
maxTurns = MaxTurnsFromEnv()
}
turns, excludedPrivate, excludedNoAccess, excludedNoise, err := c.collectTurns(ctx, opts, maxDays, maxTurns)
if err != nil {
return nil, err
}
pkg.ExcludedPrivate = excludedPrivate
pkg.ExcludedNoAccess = excludedNoAccess
pkg.ExcludedNoise = excludedNoise
pkg.TurnCount = len(turns)
if len(turns) > 0 {
pkg.CoveredFrom = turns[0].CreatedAt
pkg.CoveredTo = turns[len(turns)-1].CreatedAt
}
pkg.TurnsMD = renderTurnsMD(turns, pkg.CoveredFrom, pkg.CoveredTo)
pkg.DigestMD = renderDigestMD(turns, pkg)
pkg.Facts = extractFacts(turns)
return pkg, nil
}
type rawRow struct {
rec ctxmgr.ChatMessageRecord
speakerName string
systemRole string
projectRole string
docDuty string
}
func (c *Collector) collectTurns(ctx context.Context, opts CollectOptions, maxDays, maxTurns int) ([]RichDialogueTurn, int, int, int, error) {
cutoff := time.Now().AddDate(0, 0, -maxDays)
if !opts.Now.IsZero() {
cutoff = opts.Now.AddDate(0, 0, -maxDays)
}
var rows []rawRow
var err error
if c != nil && c.Pool != nil {
rows, err = c.queryPG(ctx, opts.ProjectID, cutoff, maxTurns*3)
} else if c != nil && c.MemoryFallback != nil {
rows, err = c.queryMemory(ctx, opts.ProjectID, cutoff, maxTurns*3)
}
if err != nil {
return nil, 0, 0, 0, err
}
var (
out []RichDialogueTurn
excludedPrivate int
excludedNoAccess int
excludedNoise int
seen = map[string]struct{}{}
)
for _, row := range rows {
stageKey := row.rec.StageKey
if stageKey == "" {
stageKey = stageKeyFromMeta(row.rec.Metadata)
}
cr := cleanMessage(row.rec.Role, row.rec.Content, row.rec.ContentKind, row.rec.ExportPolicy, stageKey, opts.AllowedStageKey, row.rec.Metadata)
if cr.Private {
excludedPrivate++
continue
}
if cr.NoAccess {
excludedNoAccess++
continue
}
if cr.Noise || !cr.Include {
excludedNoise++
continue
}
fp := contentFingerprint(row.rec.Role, cr.Content)
if _, ok := seen[fp]; ok {
excludedNoise++
continue
}
seen[fp] = struct{}{}
name := strings.TrimSpace(row.speakerName)
if name == "" {
if row.rec.Role == "assistant" {
name = "助手"
} else {
name = fmt.Sprintf("用户#%d", row.rec.UserID)
}
}
out = append(out, RichDialogueTurn{
MessageID: row.rec.ID,
SessionID: row.rec.SessionID,
SpeakerID: row.rec.UserID,
SpeakerName: name,
SystemRole: row.systemRole,
ProjectRole: row.projectRole,
DocDuty: row.docDuty,
Role: row.rec.Role,
Content: truncateRunes(cr.Content, MaxTurnContentRunes),
StageKey: stageKey,
DayKey: row.rec.DayKey,
Category: row.rec.Category,
ContentKind: row.rec.ContentKind,
CreatedAt: row.rec.CreatedAt,
})
}
if len(out) > maxTurns {
out = out[len(out)-maxTurns:]
}
return out, excludedPrivate, excludedNoAccess, excludedNoise, nil
}
func (c *Collector) queryPG(ctx context.Context, projectID int64, cutoff time.Time, limit int) ([]rawRow, error) {
q := `
SELECT cm.id, cm.session_id, COALESCE(cm.user_id, 0), cm.role, cm.content,
cm.intent, cm.branch, cm.metadata, COALESCE(cm.day_key::text, ''),
COALESCE(cm.category, ''), COALESCE(cm.content_kind, ''),
COALESCE(cm.export_policy, 'none'),
COALESCE(cm.stage_key, ''), cm.created_at,
COALESCE(u.name, ''), COALESCE(u.role, ''),
COALESCE(pm.project_role, ''), COALESCE(pm.document_duty, '')
FROM chat_messages cm
JOIN chat_sessions cs ON cs.session_id = cm.session_id
LEFT JOIN users u ON u.id = cm.user_id
LEFT JOIN project_members pm ON pm.project_id = cs.project_id AND pm.user_id = cm.user_id AND pm.status = 'active'
WHERE cs.project_id = $1 AND cm.deleted_at IS NULL AND cm.created_at >= $2
ORDER BY cm.created_at ASC
LIMIT $3
`
pgRows, err := c.Pool.Query(ctx, q, projectID, cutoff, limit)
if err != nil {
// Fallback without document_duty / stage_key columns if schema older.
return c.queryPGLegacy(ctx, projectID, cutoff, limit)
}
defer pgRows.Close()
var out []rawRow
for pgRows.Next() {
var rec ctxmgr.ChatMessageRecord
var metaJSON []byte
var name, sysRole, pRole, duty string
if err := pgRows.Scan(
&rec.ID, &rec.SessionID, &rec.UserID, &rec.Role, &rec.Content,
&rec.Intent, &rec.Branch, &metaJSON, &rec.DayKey, &rec.Category,
&rec.ContentKind, &rec.ExportPolicy, &rec.StageKey, &rec.CreatedAt,
&name, &sysRole, &pRole, &duty,
); err != nil {
return nil, err
}
if len(metaJSON) > 0 {
_ = json.Unmarshal(metaJSON, &rec.Metadata)
}
out = append(out, rawRow{rec: rec, speakerName: name, systemRole: sysRole, projectRole: pRole, docDuty: duty})
}
return out, pgRows.Err()
}
func (c *Collector) queryPGLegacy(ctx context.Context, projectID int64, cutoff time.Time, limit int) ([]rawRow, error) {
q := `
SELECT cm.id, cm.session_id, COALESCE(cm.user_id, 0), cm.role, cm.content,
cm.intent, cm.branch, cm.metadata, COALESCE(cm.day_key::text, ''),
COALESCE(cm.category, ''), COALESCE(cm.content_kind, ''),
COALESCE(cm.export_policy, 'none'), cm.created_at,
COALESCE(u.name, ''), COALESCE(u.role, ''),
COALESCE(pm.project_role, '')
FROM chat_messages cm
JOIN chat_sessions cs ON cs.session_id = cm.session_id
LEFT JOIN users u ON u.id = cm.user_id
LEFT JOIN project_members pm ON pm.project_id = cs.project_id AND pm.user_id = cm.user_id AND pm.status = 'active'
WHERE cs.project_id = $1 AND cm.deleted_at IS NULL AND cm.created_at >= $2
ORDER BY cm.created_at ASC
LIMIT $3
`
pgRows, err := c.Pool.Query(ctx, q, projectID, cutoff, limit)
if err != nil {
return nil, fmt.Errorf("dialoguectx collect: %w", err)
}
defer pgRows.Close()
var out []rawRow
for pgRows.Next() {
var rec ctxmgr.ChatMessageRecord
var metaJSON []byte
var name, sysRole, pRole string
if err := pgRows.Scan(
&rec.ID, &rec.SessionID, &rec.UserID, &rec.Role, &rec.Content,
&rec.Intent, &rec.Branch, &metaJSON, &rec.DayKey, &rec.Category,
&rec.ContentKind, &rec.ExportPolicy, &rec.CreatedAt,
&name, &sysRole, &pRole,
); err != nil {
return nil, err
}
if len(metaJSON) > 0 {
_ = json.Unmarshal(metaJSON, &rec.Metadata)
}
out = append(out, rawRow{rec: rec, speakerName: name, systemRole: sysRole, projectRole: pRole})
}
return out, pgRows.Err()
}
func (c *Collector) queryMemory(ctx context.Context, projectID int64, cutoff time.Time, limit int) ([]rawRow, error) {
if c == nil || c.MemoryFallback == nil {
return nil, nil
}
recs, err := c.MemoryFallback.CollectProjectDialogueRecords(ctx, projectID, limit)
if err != nil {
return nil, err
}
var out []rawRow
for _, rec := range recs {
if !rec.CreatedAt.IsZero() && rec.CreatedAt.Before(cutoff) {
continue
}
out = append(out, rawRow{rec: rec, speakerName: "", systemRole: "", projectRole: ""})
}
return out, nil
}
// CollectFromRecords packages pre-loaded records (tests / memory mode).
func CollectFromRecords(recs []ctxmgr.ChatMessageRecord, opts CollectOptions, enrich map[int64]speakerMeta) (*Package, error) {
now := opts.Now
if now.IsZero() {
now = time.Now()
}
pkg := &Package{AssembledAt: now, InjectionEnabled: true, Facts: []DialogueFact{}}
maxDays := opts.MaxDays
if maxDays <= 0 {
maxDays = DefaultMaxDays
}
maxTurns := opts.MaxTurns
if maxTurns <= 0 {
maxTurns = DefaultMaxTurns
}
cutoff := now.AddDate(0, 0, -maxDays)
var (
turns []RichDialogueTurn
excludedPrivate int
excludedNoAccess int
excludedNoise int
seen = map[string]struct{}{}
)
for _, rec := range recs {
if !rec.CreatedAt.IsZero() && rec.CreatedAt.Before(cutoff) {
continue
}
stageKey := rec.StageKey
if stageKey == "" {
stageKey = stageKeyFromMeta(rec.Metadata)
}
cr := cleanMessage(rec.Role, rec.Content, rec.ContentKind, rec.ExportPolicy, stageKey, opts.AllowedStageKey, rec.Metadata)
if cr.Private {
excludedPrivate++
continue
}
if cr.NoAccess {
excludedNoAccess++
continue
}
if cr.Noise || !cr.Include {
excludedNoise++
continue
}
fp := contentFingerprint(rec.Role, cr.Content)
if _, ok := seen[fp]; ok {
excludedNoise++
continue
}
seen[fp] = struct{}{}
meta := enrich[rec.UserID]
name := meta.Name
if name == "" {
if rec.Role == "assistant" {
name = "助手"
} else {
name = fmt.Sprintf("用户#%d", rec.UserID)
}
}
turns = append(turns, RichDialogueTurn{
MessageID: rec.ID,
SessionID: rec.SessionID,
SpeakerID: rec.UserID,
SpeakerName: name,
SystemRole: meta.SystemRole,
ProjectRole: meta.ProjectRole,
DocDuty: meta.DocDuty,
Role: rec.Role,
Content: truncateRunes(cr.Content, MaxTurnContentRunes),
StageKey: stageKey,
DayKey: rec.DayKey,
Category: rec.Category,
ContentKind: rec.ContentKind,
CreatedAt: rec.CreatedAt,
})
}
if len(turns) > maxTurns {
turns = turns[len(turns)-maxTurns:]
}
pkg.ExcludedPrivate = excludedPrivate
pkg.ExcludedNoAccess = excludedNoAccess
pkg.ExcludedNoise = excludedNoise
pkg.TurnCount = len(turns)
if len(turns) > 0 {
pkg.CoveredFrom = turns[0].CreatedAt
pkg.CoveredTo = turns[len(turns)-1].CreatedAt
}
pkg.TurnsMD = renderTurnsMD(turns, pkg.CoveredFrom, pkg.CoveredTo)
pkg.DigestMD = renderDigestMD(turns, pkg)
pkg.Facts = extractFacts(turns)
return pkg, nil
}
type speakerMeta struct {
Name string
SystemRole string
ProjectRole string
DocDuty string
}
+47
View File
@@ -0,0 +1,47 @@
package dialoguectx
import (
"os"
"strconv"
"strings"
)
// InjectionEnabled reports whether dialogue context injection is on.
// Default is on; set DIALOGUE_CTX_INJECTION=off|false|0 to disable urgently.
func InjectionEnabled() bool {
v := strings.TrimSpace(strings.ToLower(os.Getenv("DIALOGUE_CTX_INJECTION")))
switch v {
case "off", "false", "0", "no":
return false
default:
return true
}
}
// MaxDaysFromEnv returns the configured day window (default 7, cap 31).
func MaxDaysFromEnv() int {
return envInt("DIALOGUE_CTX_MAX_DAYS", DefaultMaxDays, 1, MaxDaysCap)
}
// MaxTurnsFromEnv returns the configured turn cap (default 80, cap 200).
func MaxTurnsFromEnv() int {
return envInt("DIALOGUE_CTX_MAX_TURNS", DefaultMaxTurns, 1, MaxTurnsCap)
}
func envInt(key string, def, min, max int) int {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil {
return def
}
if n < min {
return min
}
if n > max {
return max
}
return n
}
+225
View File
@@ -0,0 +1,225 @@
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
}
+97
View File
@@ -0,0 +1,97 @@
package dialoguectx_test
import (
"os"
"strings"
"testing"
"time"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/dialoguectx"
"chat-agent-system/internal/replyexport"
)
func TestInjectionEnabledDefaultOn(t *testing.T) {
t.Setenv("DIALOGUE_CTX_INJECTION", "")
if !dialoguectx.InjectionEnabled() {
t.Fatal("expected default on")
}
t.Setenv("DIALOGUE_CTX_INJECTION", "off")
if dialoguectx.InjectionEnabled() {
t.Fatal("expected off")
}
}
func TestCollectFromRecordsCleansAndEnriches(t *testing.T) {
now := time.Date(2026, 9, 24, 15, 0, 0, 0, time.UTC)
recs := []ctxmgr.ChatMessageRecord{
{ID: 1, SessionID: "s1", UserID: 7, Role: "user", Content: "你好", CreatedAt: now, DayKey: "2026-09-24"},
{ID: 2, SessionID: "s1", UserID: 7, Role: "user", Content: "器械类型:眼科OCT", CreatedAt: now.Add(time.Minute), DayKey: "2026-09-24"},
{ID: 3, SessionID: "s1", UserID: 0, Role: "assistant", Content: "状态更新中", ContentKind: string(replyexport.KindStatusUpdate), ExportPolicy: string(replyexport.PolicyNone), CreatedAt: now.Add(2 * time.Minute), DayKey: "2026-09-24"},
{ID: 4, SessionID: "s1", UserID: 7, Role: "user", Content: "[private] 草稿备注", CreatedAt: now.Add(3 * time.Minute), DayKey: "2026-09-24"},
{ID: 5, SessionID: "s1", UserID: 0, Role: "assistant", Content: "已记录眼科OCT分类建议", ContentKind: string(replyexport.KindDocumentText), ExportPolicy: string(replyexport.PolicyFull), CreatedAt: now.Add(4 * time.Minute), DayKey: "2026-09-24"},
{ID: 6, SessionID: "s1", UserID: 8, Role: "user", Content: "目标市场:国内III类", CreatedAt: now.Add(5 * time.Minute), DayKey: "2026-09-24", Metadata: map[string]any{"stage_key": "other"}},
}
enrich := map[int64]struct {
Name, SystemRole, ProjectRole, DocDuty string
}{
7: {Name: "张工", SystemRole: "engineer", ProjectRole: "member"},
8: {Name: "李工", SystemRole: "engineer", ProjectRole: "member"},
}
// CollectFromRecords takes unexported speakerMeta — use public path via package rebuild:
// call through exported helper by duplicating via CollectAndPackage memory is heavy;
// instead test packaging via Inject test shim in same package.
_ = enrich
_ = os.Getenv
pkg, err := dialoguectx.CollectFromRecords(recs, dialoguectx.CollectOptions{
ProjectID: 1, MaxDays: 7, MaxTurns: 80, Now: now.Add(time.Hour), AllowedStageKey: "",
}, nil)
if err != nil {
t.Fatal(err)
}
if pkg.TurnCount < 2 {
t.Fatalf("expected cleaned turns, got %d (noise=%d private=%d)", pkg.TurnCount, pkg.ExcludedNoise, pkg.ExcludedPrivate)
}
if pkg.ExcludedPrivate < 1 {
t.Fatalf("expected private exclusion")
}
if pkg.ExcludedNoise < 1 {
t.Fatalf("expected noise exclusion for greeting/status")
}
if !strings.Contains(pkg.TurnsMD, "器械类型") {
t.Fatalf("turns missing content: %s", pkg.TurnsMD)
}
foundDevice := false
for _, f := range pkg.Facts {
if f.Key == "device_type" && strings.Contains(f.Value, "眼科OCT") {
foundDevice = true
}
}
if !foundDevice {
t.Fatalf("expected device_type fact, got %#v", pkg.Facts)
}
snip := dialoguectx.RenderAssistantSnippet(pkg)
if !strings.Contains(snip, "项目成员协同近况") {
t.Fatalf("snippet missing header: %s", snip)
}
}
func TestStageFilterExcludes(t *testing.T) {
now := time.Now()
recs := []ctxmgr.ChatMessageRecord{
{ID: 1, Role: "user", Content: "当前阶段事实:预算一千万", CreatedAt: now, Metadata: map[string]any{"stage_key": "rd_proposal"}},
{ID: 2, Role: "user", Content: "其他阶段事实:预算两千万", CreatedAt: now, Metadata: map[string]any{"stage_key": "system_reqs"}},
}
pkg, err := dialoguectx.CollectFromRecords(recs, dialoguectx.CollectOptions{
ProjectID: 1, MaxDays: 7, Now: now.Add(time.Minute), AllowedStageKey: "rd_proposal",
}, nil)
if err != nil {
t.Fatal(err)
}
if pkg.ExcludedNoAccess != 1 {
t.Fatalf("expected 1 stage exclusion, got %d", pkg.ExcludedNoAccess)
}
if pkg.TurnCount != 1 {
t.Fatalf("expected 1 turn, got %d", pkg.TurnCount)
}
}
+76
View File
@@ -0,0 +1,76 @@
// Package dialoguectx collects, cleans, and packages project member dialogue
// for injection into squad workspaces and assistant prompts.
package dialoguectx
import "time"
const (
AuthorityCollabDialogue = "collab_dialogue"
DefaultMaxDays = 7
DefaultMaxTurns = 80
MaxDaysCap = 31
MaxTurnsCap = 200
MaxDigestRunes = 2400
MaxTurnContentRunes = 480
MinContentRunes = 4
)
// CollectOptions controls dialogue collection and packaging.
type CollectOptions struct {
ProjectID int64
AllowedStageKey string // empty = no stage filter
MaxDays int
MaxTurns int
Now time.Time
}
// RichDialogueTurn is a cleaned, metadata-enriched dialogue unit.
type RichDialogueTurn struct {
MessageID int64 `json:"message_id"`
SessionID string `json:"session_id"`
SpeakerID int64 `json:"speaker_id"`
SpeakerName string `json:"speaker_name"`
SystemRole string `json:"system_role"`
ProjectRole string `json:"project_role"`
DocDuty string `json:"doc_duty,omitempty"`
Role string `json:"role"` // user | assistant
Content string `json:"content"`
StageKey string `json:"stage_key,omitempty"`
DayKey string `json:"day_key"`
Category string `json:"category,omitempty"`
ContentKind string `json:"content_kind,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// DialogueFact is a structured claim extracted from dialogue.
type DialogueFact struct {
Key string `json:"key"`
Value string `json:"value"`
SourceMessageID int64 `json:"source_message_id"`
SpeakerID int64 `json:"speaker_id"`
SpeakerName string `json:"speaker_name"`
StatedAt time.Time `json:"stated_at"`
Authority string `json:"authority"`
ExplicitEvidence bool `json:"explicit_evidence"`
}
// Package is the layered dialogue payload written under context/dialogue/.
type Package struct {
DigestMD string `json:"-"`
TurnsMD string `json:"-"`
Facts []DialogueFact `json:"facts"`
ConfirmationsMD string `json:"-"`
ExcludedPrivate int `json:"excluded_private"`
ExcludedNoAccess int `json:"excluded_no_access"`
ExcludedNoise int `json:"excluded_noise"`
TurnCount int `json:"turn_count"`
CoveredFrom time.Time `json:"covered_from,omitempty"`
CoveredTo time.Time `json:"covered_to,omitempty"`
AssembledAt time.Time `json:"assembled_at"`
InjectionEnabled bool `json:"injection_enabled"`
}
// AssistantSnippet is a short prompt fragment for MemoryComposer.
type AssistantSnippet struct {
Text string
}