Class: ClaudeMemory::Dashboard::Conflicts
- Inherits:
-
Object
- Object
- ClaudeMemory::Dashboard::Conflicts
- 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
- #detail(id, scope) ⇒ Object
-
#distinct_open_counts ⇒ Object
Count distinct open conflicts per scope (after deduplication).
-
#initialize(manager) ⇒ Conflicts
constructor
A new instance of Conflicts.
- #list(params = {}) ⇒ Object
-
#reject(id, side:, reason: nil, scope: "project") ⇒ Object
Rejects one side of a conflict by rejecting its fact.
-
#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.
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
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_counts ⇒ Object
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
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.
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 |