diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 7733b09a..e365d17f 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -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 | diff --git a/.doc/ops/short-term/2026-09-25-dialogue-context-injection.md b/.doc/ops/short-term/2026-09-25-dialogue-context-injection.md new file mode 100644 index 00000000..762f541f --- /dev/null +++ b/.doc/ops/short-term/2026-09-25-dialogue-context-injection.md @@ -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) diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index e2d26d8f..13eeb764 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.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` | diff --git a/.doc/system-design/2026-09-25-dialogue-context-injection-design.md b/.doc/system-design/2026-09-25-dialogue-context-injection-design.md new file mode 100644 index 00000000..ea1813d7 --- /dev/null +++ b/.doc/system-design/2026-09-25-dialogue-context-injection-design.md @@ -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 | diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index 1c86a2c7..227a09f5 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -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: diff --git a/.doc/test-reports/2026-09-25-dialogue-context-injection-test-report.md b/.doc/test-reports/2026-09-25-dialogue-context-injection-test-report.md new file mode 100644 index 00000000..5a3bd1b8 --- /dev/null +++ b/.doc/test-reports/2026-09-25-dialogue-context-injection-test-report.md @@ -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 浸泡。 diff --git a/deploy/postgres/goose/00071_2026-09-25-context-conflict-adjudications.sql b/deploy/postgres/goose/00071_2026-09-25-context-conflict-adjudications.sql new file mode 100644 index 00000000..51cceba7 --- /dev/null +++ b/deploy/postgres/goose/00071_2026-09-25-context-conflict-adjudications.sql @@ -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; diff --git a/internal/bizdev/dialogue_inject.go b/internal/bizdev/dialogue_inject.go new file mode 100644 index 00000000..657822ba --- /dev/null +++ b/internal/bizdev/dialogue_inject.go @@ -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 +} diff --git a/internal/bizdev/service_task_run.go b/internal/bizdev/service_task_run.go index 3dff8e02..9170f637 100644 --- a/internal/bizdev/service_task_run.go +++ b/internal/bizdev/service_task_run.go @@ -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, diff --git a/internal/conflictresolve/engine.go b/internal/conflictresolve/engine.go new file mode 100644 index 00000000..2f3b8656 --- /dev/null +++ b/internal/conflictresolve/engine.go @@ -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 +} diff --git a/internal/conflictresolve/engine_test.go b/internal/conflictresolve/engine_test.go new file mode 100644 index 00000000..932b967e --- /dev/null +++ b/internal/conflictresolve/engine_test.go @@ -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") + } +} diff --git a/internal/conflictresolve/store.go b/internal/conflictresolve/store.go new file mode 100644 index 00000000..a495178a --- /dev/null +++ b/internal/conflictresolve/store.go @@ -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]) +} diff --git a/internal/coordinator/context.go b/internal/coordinator/context.go index c9039ab1..80538a92 100644 --- a/internal/coordinator/context.go +++ b/internal/coordinator/context.go @@ -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 使用) diff --git a/internal/coordinator/dialogue_llm.go b/internal/coordinator/dialogue_llm.go index 222a9052..fd99ac0c 100644 --- a/internal/coordinator/dialogue_llm.go +++ b/internal/coordinator/dialogue_llm.go @@ -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 == "" { diff --git a/internal/coordinator/memory_composer.go b/internal/coordinator/memory_composer.go index 6aa29259..f27ff9b5 100644 --- a/internal/coordinator/memory_composer.go +++ b/internal/coordinator/memory_composer.go @@ -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") // ------------------------------------------------------------- diff --git a/internal/ctxmgr/project_dialogue_records.go b/internal/ctxmgr/project_dialogue_records.go new file mode 100644 index 00000000..26544877 --- /dev/null +++ b/internal/ctxmgr/project_dialogue_records.go @@ -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 +} diff --git a/internal/dialoguectx/clean.go b/internal/dialoguectx/clean.go new file mode 100644 index 00000000..5c409fec --- /dev/null +++ b/internal/dialoguectx/clean.go @@ -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 "" +} diff --git a/internal/dialoguectx/collector.go b/internal/dialoguectx/collector.go new file mode 100644 index 00000000..854aae33 --- /dev/null +++ b/internal/dialoguectx/collector.go @@ -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 +} diff --git a/internal/dialoguectx/flags.go b/internal/dialoguectx/flags.go new file mode 100644 index 00000000..0ac7a9fd --- /dev/null +++ b/internal/dialoguectx/flags.go @@ -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 +} diff --git a/internal/dialoguectx/package.go b/internal/dialoguectx/package.go new file mode 100644 index 00000000..7e96f4e3 --- /dev/null +++ b/internal/dialoguectx/package.go @@ -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 +} diff --git a/internal/dialoguectx/package_test.go b/internal/dialoguectx/package_test.go new file mode 100644 index 00000000..88a6bbb6 --- /dev/null +++ b/internal/dialoguectx/package_test.go @@ -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) + } +} diff --git a/internal/dialoguectx/types.go b/internal/dialoguectx/types.go new file mode 100644 index 00000000..06d5165e --- /dev/null +++ b/internal/dialoguectx/types.go @@ -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 +} diff --git a/internal/squad/dialogue_context_test.go b/internal/squad/dialogue_context_test.go new file mode 100644 index 00000000..20328c09 --- /dev/null +++ b/internal/squad/dialogue_context_test.go @@ -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) + } +} diff --git a/internal/squad/service.go b/internal/squad/service.go index 3a99f37f..1f9b3662 100644 --- a/internal/squad/service.go +++ b/internal/squad/service.go @@ -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) diff --git a/internal/squad/workspace.go b/internal/squad/workspace.go index 1304bd7f..4fc179ac 100644 --- a/internal/squad/workspace.go +++ b/internal/squad/workspace.go @@ -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 diff --git a/internal/squad/workspace_context_build.go b/internal/squad/workspace_context_build.go index ccc683b1..7ad44fe7 100644 --- a/internal/squad/workspace_context_build.go +++ b/internal/squad/workspace_context_build.go @@ -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 +} diff --git a/internal/squad/workspace_sub_build.go b/internal/squad/workspace_sub_build.go index 94fab7ce..946e6684 100644 --- a/internal/squad/workspace_sub_build.go +++ b/internal/squad/workspace_sub_build.go @@ -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")