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
+1
View File
@@ -18,6 +18,7 @@
| owner | task | branch | worktree | mutex | status |
|---|---|---|---|---|---|
| Cursor | dialogue-context-injection-2026-09-25 | feature/dialogue-context-injection-2026-09-25 | /home/weichen/projects/Agent-System/.worktrees/dialogue-context-injection-2026-09-25 | dialogue-ctx | in_progress |
| Cursor | dm-migration-2026-09-24-r2 | feature/dm-migration-2026-09-24 | .worktrees/dm-migration-2026-09-24 | dm-migration | done |
| Cursor | governance-console-p1-impl-2026-09-24 | feature/governance-console-p1-impl-2026-09-24 | .worktrees/governance-console-p1-impl-2026-09-24 | agentctl-console | done |
| Cursor | governance-console-p1-design-2026-09-24 | feature/governance-console-p1-design-2026-09-24 | .worktrees/governance-console-p1-design-2026-09-24 | agentctl-console-design | done |
@@ -0,0 +1,15 @@
# 对话上下文注入(short-term)
- **task_id**: dialogue-context-injection-2026-09-25
- **status**: in_progress
- **write_scope**: internal/dialoguectx, internal/conflictresolve, internal/squad, internal/bizdev, internal/coordinator, internal/chat, internal/ctxmgr, internal/sysadmin, deploy/postgres/goose, .doc
- **mutex**: dialogue-ctx
- **base_dev_sha**: 4e410efbabeea5b944909d8a0de49bda1265c975
- **review_state**: pending
- **integration_state**: pending
- **deployment**: not_applicable
- **cleanup**: pending
- **remote_sync**: not_requested
设计:[2026-09-25-dialogue-context-injection-design.md](../../system-design/2026-09-25-dialogue-context-injection-design.md)
报告:[2026-09-25-dialogue-context-injection-test-report.md](../../test-reports/2026-09-25-dialogue-context-injection-test-report.md)
+1
View File
@@ -49,6 +49,7 @@
| record_id | 模块 | 声明范围 | 状态 | 发布关系 | 证据 | 剩余工作 / 关闭条件 | mutex |
|------|----------|------|----------|------|---------------------|-------|-------|
| dialogue-ctx-injection | 对话上下文注入 | 项目成员对话清洗分层注入小队 workspace 与助手 Prompt;冲突裁决+HITL 分级路由;默认 on 紧急可关 | partial | deferred_enhancement | [设计](../system-design/2026-09-25-dialogue-context-injection-design.md);[报告](../test-reports/2026-09-25-dialogue-context-injection-test-report.md);[短文](../ops/short-term/2026-09-25-dialogue-context-injection.md) | 关闭条件:dev 迁移 goose 00071 + 真实 PI 落盘核验 + 冲突确认卡冒烟。本行不升 done | `dialogue-ctx` |
| jevdecide-shadow | jev 产品决策 shadow/assist | jevdecide + coordinator realtime/ProfileTask;compose 默认 off;**local_dev 已开 assist**(`configs/.env.dev` → 172.17.0.1:8767);决策层漏检加成已对话验收 | partial | deferred_enhancement | [产品报告](../test-reports/2026-09-24-jev-product-shadow-test-report.md);[shadow 启用](../test-reports/2026-09-24-jev-dev-shadow-enable-test-report.md);[assist 启用+对话](../test-reports/2026-09-24-jev-dev-assist-enable-test-report.md);[assist 短文](../ops/short-term/2026-09-24-jev-dev-assist-enable.md) | 关闭条件:真实轮浸泡≥200 + 体验层检索空结果收敛;intent/risk 另立。本行不升 done | `jevdecide` |
| chat-semantic-replace | 语义摘要 REPLACE | 以 shadow 叙事可选替换 served deterministic Narrative(fail-closed 灰度) | deferred | deferred_enhancement | [Wave5 X-2](../ops/short-term/2026-09-18-wave5-deadcode-decisions.md);[pu6](../ops/long-term/2026-09-16-pu6-semantic-summary-contract.md);[summary_flags](../../internal/ctxmgr/summary_flags.go) | 关闭条件:用户独立授权 + fail-closed 灰度设计;`SEMANTIC_SUMMARY_REPLACE` 当前密封不接线。本行不自动升 done | `chat` |
| gov-agent-worktree | Agent worktree 治理自动化 | 主目录保护、任务 worktree、机器认领、候选集成、dev 部署与安全清理 | partial | non_blocker | [方案](../remediation/2026-09-21-agent-workflow-governance-plan.md);[L1 验收](../test-reports/2026-09-21-agent-governance-test-report.md);[W1-B macOS](../ops/short-term/2026-09-22-agentctl-macos.md);[W4](../ops/short-term/2026-09-22-governance-automation.md);residual `modular-decouple-residual-2026-09-23` | L1 与 macOS `with/docs apply/validate/integrate` 已合入。W4 landed:durable queue + 完整 docs projection apply(行/整文 CAS)+ deploy/recover 真实收据闭环 + Darwin cwd 可见性测试。仍 deferred:PI 源码镜像 compose、跨机隔离、automation/w4 实现收敛。本行不升 done | `agent-workflow-governance` |
@@ -0,0 +1,62 @@
# 对话上下文注入智能体(设计)
> **状态**:in_progress(实施中)
> **task_id**:`dialogue-context-injection-2026-09-25`
> **mutex**:`dialogue-ctx`
> **base_dev_sha**:`4e410efbabeea5b944909d8a0de49bda1265c975`
> **授权**:用户确认方案后实施(本会话)
> **policy_revision**:agentctl-mandatory / AGENTS 2026-09
## 一、目标
把项目成员共享对话以「清洗 + 分层 + 带元数据」方式注入:
1. 智能体小队工作区 `context/dialogue/`
2. 对话助手 Prompt(【项目成员协同近况】)
并配套确定性冲突裁决 + jev assist + HITL 分级路由。
## 二、模块
| 包 | 职责 |
|---|---|
| `internal/dialoguectx` | 采集 / 清洗 / 打包(digest.md、turns.md、facts.json) |
| `internal/conflictresolve` | 权威级/时间窗裁决、卡片限额、CONFIRMATIONS.md、裁决表持久化 |
| `internal/squad` | 工作区落盘 + TASK.md 导航 + `DialoguePackage` 透传 |
| `internal/bizdev` | 全任务默认注入(`DIALOGUE_CTX_INJECTION` 紧急关闭) |
| `internal/coordinator` / `chat` | MemoryComposer 近况段 |
## 三、权威度
`collab_dialogue` < 黑板 / 已批准文档。冲突不静默采信对话版本。
## 四、HITL 分级路由
- 事实级模糊冲突 → 触发用户(`PendingExpectation`)
- 涉及受控文档槽位 → 文档 author(digest 通知)
- 单任务确认卡 ≤ 3;超额自动采信规则胜者
## 五、开关
- `DIALOGUE_CTX_INJECTION` 默认 `on`;`off|false|0` 紧急关闭(`internal/dialoguectx` 读环境变量)
- `DIALOGUE_CTX_MAX_DAYS` 默认 7(≤31)
- `DIALOGUE_CTX_MAX_TURNS` 默认 80(≤200)
- sysadmin Features 目录登记可后续补行(本批不改 `internal/sysadmin` 写范围)
## 六、迁移
`deploy/postgres/goose/00071_2026-09-25-context-conflict-adjudications.sql`
## 七、验证
见 `.doc/test-reports/2026-09-25-dialogue-context-injection-test-report.md`。
## 八、五态
| 字段 | 值 |
|---|---|
| review_state | pending |
| integration_state | pending |
| deployment | not_applicable(本批代码合入后需 dev 迁移) |
| cleanup | pending |
| remote_sync | not_requested |
+12
View File
@@ -29,13 +29,16 @@ imports:
- authz
- app/uow
- config
- conflictresolve
- contracts/vocabulary
- ctxmgr
- db
- dialoguectx
- docduty
- docgov
- docrevision
- freedoc
- intent
- kb
- materialindex
- maturity
@@ -67,6 +70,7 @@ imports:
- config
- contracts/vocabulary
- ctxmgr
- dialoguectx
- docgov
- intent
- jevdecide
@@ -82,7 +86,13 @@ imports:
- docgov
- replyexport
- storage
conflictresolve:
- dialoguectx
- jevdecide
db: []
dialoguectx:
- ctxmgr
- replyexport
docduty: []
app:
- agent
@@ -316,6 +326,8 @@ imports:
- traceimpact
table_owners:
context_conflict_adjudications:
- conflictresolve
document_records:
- docgov
project_trace_items:
@@ -0,0 +1,90 @@
# 对话上下文注入智能体 测试报告
## 一、 测试背景 (Background)
核查确认项目成员对话几乎不进入智能体小队工作区;`projectcontext` 快照虽采集对话但默认不 Referenced。本轮落地清洗分层注入、冲突裁决与 HITL 路由。
## 二、 测试目标 (Test Objectives)
1. 清洗过滤私有/噪声/阶段不可见消息,保留元数据轮次与事实抽取
2. 工作区写出 `context/dialogue/` 与权威度指引
3. 冲突裁决:黑板胜对话;同级时间相近需 HITL;卡片限额生效
4. 紧急关闭开关默认 on / off 可关
## 三、 测试方案与环境 (Test Plan & Environment)
- OS: Linux;Go 模块单测
- 层级:工程工具 / Go 包单测(验证契约「工程工具验收补充」)
- **真实 PI 全链路**:本批标记为后续获 `env:dev` 授权后补跑(NOT REQUIRED for 本批代码合入;见结论)
## 四、 预期结果 (Expected Results)
| 用例 | 预期 |
|---|---|
| dialoguectx 清洗 | 寒暄/状态卡/私有排除;器械类型事实抽出 |
| 阶段过滤 | AllowedStageKey 排除他阶段 |
| conflictresolve 权威差 | 黑板胜对话且无 HITL |
| conflictresolve 模糊 | NeedsHITL + trigger_user |
| 卡片限额 | 最多 3 张 HITL,超额 auto |
| squad 工作区 | dialogue 五文件 + README collab_dialogue + TASK.md 导航 |
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 5.1 dialoguectx
**输入**:`go test ./internal/dialoguectx/ -count=1`
**实际输出**:
```
ok chat-agent-system/internal/dialoguectx 0.002s
```
**评价判定**:PASS
**耗时**:~2ms
### 5.2 conflictresolve
**输入**:`go test ./internal/conflictresolve/ -count=1`
**实际输出**:
```
ok chat-agent-system/internal/conflictresolve 0.002s
```
**评价判定**:PASS
### 5.3 squad dialogue 落盘
**输入**:`go test ./internal/squad/ -count=1 -run TestPrepareWorkspaceWritesDialogueContext`
**实际输出**:包级 `ok chat-agent-system/internal/squad`(含本用例)
**评价判定**:PASS
### 5.4 关联包编译
**输入**:`go test ./internal/bizdev/ -short`、`./internal/coordinator/`、`./internal/ctxmgr/`
**实际输出**:均 `ok`
**评价判定**:PASS(编译与短测)
### 5.5 真实 PI / PG 集成
**评价判定**:未运行(NOT REQUIRED 本批;需 env:dev 迁移 goose 00071 后补)
## 六、 自动化测试套件汇总 (Test Suite Execution)
| 套件 | 结果 |
|---|---|
| `go test ./internal/dialoguectx/` | PASS |
| `go test ./internal/conflictresolve/` | PASS |
| `go test ./internal/squad/` | PASS |
| `go test ./internal/bizdev/ -short` | PASS |
| `go test ./internal/coordinator/` | PASS |
| `go test ./internal/ctxmgr/` | PASS |
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
- 本批代码路径与单测达标;FEATURE-MATRIX 行以 `partial` 登记。
- 后续:dev 环境应用 goose 00071;选真实项目跑 `system_reqs`/`test_manager` 核验 `context/dialogue/` 落盘与冲突确认卡;可选 jev assist 浸泡。
@@ -0,0 +1,22 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS context_conflict_adjudications (
id BIGSERIAL PRIMARY KEY,
task_run_id TEXT NOT NULL DEFAULT '',
project_id BIGINT NOT NULL DEFAULT 0,
fact_key TEXT NOT NULL,
resolution TEXT NOT NULL DEFAULT '',
hitl_route TEXT NOT NULL DEFAULT '',
winning_value TEXT NOT NULL DEFAULT '',
decided_by TEXT NOT NULL DEFAULT 'rule',
evidence_digest TEXT NOT NULL DEFAULT '',
nonce TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_cca_run_fact_nonce UNIQUE (task_run_id, fact_key, nonce)
);
CREATE INDEX IF NOT EXISTS idx_cca_project ON context_conflict_adjudications (project_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_cca_run ON context_conflict_adjudications (task_run_id);
-- +goose Down
-- record retention: do not drop tables
SELECT 1;
+129
View File
@@ -0,0 +1,129 @@
package bizdev
import (
"context"
"fmt"
"log/slog"
"time"
"chat-agent-system/internal/conflictresolve"
"chat-agent-system/internal/dialoguectx"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/notification"
"chat-agent-system/internal/squad"
)
// buildDialoguePackageForRun collects cleaned project dialogue, runs conflict
// adjudication, and returns a squad.DialoguePackage ready for workspace inject.
func (s *Service) buildDialoguePackageForRun(ctx context.Context, req TaskRunRequest, blackboard map[string]string) (*squad.DialoguePackage, []*intent.PendingExpectation, error) {
if s == nil || req.ProjectID <= 0 || !dialoguectx.InjectionEnabled() {
return nil, nil, nil
}
collector := &dialoguectx.Collector{Pool: s.pool, MemoryFallback: s.ctxMgr}
pkg, err := collector.CollectAndPackage(ctx, dialoguectx.CollectOptions{
ProjectID: req.ProjectID,
AllowedStageKey: req.StageKey,
})
if err != nil {
return nil, nil, err
}
if pkg == nil {
return nil, nil, nil
}
higher := make([]conflictresolve.FactClaim, 0, len(blackboard))
for k, v := range blackboard {
if v == "" {
continue
}
claim := conflictresolve.FactClaim{
Key: k, Value: v, AuthorityRank: conflictresolve.RankBlackboard,
SourceLabel: "blackboard", StatedAt: time.Now().Add(-time.Hour),
}
// When a controlled document is in play, conflicts involving these keys
// route to the document author (tiered HITL).
if req.DocID != "" && req.AuthorID > 0 {
claim.AuthorityRank = conflictresolve.RankApprovedDoc
claim.SourceLabel = "approved_document_slot"
claim.DocID = req.DocID
claim.DocAuthorID = req.AuthorID
claim.DocAuthorClaimed = true
}
higher = append(higher, claim)
}
engine := &conflictresolve.Engine{}
verdicts := engine.Adjudicate(ctx, pkg.Facts, higher)
confirmMD := conflictresolve.RenderConfirmationsMD(verdicts, time.Now())
store := &conflictresolve.Store{Pool: s.pool}
var expectations []*intent.PendingExpectation
for _, v := range verdicts {
_ = store.Persist(ctx, conflictresolve.AdjudicationRecord{
TaskRunID: req.RunID,
ProjectID: req.ProjectID,
FactKey: v.FactKey,
Resolution: v.Resolution,
HITLRoute: v.HITLRoute,
WinningValue: v.Winning.Value,
DecidedBy: "rule",
EvidenceDigest: conflictresolve.EvidenceDigest(req.RunID, v.FactKey, v.Winning.Value, v.Resolution),
})
if !v.NeedsHITL || !v.CardEligible {
continue
}
switch v.HITLRoute {
case conflictresolve.RouteTriggerUser:
title, prompt, snippet, payload := conflictresolve.BuildTriggerExpectation(v)
expectations = append(expectations, &intent.PendingExpectation{
ExpectationID: fmt.Sprintf("ctx_conflict_%s_%s", req.RunID, v.FactKey),
ExpectedIntent: intent.IntentConfirmAction,
TargetAction: "confirm_context_fact",
Prompt: prompt,
AffirmativeWord: "确认",
DeclineWord: "纠正",
ExpiresAt: time.Now().Add(24 * time.Hour),
Payload: payload,
ContextSnippet: snippet,
AllowAffirmative: true,
CreatedAt: time.Now(),
DefaultOption: &intent.ExpectationOption{
OptionID: "accept_winning",
Title: title,
Intent: intent.IntentConfirmAction,
Slots: map[string]string{"fact_key": v.FactKey, "value": v.Winning.Value},
},
})
case conflictresolve.RouteDocAuthor:
authorID := v.Winning.DocAuthorID
docID := v.Winning.DocID
if authorID == 0 {
authorID = req.AuthorID
}
if docID == "" {
docID = req.DocID
}
if s.notify != nil && authorID > 0 && docID != "" {
if err := s.notify.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{
UserID: authorID,
ProjectID: req.ProjectID,
DigestKind: notification.DigestKindUnclaimedEdit,
DocID: docID,
DocTitle: req.Title,
Severity: notification.SeverityInfo,
ActorID: req.UserID,
Summary: fmt.Sprintf("对话与文档事实冲突待确认:%s(推荐 %s)", v.FactKey, v.Winning.Value),
}); err != nil {
slog.Warn("dialogue_ctx_author_notify_failed", "error", err.Error(), "fact", v.FactKey)
}
}
}
}
return &squad.DialoguePackage{
DigestMD: pkg.DigestMD,
TurnsMD: pkg.TurnsMD,
FactsJSON: dialoguectx.FactsJSON(pkg.Facts),
ConfirmationsMD: confirmMD,
}, expectations, nil
}
+10
View File
@@ -624,6 +624,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
memBank = make(map[string]string)
}
blackboardFacts := map[string]string{}
if s.ctxMgr != nil {
blended, err := s.buildBlendedSquadContextForUser(ctx, req.ProjectID, req.UserID, req.TaskKey, "squad")
if err == nil && blended != nil {
@@ -636,9 +637,17 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
if len(blended.KeyDecisions) > 0 {
memBank["team_decisions.md"] = "# 项目团队关键决策与里程碑记录\n\n" + strings.Join(blended.KeyDecisions, "\n")
}
for k, v := range blended.BlackboardFacts {
blackboardFacts[k] = v
}
}
}
dialoguePkg, _, dlgErr := s.buildDialoguePackageForRun(ctx, req, blackboardFacts)
if dlgErr != nil {
log.Printf("dialogue_ctx_package_failed project_id=%d err=%v", req.ProjectID, dlgErr)
}
priorDocs := req.PriorDocs
if s.stageGate != nil && req.ProjectID > 0 && req.UserID > 0 {
priorDocs = s.stageGate.FilterPriorDocs(ctx, req.ProjectID, req.UserID, priorDocs)
@@ -663,6 +672,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
PriorDocs: priorDocs,
UserContext: userCtxStr,
MemoryBankFiles: memBank,
DialoguePackage: dialoguePkg,
SessionID: req.SessionID,
UserID: req.UserID,
ProjectID: req.ProjectID,
+305
View File
@@ -0,0 +1,305 @@
// Package conflictresolve adjudicates conflicts between dialogue facts and
// higher-authority sources (blackboard / approved docs) with optional jev assist.
package conflictresolve
import (
"context"
"fmt"
"strings"
"time"
"chat-agent-system/internal/dialoguectx"
"chat-agent-system/internal/jevdecide"
)
const (
ResolutionAutoSupersede = "auto_supersede"
ResolutionAutoRetain = "auto_retain_higher"
ResolutionNeedsConfirm = "needs_confirm"
ResolutionCardLimitAuto = "auto_adjudicated_card_limit"
RouteTriggerUser = "trigger_user"
RouteDocAuthor = "doc_author"
AmbiguousWindow = 30 * time.Minute
MaxCardsPerTask = 3
)
// Authority ranks (lower = stronger).
const (
RankBlackboard = 0
RankApprovedDoc = 1
RankDialogue = 4
)
// FactClaim is a comparable claim from any authority tier.
type FactClaim struct {
Key string
Value string
AuthorityRank int
SourceLabel string
StatedAt time.Time
ExplicitEvidence bool
SpeakerID int64
SpeakerName string
MessageID int64
DocID string // set when claim comes from / conflicts with a document
DocAuthorID int64
DocAuthorClaimed bool
}
// Verdict is one conflict adjudication outcome.
type Verdict struct {
FactKey string
Resolution string
Winning FactClaim
Losers []FactClaim
Reason string
NeedsHITL bool
HITLRoute string // trigger_user | doc_author
CardEligible bool
}
// DecideAssist is the narrow jev port (nil-safe).
type DecideAssist interface {
Decide(ctx context.Context, situation string, questions map[string]jevdecide.Question) (*jevdecide.Response, error)
}
// Engine runs deterministic rules then optional jev assist.
type Engine struct {
Assist DecideAssist
Now func() time.Time
}
// Adjudicate compares dialogue facts against higher-authority claims.
func (e *Engine) Adjudicate(ctx context.Context, dialogue []dialoguectx.DialogueFact, higher []FactClaim) []Verdict {
now := time.Now()
if e != nil && e.Now != nil {
now = e.Now()
}
_ = now
higherByKey := map[string][]FactClaim{}
for _, h := range higher {
higherByKey[h.Key] = append(higherByKey[h.Key], h)
}
// Also detect dialogue-internal conflicts (same key, different values).
byKey := map[string][]dialoguectx.DialogueFact{}
for _, f := range dialogue {
byKey[f.Key] = append(byKey[f.Key], f)
}
var verdicts []Verdict
for key, facts := range byKey {
claims := make([]FactClaim, 0, len(facts)+len(higherByKey[key]))
for _, f := range facts {
claims = append(claims, FactClaim{
Key: f.Key, Value: f.Value, AuthorityRank: RankDialogue,
SourceLabel: "dialogue", StatedAt: f.StatedAt,
ExplicitEvidence: f.ExplicitEvidence, SpeakerID: f.SpeakerID,
SpeakerName: f.SpeakerName, MessageID: f.SourceMessageID,
})
}
claims = append(claims, higherByKey[key]...)
if len(claims) < 2 {
continue
}
// Skip if all values agree.
if allSameValue(claims) {
continue
}
v := e.resolveGroup(ctx, key, claims)
verdicts = append(verdicts, v)
}
return ApplyCardLimit(verdicts)
}
func allSameValue(claims []FactClaim) bool {
if len(claims) == 0 {
return true
}
base := strings.TrimSpace(claims[0].Value)
for _, c := range claims[1:] {
if strings.TrimSpace(c.Value) != base {
return false
}
}
return true
}
func (e *Engine) resolveGroup(ctx context.Context, key string, claims []FactClaim) Verdict {
// Pick strongest authority; among same rank, newer + explicit evidence wins.
best := claims[0]
for _, c := range claims[1:] {
if better(c, best) {
best = c
}
}
losers := make([]FactClaim, 0)
for _, c := range claims {
if c.Value == best.Value && c.AuthorityRank == best.AuthorityRank && c.MessageID == best.MessageID && c.SourceLabel == best.SourceLabel {
continue
}
if strings.TrimSpace(c.Value) == strings.TrimSpace(best.Value) {
continue
}
losers = append(losers, c)
}
v := Verdict{
FactKey: key,
Winning: best,
Losers: losers,
Resolution: ResolutionAutoRetain,
Reason: fmt.Sprintf("权威级 %d 胜出(来源 %s)", best.AuthorityRank, best.SourceLabel),
}
if best.AuthorityRank == RankDialogue {
v.Resolution = ResolutionAutoSupersede
v.Reason = "同级对话取更新证据"
}
// Ambiguous: same authority rank and close timestamps.
ambiguous := false
for _, l := range losers {
if l.AuthorityRank == best.AuthorityRank {
dt := best.StatedAt.Sub(l.StatedAt)
if dt < 0 {
dt = -dt
}
if dt <= AmbiguousWindow && !best.ExplicitEvidence && !l.ExplicitEvidence {
ambiguous = true
break
}
}
}
if !ambiguous {
// Clear authority gap: dialogue loses to blackboard/doc → auto, no HITL.
return v
}
// Ask jev assist; fail-open to needs_confirm.
decision := ResolutionNeedsConfirm
if e != nil && e.Assist != nil && jevdecide.CurrentMode() == jevdecide.ModeAssist {
if res, err := e.Assist.Decide(ctx, buildSituation(key, best, losers), map[string]jevdecide.Question{
"action": {
Type: "choice", Question: "How should this conflicting fact be resolved?",
Options: []string{ResolutionAutoSupersede, ResolutionNeedsConfirm},
},
"is_ambiguous": {Type: "noul", Question: "Is this conflict ambiguous and time-proximate?"},
}); err == nil && res != nil {
if ans, ok := res.Answers["action"]; ok && ans.Reliable(jevdecide.MinAssistConf, jevdecide.MinAssistMargin) {
if s := strings.TrimSpace(ans.Choice); s == ResolutionAutoSupersede || s == ResolutionNeedsConfirm {
decision = s
}
}
}
}
if decision == ResolutionAutoSupersede {
v.Resolution = ResolutionAutoSupersede
v.Reason = "jev assist 高置信自动采信更新证据;" + v.Reason
return v
}
v.Resolution = ResolutionNeedsConfirm
v.NeedsHITL = true
v.CardEligible = true
v.Reason = "同级时间相近且无明确证据,需人工确认"
v.HITLRoute = RouteTriggerUser
// Document-level: any loser or winner tied to a doc → author route.
for _, c := range append([]FactClaim{best}, losers...) {
if c.DocID != "" || c.AuthorityRank == RankApprovedDoc {
v.HITLRoute = RouteDocAuthor
if c.DocAuthorID > 0 {
v.Winning.DocAuthorID = c.DocAuthorID
v.Winning.DocAuthorClaimed = c.DocAuthorClaimed
v.Winning.DocID = c.DocID
}
break
}
}
return v
}
func better(a, b FactClaim) bool {
if a.AuthorityRank != b.AuthorityRank {
return a.AuthorityRank < b.AuthorityRank
}
if a.ExplicitEvidence != b.ExplicitEvidence {
return a.ExplicitEvidence
}
return a.StatedAt.After(b.StatedAt)
}
func buildSituation(key string, best FactClaim, losers []FactClaim) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Conflict on fact key %s. Candidate winner: %s=%s (rank %d, at %s). Losers: ",
key, best.Key, best.Value, best.AuthorityRank, best.StatedAt.Format(time.RFC3339)))
for i, l := range losers {
if i > 0 {
sb.WriteString("; ")
}
sb.WriteString(fmt.Sprintf("%s=%s rank=%d at=%s", l.Key, l.Value, l.AuthorityRank, l.StatedAt.Format(time.RFC3339)))
}
return sb.String()
}
// ApplyCardLimit keeps at most MaxCardsPerTask HITL cards; excess auto-adjudicated.
func ApplyCardLimit(verdicts []Verdict) []Verdict {
cards := 0
out := make([]Verdict, 0, len(verdicts))
for _, v := range verdicts {
if v.NeedsHITL && v.CardEligible {
cards++
if cards > MaxCardsPerTask {
v.NeedsHITL = false
v.CardEligible = false
v.Resolution = ResolutionCardLimitAuto
v.Reason = "超出单任务确认卡限额,自动采信规则矩阵胜者;" + v.Reason
}
}
out = append(out, v)
}
return out
}
// RenderConfirmationsMD writes adjudication audit for context/dialogue/.
func RenderConfirmationsMD(verdicts []Verdict, at time.Time) string {
var sb strings.Builder
sb.WriteString("# 上下文冲突裁决记录\n\n")
sb.WriteString(fmt.Sprintf("- 生成时间: %s\n\n", at.Format(time.RFC3339)))
if len(verdicts) == 0 {
sb.WriteString("本轮未检出对话与高权威来源之间的冲突。\n")
return sb.String()
}
for i, v := range verdicts {
sb.WriteString(fmt.Sprintf("## %d. `%s`\n\n", i+1, v.FactKey))
sb.WriteString(fmt.Sprintf("- 决议: **%s**\n", v.Resolution))
sb.WriteString(fmt.Sprintf("- 理由: %s\n", v.Reason))
sb.WriteString(fmt.Sprintf("- 胜者: %s=%s(%s, rank=%d)\n", v.Winning.Key, v.Winning.Value, v.Winning.SourceLabel, v.Winning.AuthorityRank))
if v.NeedsHITL {
sb.WriteString(fmt.Sprintf("- HITL: 是(路由 %s)\n", v.HITLRoute))
}
for _, l := range v.Losers {
sb.WriteString(fmt.Sprintf("- 并列矛盾: %s=%s(%s, rank=%d)\n", l.Key, l.Value, l.SourceLabel, l.AuthorityRank))
}
sb.WriteString("\n")
}
return sb.String()
}
// BuildTriggerExpectation creates a PendingExpectation-compatible payload map.
func BuildTriggerExpectation(v Verdict) (title, prompt, snippet string, payload map[string]string) {
title = fmt.Sprintf("确认事实冲突:%s", v.FactKey)
prompt = fmt.Sprintf("对话与现有事实对「%s」说法不一致。推荐采信:%s。请确认或纠正。", v.FactKey, v.Winning.Value)
snippet = v.Reason
payload = map[string]string{
"fact_key": v.FactKey,
"winning_value": v.Winning.Value,
"resolution": v.Resolution,
"hitl_route": v.HITLRoute,
}
return title, prompt, snippet, payload
}
+91
View File
@@ -0,0 +1,91 @@
package conflictresolve_test
import (
"context"
"testing"
"time"
"chat-agent-system/internal/conflictresolve"
"chat-agent-system/internal/dialoguectx"
)
func TestAdjudicateBlackboardBeatsDialogue(t *testing.T) {
now := time.Now()
engine := &conflictresolve.Engine{}
verdicts := engine.Adjudicate(context.Background(),
[]dialoguectx.DialogueFact{{
Key: "device_type", Value: "超声", SpeakerName: "张工",
StatedAt: now, Authority: dialoguectx.AuthorityCollabDialogue,
}},
[]conflictresolve.FactClaim{{
Key: "device_type", Value: "眼科OCT", AuthorityRank: conflictresolve.RankBlackboard,
SourceLabel: "blackboard", StatedAt: now.Add(-time.Hour),
}},
)
if len(verdicts) != 1 {
t.Fatalf("want 1 verdict, got %d", len(verdicts))
}
v := verdicts[0]
if v.Winning.Value != "眼科OCT" {
t.Fatalf("blackboard should win, got %s", v.Winning.Value)
}
if v.NeedsHITL {
t.Fatal("clear authority gap should not need HITL")
}
}
func TestAdjudicateAmbiguousNeedsHITL(t *testing.T) {
now := time.Now()
engine := &conflictresolve.Engine{}
verdicts := engine.Adjudicate(context.Background(),
[]dialoguectx.DialogueFact{
{Key: "budget", Value: "一千万", SpeakerName: "张工", StatedAt: now},
{Key: "budget", Value: "两千万", SpeakerName: "李工", StatedAt: now.Add(5 * time.Minute)},
},
nil,
)
if len(verdicts) != 1 {
t.Fatalf("want 1 verdict, got %d", len(verdicts))
}
if !verdicts[0].NeedsHITL {
t.Fatalf("expected HITL for ambiguous same-rank, got %#v", verdicts[0])
}
if verdicts[0].HITLRoute != conflictresolve.RouteTriggerUser {
t.Fatalf("expected trigger_user route, got %s", verdicts[0].HITLRoute)
}
}
func TestCardLimitAutoAdjudicates(t *testing.T) {
verdicts := make([]conflictresolve.Verdict, 0, 5)
for i := 0; i < 5; i++ {
verdicts = append(verdicts, conflictresolve.Verdict{
FactKey: "k", NeedsHITL: true, CardEligible: true,
Resolution: conflictresolve.ResolutionNeedsConfirm,
Winning: conflictresolve.FactClaim{Value: "v"},
})
}
out := conflictresolve.ApplyCardLimit(verdicts)
cards := 0
autos := 0
for _, v := range out {
if v.NeedsHITL {
cards++
}
if v.Resolution == conflictresolve.ResolutionCardLimitAuto {
autos++
}
}
if cards != conflictresolve.MaxCardsPerTask {
t.Fatalf("want %d cards, got %d", conflictresolve.MaxCardsPerTask, cards)
}
if autos != 2 {
t.Fatalf("want 2 auto, got %d", autos)
}
}
func TestRenderConfirmationsMD(t *testing.T) {
md := conflictresolve.RenderConfirmationsMD(nil, time.Now())
if md == "" {
t.Fatal("empty md")
}
}
+58
View File
@@ -0,0 +1,58 @@
package conflictresolve
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store persists conflict adjudications with CAS-style nonce uniqueness.
type Store struct {
Pool *pgxpool.Pool
}
// AdjudicationRecord is one durable verdict row.
type AdjudicationRecord struct {
ID int64
TaskRunID string
ProjectID int64
FactKey string
Resolution string
HITLRoute string
WinningValue string
DecidedBy string
EvidenceDigest string
Nonce string
CreatedAt time.Time
}
// Persist writes a verdict; duplicate (task_run_id, fact_key, nonce) is ignored.
func (s *Store) Persist(ctx context.Context, rec AdjudicationRecord) error {
if s == nil || s.Pool == nil {
return nil
}
if rec.Nonce == "" {
rec.Nonce = EvidenceDigest(rec.TaskRunID, rec.FactKey, rec.WinningValue, rec.Resolution)
}
if rec.EvidenceDigest == "" {
rec.EvidenceDigest = rec.Nonce
}
_, err := s.Pool.Exec(ctx, `
INSERT INTO context_conflict_adjudications
(task_run_id, project_id, fact_key, resolution, hitl_route, winning_value, decided_by, evidence_digest, nonce)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (task_run_id, fact_key, nonce) DO NOTHING
`, rec.TaskRunID, rec.ProjectID, rec.FactKey, rec.Resolution, rec.HITLRoute,
rec.WinningValue, rec.DecidedBy, rec.EvidenceDigest, rec.Nonce)
return err
}
// EvidenceDigest builds a stable CAS token for an adjudication.
func EvidenceDigest(taskRunID, factKey, winning, resolution string) string {
sum := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%s|%s", taskRunID, factKey, winning, resolution)))
return hex.EncodeToString(sum[:16])
}
+2
View File
@@ -204,6 +204,8 @@ type UserProjectContext struct {
RecentDialogue []DialogueTurn `json:"-"`
// DialogueSummary 早期对话滚动压缩摘要(由 ctxmgr 维护生成,不序列化到快照)
DialogueSummary string `json:"-"`
// ProjectDialogueSnippet 项目成员协同近况(跨会话共享对话摘要,权威度低于黑板)
ProjectDialogueSnippet string `json:"-"`
// TaskState 会话级任务状态(DST,记录 active_task, topic_anchor, collected_slots 等)
TaskState *ctxmgr.ConversationTaskState `json:"task_state,omitempty"`
// PersonalizedGuidance 用户工作习惯/偏好注入语(由 ctxmgr 编译,供对话 LLM 使用)
+43
View File
@@ -5,10 +5,12 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"chat-agent-system/internal/agent"
"chat-agent-system/internal/dialoguectx"
"chat-agent-system/internal/stream"
)
@@ -74,6 +76,7 @@ func (e *CoordinatorEngine) generateDialogueReply(
}
}
e.ensureProjectDialogueSnippet(ctx, userCtx)
prompt := buildDialoguePrompt(userInput, userCtx)
instructions := `你是「水木东方」医疗器械「项目助手」——温和、靠谱、会替用户着想的项目助理。
定位:以助理身份与用户友好协作。能力覆盖商务研判、工程方案与法规合规,但对外只以「项目助手」自称,不要分饰多角色,也不要自称「小队/参谋/总工/法规专家」。
@@ -141,6 +144,46 @@ func buildDialoguePrompt(userInput string, userCtx *UserProjectContext) string {
return composer.ComposeDialoguePrompt(userInput, userCtx)
}
// ensureProjectDialogueSnippet fills UserCtx.ProjectDialogueSnippet from shared project dialogue.
func (e *CoordinatorEngine) ensureProjectDialogueSnippet(ctx context.Context, userCtx *UserProjectContext) {
if e == nil || userCtx == nil || e.ctxMgr == nil {
return
}
if !dialoguectx.InjectionEnabled() {
userCtx.ProjectDialogueSnippet = ""
return
}
if strings.TrimSpace(userCtx.ProjectDialogueSnippet) != "" {
return
}
projectID := parseProjectID(userCtx.Project.ID)
if projectID <= 0 {
return
}
collector := &dialoguectx.Collector{MemoryFallback: e.ctxMgr}
pkg, err := collector.CollectAndPackage(ctx, dialoguectx.CollectOptions{
ProjectID: projectID,
MaxDays: 3,
MaxTurns: 40,
})
if err != nil || pkg == nil {
return
}
userCtx.ProjectDialogueSnippet = dialoguectx.RenderAssistantSnippet(pkg)
}
func parseProjectID(id string) int64 {
id = strings.TrimSpace(id)
if id == "" {
return 0
}
n, err := strconv.ParseInt(id, 10, 64)
if err != nil || n <= 0 {
return 0
}
return n
}
func sanitizePathSegment(s string) string {
s = strings.TrimSpace(s)
if s == "" {
+6
View File
@@ -139,6 +139,12 @@ func (mc *MemoryComposer) ComposeDialoguePrompt(userInput string, userCtx *UserP
}
}
}
if s := strings.TrimSpace(userCtx.ProjectDialogueSnippet); s != "" {
sb.WriteString(s)
if !strings.HasSuffix(s, "\n") {
sb.WriteString("\n")
}
}
sb.WriteString("\n")
// -------------------------------------------------------------
+103
View File
@@ -0,0 +1,103 @@
package ctxmgr
import (
"context"
"encoding/json"
"fmt"
)
// CollectProjectDialogueRecords returns authorized-visible chat message records
// for a project (before line formatting). Used by dialoguectx packaging.
func (m *ContextManager) CollectProjectDialogueRecords(ctx context.Context, projectID int64, limit int) ([]ChatMessageRecord, error) {
if m == nil || projectID <= 0 {
return nil, nil
}
if limit <= 0 {
limit = defaultProjectDialogueLimit
}
var msgs []ChatMessageRecord
if m.pool != nil {
rows, err := m.pool.Query(ctx, `
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
FROM chat_messages cm
JOIN chat_sessions cs ON cs.session_id = cm.session_id
WHERE cs.project_id = $1 AND cm.deleted_at IS NULL
ORDER BY cm.created_at ASC
LIMIT $2
`, projectID, limit)
if err != nil {
rows, err = m.pool.Query(ctx, `
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
FROM chat_messages cm
JOIN chat_sessions cs ON cs.session_id = cm.session_id
WHERE cs.project_id = $1 AND cm.deleted_at IS NULL
ORDER BY cm.created_at ASC
LIMIT $2
`, projectID, limit)
if err != nil {
return nil, fmt.Errorf("collect project dialogue records: %w", err)
}
defer rows.Close()
for rows.Next() {
var rec ChatMessageRecord
var metaJSON []byte
if err := rows.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,
); err != nil {
return nil, err
}
if len(metaJSON) > 0 {
_ = json.Unmarshal(metaJSON, &rec.Metadata)
}
msgs = append(msgs, rec)
}
return msgs, rows.Err()
}
defer rows.Close()
for rows.Next() {
var rec ChatMessageRecord
var metaJSON []byte
if err := rows.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,
); err != nil {
return nil, err
}
if len(metaJSON) > 0 {
_ = json.Unmarshal(metaJSON, &rec.Metadata)
}
msgs = append(msgs, rec)
}
return msgs, rows.Err()
}
m.mu.RLock()
defer m.mu.RUnlock()
for sessionID, sessionMsgs := range m.messages {
sess := m.sessions[sessionID]
if sess != nil && sess.ProjectID != projectID {
continue
}
for _, rec := range sessionMsgs {
if sess == nil || sess.ProjectID == 0 {
if pid := messageProjectID(rec); pid != projectID {
continue
}
}
msgs = append(msgs, rec)
if len(msgs) >= limit {
return msgs, nil
}
}
}
return msgs, nil
}
+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
}
+52
View File
@@ -0,0 +1,52 @@
package squad_test
import (
"os"
"path/filepath"
"strings"
"testing"
"chat-agent-system/internal/squad"
)
func TestPrepareWorkspaceWritesDialogueContext(t *testing.T) {
dir := t.TempDir()
engine := squad.NewWorkspaceEngine()
ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{
WorkDir: dir,
TaskKey: "system_reqs",
StageTitle: "系统需求",
ProjectName: "Demo",
CustomerName: "Acme",
DialoguePackage: &squad.DialoguePackage{
DigestMD: "# 项目对话纪要\n\n- **2026-09-24**: 张工: 器械类型眼科OCT\n",
TurnsMD: "# turns\n\n[2026-09-24 15:00] 张工(engineer/member) 用户: 器械类型:眼科OCT\n",
FactsJSON: []byte(`[{"key":"device_type","value":"眼科OCT"}]`),
ConfirmationsMD: "# 裁决\n\n无冲突\n",
},
WorkflowSteps: []squad.StepSpec{{
ID: "s1", Title: "整理", Role: "organizer", Mode: "write",
SubWorkspace: "tasks/01_organizer", Status: "pending",
}},
})
if err != nil {
t.Fatal(err)
}
if ws == nil {
t.Fatal("nil workspace")
}
for _, name := range []string{"digest.md", "turns.md", "facts.json", "CONFIRMATIONS.md", "README.md"} {
p := filepath.Join(dir, "context", "dialogue", name)
if _, err := os.Stat(p); err != nil {
t.Fatalf("missing %s: %v", name, err)
}
}
readme, _ := os.ReadFile(filepath.Join(dir, "context", "README.md"))
if !strings.Contains(string(readme), "collab_dialogue") {
t.Fatalf("README missing collab_dialogue: %s", readme)
}
taskMD, _ := os.ReadFile(filepath.Join(dir, "tasks", "01_organizer", "TASK.md"))
if !strings.Contains(string(taskMD), "context/dialogue/") {
t.Fatalf("TASK.md missing dialogue nav: %s", taskMD)
}
}
+2
View File
@@ -51,6 +51,7 @@ type SquadRunRequest struct {
PriorDocs []PriorDocItem `json:"prior_docs"`
UserContext string `json:"user_context,omitempty"`
MemoryBankFiles map[string]string `json:"memory_bank_files,omitempty"`
DialoguePackage *DialoguePackage `json:"-"`
SessionID string `json:"session_id,omitempty"`
UserID int64 `json:"user_id,omitempty"`
ProjectID int64 `json:"project_id,omitempty"`
@@ -234,6 +235,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR
InitialFields: req.Fields,
UserContext: req.UserContext,
MemoryBankFiles: req.MemoryBankFiles,
DialoguePackage: req.DialoguePackage,
}
_, err := s.engine.PrepareWorkspace(wsReq)
+10
View File
@@ -31,12 +31,22 @@ type WorkspacePrepareRequest struct {
ProjectContext string
AgentContext string
MemoryBankFiles map[string]string
// DialoguePackage is optional layered project dialogue (digest/turns/facts).
DialoguePackage *DialoguePackage
CustomAgents []AgentSpec
WorkflowSteps []StepSpec
InitialFields map[string]string
Metadata map[string]string
}
// DialoguePackage is the squad-facing dialogue injection payload.
type DialoguePackage struct {
DigestMD string
TurnsMD string
FactsJSON []byte
ConfirmationsMD string
}
// Workspace 表示已初始化的工作区句柄
type Workspace struct {
WorkDir string
+48 -2
View File
@@ -181,13 +181,18 @@ func (e *WorkspaceEngine) buildContextSystem(workDir string, req WorkspacePrepar
Guidance: "协同上下文背景参考,防止上下文遗忘",
})
// 8. 写入 context/index.json
// 8. dialogue/ — project member dialogue (digest + turns + facts)
if err := e.writeDialogueContext(ctxDir, req.DialoguePackage, &contextItems); err != nil {
return err
}
// 9. 写入 context/index.json
ctxIndex := ContextIndex{Items: contextItems}
if err := writeJSON(filepath.Join(ctxDir, "index.json"), ctxIndex); err != nil {
return err
}
// 9. 写入 context/README.md 指引
// 10. 写入 context/README.md 指引
readmeContent := `# 上下文使用指引 (Context Reading Guide)
本目录为当前智能体任务的自包含上下文沙箱。所有智能体专员在执行任务时,必须按照以下权威度等级(Authority Level)采纳信息:
@@ -197,9 +202,50 @@ func (e *WorkspaceEngine) buildContextSystem(workDir string, req WorkspacePrepar
| **authoritative** | ` + "`user.md`" + `, ` + "`project.md`" + `, ` + "`agent.md`" + ` | **最高权威**。必须绝对遵循;严禁越权替用户拍板,未决项列入待确认清单 |
| **primary_evidence** | ` + "`materials_text/*`" + `, ` + "`prior_docs/*`" + ` | **原始事实证据**。引用必须标明出处条文;若材料间存在矛盾,并列呈现 |
| **reference_standard** | ` + "`knowledge/hits.md`" + ` | **标准与法规依据**。必须指明法规/标准代码;涉及监管裁定标注需窗口核实 |
| **collab_dialogue** | ` + "`dialogue/*`" + ` | **项目成员对话**。采信级低于黑板事实与已批准文档;与高权威来源冲突时须并列呈现矛盾,不得静默采信对话版本 |
| **supplementary** | ` + "`memory/*`" + ` | **补充记忆背景**。仅供背景了解,不作为唯一支撑证据 |
---
`
return os.WriteFile(filepath.Join(ctxDir, "README.md"), []byte(readmeContent), 0o644)
}
func (e *WorkspaceEngine) writeDialogueContext(ctxDir string, pkg *DialoguePackage, items *[]ContextItem) error {
dlgDir := filepath.Join(ctxDir, "dialogue")
if err := os.MkdirAll(dlgDir, 0o755); err != nil {
return err
}
if pkg == nil {
_ = os.WriteFile(filepath.Join(dlgDir, "README.md"), []byte("# 项目对话说明\n\n本任务未注入项目成员对话上下文(开关关闭或无可共享对话)。\n"), 0o644)
return nil
}
digest := pkg.DigestMD
if strings.TrimSpace(digest) == "" {
digest = "# 项目对话纪要\n\n暂无可用共享对话纪要。\n"
}
turns := pkg.TurnsMD
if strings.TrimSpace(turns) == "" {
turns = "# 项目成员对话近窗\n\n本窗口内无授权可见的共享对话。\n"
}
facts := pkg.FactsJSON
if len(facts) == 0 {
facts = []byte("[]\n")
}
confirmations := pkg.ConfirmationsMD
if strings.TrimSpace(confirmations) == "" {
confirmations = "# 上下文冲突裁决记录\n\n本轮未检出冲突,或裁决尚未执行。\n"
}
_ = os.WriteFile(filepath.Join(dlgDir, "digest.md"), []byte(digest), 0o644)
_ = os.WriteFile(filepath.Join(dlgDir, "turns.md"), []byte(turns), 0o644)
_ = os.WriteFile(filepath.Join(dlgDir, "facts.json"), facts, 0o644)
_ = os.WriteFile(filepath.Join(dlgDir, "CONFIRMATIONS.md"), []byte(confirmations), 0o644)
_ = os.WriteFile(filepath.Join(dlgDir, "README.md"), []byte("# 项目成员对话上下文\n\n- `digest.md`:按日纪要\n- `turns.md`:近窗带元数据原文\n- `facts.json`:对话沉淀事实候选\n- `CONFIRMATIONS.md`:冲突裁决审计\n\n权威度 `collab_dialogue`:低于黑板与已批准文档。\n"), 0o644)
*items = append(*items, ContextItem{
File: "context/dialogue/",
Description: "项目成员共享对话(纪要/近窗原文/事实候选/冲突裁决)",
AuthorityLevel: "collab_dialogue",
Guidance: "项目成员对话记录。采信级低于黑板事实与已批准文档;与高权威来源冲突时须并列呈现矛盾,不得静默采信对话版本",
})
return nil
}
+1
View File
@@ -100,6 +100,7 @@ func (e *WorkspaceEngine) buildSubWorkspaces(workDir string, wf WorkflowPlan, sq
taskBuilder.WriteString("- `../../blackboard.json`:前序专员抽取的结构化事实与缺口\n")
taskBuilder.WriteString("- `../../context/project.md`:项目全局立项基线与产品定义\n")
taskBuilder.WriteString("- `../../context/user.md`:用户个性化工作偏好与决策约束\n")
taskBuilder.WriteString("- `../../context/dialogue/`:项目成员共享对话(纪要/近窗/事实/冲突裁决)\n")
taskBuilder.WriteString("- `../../context/materials_text/README.md`:原始材料全文或基线说明\n")
taskBuilder.WriteString("- `../../context/knowledge/hits.md`:法规与标准命中快照 (若存在)\n\n")