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.
173 lines
4.7 KiB
Go
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 ""
|
|
}
|