Files
agent-bot ee1e53b510
CI / go-vet (pull_request) Failing after 10s
CI / backup-restore-drill (pull_request) Failing after 25s
CI / web (pull_request) Successful in 33s
fix(tests): per-package test databases + legitimate approval fixtures (issue #2 close)
Root causes of the remaining 5 CI PG failures (and the hidden flaky set):
1. All PG-integration packages shared ONE database under parallel package
   execution - migrations/seeds/truncates raced (nondeterministic failure
   sets, locally and in CI).
   Fix: RequireTestDatabaseURL/SkipIfNoTestDatabaseURL now derive a
   per-package database (<base>_<pkgHash>, advisory-lock-guarded
   CREATE); 9 raw-env straggler tests funnelled through the helpers;
   db-package tests call it unqualified (no import cycle).
2. Approval-chain fixtures were not production-shaped: freezeReviewSnapshot
   only honors head_snapshot_id, fake content hashes never froze a
   snapshot, so ApproverApprove rejected with 'approved requires frozen
   review_snapshot_id'. Errors were swallowed (_, _ =) hiding this.
   Fix (ownership-respecting, arch-enforced):
   - docrevision.SeedContentSnapshotForTest seeds the ledger tables it owns
   - docgov GovernanceEngine.FreezeHeadSnapshotForTest freezes head/review
     pointers on document_records (docgov-owned) and clears engine cache
   - all four integration fixtures use the pair; swallowed errors checked
3. Fixed-name fixtures collide once a package shares one DB (projects
   uq_projects_name_global): uniquified in projectinstance + server tests.
4. CAS/ctxmgr tests never bootstrapped migrations (relied on other packages
   having migrated the shared DB): explicit RunMigrationsOnceForTest.

Verified: full parallel short suite = 75 packages, 0 failures against an
isolated throwaway PG. CI skip list removed - short tests fully required.
2026-09-29 09:54:43 +00:00

173 lines
4.7 KiB
Go

package ctxmgr
import (
"context"
"encoding/json"
"fmt"
"strings"
"chat-agent-system/internal/replyexport"
)
const defaultProjectDialogueLimit = 120
// ProjectDialogueCollectResult 项目级对话采集结果(供上下文快照使用)。
type ProjectDialogueCollectResult struct {
Lines []string
ExcludedPrivate int
ExcludedNoAccess int
AllowedStageKey string
StageFilteredCount int
}
// CollectProjectDialogueOptions 采集选项。
type CollectProjectDialogueOptions struct {
Limit int
AllowedStageKey string // 非空时仅保留无 stage_key 或匹配的对话
}
// CollectProjectDialogue 收集进入项目知识边界的共享对话行。
func (m *ContextManager) CollectProjectDialogue(ctx context.Context, projectID int64, opts CollectProjectDialogueOptions) (*ProjectDialogueCollectResult, error) {
out := &ProjectDialogueCollectResult{AllowedStageKey: opts.AllowedStageKey}
if m == nil || projectID <= 0 {
return out, nil
}
limit := opts.Limit
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'), 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: %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)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("scan project dialogue failed: %w", err)
}
} else {
m.mu.RLock()
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)
}
}
m.mu.RUnlock()
}
for _, rec := range msgs {
line, include, excludedPrivate, excludedNoAccess := classifyDialogueLine(rec, opts.AllowedStageKey)
if excludedPrivate {
out.ExcludedPrivate++
}
if excludedNoAccess {
out.ExcludedNoAccess++
out.StageFilteredCount++
}
if !include || line == "" {
continue
}
out.Lines = append(out.Lines, line)
}
return out, nil
}
func messageProjectID(rec ChatMessageRecord) int64 {
if rec.Metadata == nil {
return 0
}
if pVal, ok := rec.Metadata["project_id"].(int64); ok {
return pVal
}
if pVal, ok := rec.Metadata["project_id"].(float64); ok {
return int64(pVal)
}
return 0
}
func classifyDialogueLine(rec ChatMessageRecord, allowedStageKey string) (line string, include bool, excludedPrivate bool, excludedNoAccess bool) {
content := strings.TrimSpace(rec.Content)
if content == "" {
return "", false, true, false
}
if strings.HasPrefix(content, "[private]") || strings.HasPrefix(content, "[draft]") {
return "", false, true, false
}
if vis, _ := rec.Metadata["visibility"].(string); vis == "private" {
return "", false, true, false
}
stageKey := MessageStageKey(rec.Metadata)
if allowedStageKey != "" && stageKey != "" && stageKey != allowedStageKey {
return "", false, false, true
}
switch rec.Role {
case "user":
line = fmt.Sprintf("用户: %s", content)
return line, true, false, false
case "assistant":
switch rec.ContentKind {
case string(replyexport.KindActionCard), string(replyexport.KindStatusUpdate),
string(replyexport.KindErrorNotice), string(replyexport.KindSystemNotice):
return "", false, true, false
}
switch rec.ExportPolicy {
case string(replyexport.PolicyFull), string(replyexport.PolicyCopyOnly):
line = fmt.Sprintf("助手: %s", content)
return line, true, false, false
default:
return "", false, true, false
}
default:
return "", false, true, false
}
}
// MessageStageKey 从消息 metadata 提取阶段键。
func MessageStageKey(metadata map[string]any) string {
if metadata == nil {
return ""
}
if sk, ok := metadata["stage_key"].(string); ok {
return sk
}
return ""
}