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:
@@ -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)
|
||||
@@ -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 |
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 使用)
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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")
|
||||
|
||||
// -------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user