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:
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user