Class: ClaudeMemory::Dashboard::Conflicts

Inherits:
Object
  • Object
show all
Defined in:
lib/claude_memory/dashboard/conflicts.rb

Overview

Conflicts resource for the dashboard API. Owns list/detail/reject across both scopes (global + project) and keeps them disjoint — a conflict in one store can never reference a fact in the other.

List results are deduplicated at the display layer by (source, predicate, normalized(object_a, object_b), status). Each group carries a ‘group_size` so the UI can label “sqlite vs postgres (×11)” instead of surfacing 11 rows that resolve identically. `counts` reflects the distinct count; `raw_counts` preserves the underlying row totals for the Advanced drawer.

Constant Summary collapse

DEFAULT_LIMIT =
50

Instance Method Summary collapse

Constructor Details

#initialize(manager) ⇒ Conflicts

Returns a new instance of Conflicts.



18
19
20
# File 'lib/claude_memory/dashboard/conflicts.rb', line 18

def initialize(manager)
  @manager = manager
end

Instance Method Details

#detail(id, scope) ⇒ Object

Parameters:

  • id (Integer, String)

    conflict row id

  • scope (String)

    “project” or “global” (required — conflicts are scope-local)



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/claude_memory/dashboard/conflicts.rb', line 69

def detail(id, scope)
  return {error: "Invalid scope"} unless %w[global project].include?(scope)
  store = @manager.store_if_exists(scope)
  return {error: "#{scope} store not available"} unless store

  row = store.conflicts.where(id: id.to_i).first
  return {error: "Conflict #{id} not found"} unless row

  presenter = FactPresenter.new(store)
  {
    conflict: {
      id: row[:id],
      status: row[:status],
      detected_at: row[:detected_at],
      detected_ago: Core::RelativeTime.format(row[:detected_at]),
      notes: row[:notes],
      source: scope
    },
    fact_a: presenter.with_provenance(store.facts.where(id: row[:fact_a_id]).first),
    fact_b: presenter.with_provenance(store.facts.where(id: row[:fact_b_id]).first)
  }
end

#distinct_open_countsObject

Count distinct open conflicts per scope (after deduplication). Used by Trust#needs_review so the sidebar backlog reflects distinct pairs rather than duplicated rows from pre-dedupe history.



55
56
57
58
59
60
61
62
63
64
65
# File 'lib/claude_memory/dashboard/conflicts.rb', line 55

def distinct_open_counts
  counts = {project: 0, global: 0}
  %w[project global].each do |scope|
    store = @manager.store_if_exists(scope)
    next unless store
    rows = store.conflicts.where(status: "open").all
      .map { |r| r.merge(source: scope, store: store) }
    counts[scope.to_sym] = group_rows(rows).size
  end
  counts.merge(total: counts.values.sum)
end

#list(params = {}) ⇒ Object

Parameters:

  • params (Hash) (defaults to: {})

    “scope” (project|global|all), “status” (open|resolved|all), “limit”, “offset”



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/claude_memory/dashboard/conflicts.rb', line 24

def list(params = {})
  scope = params["scope"] || "project"
  status_filter = params["status"] || "open"
  limit = (params["limit"] || DEFAULT_LIMIT).to_i
  offset = (params["offset"] || 0).to_i

  stores = stores_for(scope)
  rows = stores.flat_map { |source, store|
    dataset = store.conflicts
    dataset = dataset.where(status: status_filter) unless status_filter == "all"
    dataset.all.map { |r| r.merge(source: source, store: store) }
  }

  groups = group_rows(rows)
  groups.sort_by! { |g| -Core::RelativeTime.to_epoch(g[:representative][:detected_at]) }

  {
    total: groups.size,
    limit: limit,
    offset: offset,
    scope: scope,
    status: status_filter,
    counts: counts_across_scopes,
    raw_counts: raw_counts_across_scopes,
    conflicts: Array(groups[offset, limit]).map { |g| serialize_group(g) }
  }
end

#reject(id, side:, reason: nil, scope: "project") ⇒ Object

Rejects one side of a conflict by rejecting its fact. SQLiteStore#reject_fact flips the fact to “rejected” and cascade-resolves associated conflicts in a single transaction, so duplicate rows collapse automatically.



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/claude_memory/dashboard/conflicts.rb', line 95

def reject(id, side:, reason: nil, scope: "project")
  return {error: "Invalid side (must be 'a' or 'b')"} unless %w[a b].include?(side)
  return {error: "Invalid scope"} unless %w[global project].include?(scope)
  store = @manager.store_if_exists(scope)
  return {error: "#{scope} store not available"} unless store

  row = store.conflicts.where(id: id.to_i).first
  return {error: "Conflict #{id} not found"} unless row

  fact_id = (side == "a") ? row[:fact_a_id] : row[:fact_b_id]
  result = store.reject_fact(fact_id, reason: reason)

  {
    success: true,
    conflict_id: id,
    rejected_fact_id: fact_id,
    side: side,
    scope: scope,
    conflicts_resolved: result[:conflicts_resolved]
  }
end

#reject_similar(keeper_fact_id, reason: nil, scope: "project") ⇒ Hash

Bulk-reject every disputed fact that’s in open conflict against a single “keeper” fact. Resolves the distiller-hallucination pattern where one correct fact (e.g. uses_database=sqlite) accumulates many contradicting candidates (postgresql, mysql, redis, …). For each open conflict where keeper_fact_id is on either side, the fact on the OTHER side is rejected; SQLiteStore#reject_fact cascade-resolves the conflict inside its own transaction.

Returns:

  • (Hash)

    conflicts_resolved:



126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/claude_memory/dashboard/conflicts.rb', line 126

def reject_similar(keeper_fact_id, reason: nil, scope: "project")
  return {error: "Invalid scope"} unless %w[global project].include?(scope)
  store = @manager.store_if_exists(scope)
  return {error: "#{scope} store not available"} unless store

  keeper_id = keeper_fact_id.to_i
  rows = store.conflicts
    .where(status: "open")
    .where(Sequel.|({fact_a_id: keeper_id}, {fact_b_id: keeper_id}))
    .all

  return {success: true, keeper_fact_id: keeper_id, rejected_fact_ids: [], conflicts_resolved: 0} if rows.empty?

  rejected = []
  total_resolved = 0
  rows.each do |row|
    loser_id = (row[:fact_a_id] == keeper_id) ? row[:fact_b_id] : row[:fact_a_id]
    next if rejected.include?(loser_id)
    result = store.reject_fact(loser_id, reason: reason)
    rejected << loser_id
    total_resolved += result[:conflicts_resolved] || 0
  end

  {
    success: true,
    keeper_fact_id: keeper_id,
    rejected_fact_ids: rejected,
    conflicts_resolved: total_resolved,
    scope: scope
  }
end