-
Notifications
You must be signed in to change notification settings - Fork 7
feat: BrainBar Knowledge Graph viewer #159
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -157,6 +157,20 @@ final class BrainDatabase: @unchecked Sendable { | |
| ) | ||
| """) | ||
|
|
||
| try execute(""" | ||
| CREATE TABLE IF NOT EXISTS kg_entity_chunks ( | ||
| entity_id TEXT NOT NULL, | ||
| chunk_id TEXT NOT NULL, | ||
| relevance REAL DEFAULT 1.0, | ||
| PRIMARY KEY (entity_id, chunk_id) | ||
| ) | ||
| """) | ||
|
|
||
| try execute(""" | ||
| CREATE INDEX IF NOT EXISTS idx_kg_ec_entity | ||
| ON kg_entity_chunks(entity_id) | ||
| """) | ||
|
|
||
| try execute(""" | ||
| CREATE TABLE IF NOT EXISTS injection_events ( | ||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
|
|
@@ -1417,6 +1431,121 @@ final class BrainDatabase: @unchecked Sendable { | |
| return result | ||
| } | ||
|
|
||
| // MARK: - Knowledge Graph bulk queries | ||
|
|
||
| struct KGEntityRow: Equatable { | ||
| let id: String | ||
| let name: String | ||
| let entityType: String | ||
| let description: String? | ||
| let importance: Double | ||
| } | ||
|
|
||
| struct KGRelationRow: Equatable { | ||
| let id: String | ||
| let sourceId: String | ||
| let targetId: String | ||
| let relationType: String | ||
| } | ||
|
|
||
| struct KGChunkRow: Equatable { | ||
| let chunkID: String | ||
| let snippet: String | ||
| let importance: Int | ||
| let relevance: Double | ||
| } | ||
|
|
||
| func fetchKGEntities(limit: Int = 500) throws -> [KGEntityRow] { | ||
| guard let db else { throw DBError.notOpen } | ||
| let sql = """ | ||
| SELECT id, name, entity_type, description, | ||
| COALESCE(CAST(json_extract(metadata, '$.importance') AS REAL), 5.0) AS importance | ||
| FROM kg_entities | ||
| ORDER BY importance DESC | ||
| LIMIT ? | ||
| """ | ||
| var stmt: OpaquePointer? | ||
| guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { | ||
| throw DBError.prepare(sqlite3_errcode(db)) | ||
| } | ||
| defer { sqlite3_finalize(stmt) } | ||
| sqlite3_bind_int(stmt, 1, Int32(limit)) | ||
|
|
||
| var rows: [KGEntityRow] = [] | ||
| while sqlite3_step(stmt) == SQLITE_ROW { | ||
| rows.append(KGEntityRow( | ||
| id: columnText(stmt, 0) ?? "", | ||
| name: columnText(stmt, 1) ?? "", | ||
| entityType: columnText(stmt, 2) ?? "", | ||
| description: columnText(stmt, 3), | ||
| importance: sqlite3_column_double(stmt, 4) | ||
| )) | ||
| } | ||
| return rows | ||
| } | ||
|
|
||
| func fetchKGRelations() throws -> [KGRelationRow] { | ||
| guard let db else { throw DBError.notOpen } | ||
| let sql = "SELECT id, source_id, target_id, relation_type FROM kg_relations" | ||
| var stmt: OpaquePointer? | ||
| guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { | ||
| throw DBError.prepare(sqlite3_errcode(db)) | ||
| } | ||
| defer { sqlite3_finalize(stmt) } | ||
|
|
||
| var rows: [KGRelationRow] = [] | ||
| while sqlite3_step(stmt) == SQLITE_ROW { | ||
| rows.append(KGRelationRow( | ||
| id: columnText(stmt, 0) ?? "", | ||
| sourceId: columnText(stmt, 1) ?? "", | ||
| targetId: columnText(stmt, 2) ?? "", | ||
| relationType: columnText(stmt, 3) ?? "" | ||
| )) | ||
| } | ||
| return rows | ||
| } | ||
|
|
||
| func linkEntityChunk(entityId: String, chunkId: String, relevance: Double = 1.0) throws { | ||
| guard let db else { throw DBError.notOpen } | ||
| let sql = "INSERT OR REPLACE INTO kg_entity_chunks (entity_id, chunk_id, relevance) VALUES (?, ?, ?)" | ||
| try runWriteStatement(on: db, sql: sql, retries: 3) { stmt in | ||
| bindText(entityId, to: stmt, index: 1) | ||
| bindText(chunkId, to: stmt, index: 2) | ||
| sqlite3_bind_double(stmt, 3, relevance) | ||
| } | ||
| } | ||
|
|
||
| func fetchEntityChunks(entityId: String, limit: Int = 20) throws -> [KGChunkRow] { | ||
| guard let db else { throw DBError.notOpen } | ||
| let sql = """ | ||
| SELECT c.id, COALESCE(NULLIF(c.summary, ''), substr(c.content, 1, 200)) AS snippet, | ||
| c.importance, ec.relevance | ||
| FROM kg_entity_chunks ec | ||
| JOIN chunks c ON c.id = ec.chunk_id | ||
| WHERE ec.entity_id = ? | ||
| ORDER BY ec.relevance DESC | ||
| LIMIT ? | ||
| """ | ||
| var stmt: OpaquePointer? | ||
| guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { | ||
| throw DBError.prepare(sqlite3_errcode(db)) | ||
| } | ||
| defer { sqlite3_finalize(stmt) } | ||
| bindText(entityId, to: stmt, index: 1) | ||
| sqlite3_bind_int(stmt, 2, Int32(limit)) | ||
|
|
||
| var rows: [KGChunkRow] = [] | ||
| while sqlite3_step(stmt) == SQLITE_ROW { | ||
| rows.append(KGChunkRow( | ||
| chunkID: columnText(stmt, 0) ?? "", | ||
| snippet: columnText(stmt, 1) ?? "", | ||
| importance: Int(sqlite3_column_int(stmt, 2)), | ||
| relevance: sqlite3_column_double(stmt, 3) | ||
| )) | ||
| } | ||
| return rows | ||
| } | ||
|
|
||
| // MARK: - brain_recall | ||
|
|
||
| func recallSession(sessionId: String, limit: Int = 20) throws -> [[String: Any]] { | ||
|
|
@@ -1500,6 +1629,67 @@ final class BrainDatabase: @unchecked Sendable { | |
| return values | ||
| } | ||
|
|
||
| // MARK: - Injection events | ||
|
|
||
| @discardableResult | ||
| func recordInjectionEvent(sessionID: String, query: String, chunkIDs: [String], tokenCount: Int, timestamp: String? = nil) -> InjectionEvent { | ||
| guard let db else { | ||
| return InjectionEvent(id: 0, sessionID: sessionID, timestamp: "", query: query, chunkIDs: chunkIDs, tokenCount: tokenCount) | ||
| } | ||
| let chunkJSON = (try? JSONSerialization.data(withJSONObject: chunkIDs)).flatMap { String(data: $0, encoding: .utf8) } ?? "[]" | ||
| let ts = timestamp ?? Self.sqliteDateFormatter.string(from: Date()) | ||
| let sql = "INSERT INTO injection_events (session_id, timestamp, query, chunk_ids, token_count) VALUES (?, ?, ?, ?, ?)" | ||
| var stmt: OpaquePointer? | ||
| guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { | ||
| return InjectionEvent(id: 0, sessionID: sessionID, timestamp: ts, query: query, chunkIDs: chunkIDs, tokenCount: tokenCount) | ||
| } | ||
| defer { sqlite3_finalize(stmt) } | ||
| bindText(sessionID, to: stmt, index: 1) | ||
| bindText(ts, to: stmt, index: 2) | ||
| bindText(query, to: stmt, index: 3) | ||
| bindText(chunkJSON, to: stmt, index: 4) | ||
| sqlite3_bind_int(stmt, 5, Int32(tokenCount)) | ||
| sqlite3_step(stmt) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low In 🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
| let rowID = sqlite3_last_insert_rowid(db) | ||
| return InjectionEvent(id: rowID, sessionID: sessionID, timestamp: ts, query: query, chunkIDs: chunkIDs, tokenCount: tokenCount) | ||
| } | ||
|
|
||
| // MARK: - Injection event listing | ||
|
|
||
| func listInjectionEvents(sessionID: String? = nil, limit: Int = 20) throws -> [InjectionEvent] { | ||
| guard let db else { throw DBError.notOpen } | ||
| var sql = "SELECT id, session_id, timestamp, query, chunk_ids, token_count FROM injection_events" | ||
| if sessionID != nil { sql += " WHERE session_id = ?" } | ||
| sql += " ORDER BY timestamp DESC LIMIT ?" | ||
| var stmt: OpaquePointer? | ||
| guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { | ||
| throw DBError.prepare(sqlite3_errcode(db)) | ||
| } | ||
| defer { sqlite3_finalize(stmt) } | ||
| var idx: Int32 = 1 | ||
| if let sessionID { | ||
| bindText(sessionID, to: stmt, index: idx) | ||
| idx += 1 | ||
| } | ||
| sqlite3_bind_int(stmt, idx, Int32(limit)) | ||
|
|
||
| var events: [InjectionEvent] = [] | ||
| while sqlite3_step(stmt) == SQLITE_ROW { | ||
| let row: [String: Any] = [ | ||
| "id": Int64(sqlite3_column_int64(stmt, 0)), | ||
| "session_id": columnText(stmt, 1) as Any, | ||
| "timestamp": columnText(stmt, 2) as Any, | ||
| "query": columnText(stmt, 3) as Any, | ||
| "chunk_ids": columnText(stmt, 4) as Any, | ||
| "token_count": Int(sqlite3_column_int(stmt, 5)) | ||
| ] | ||
| if let event = try? InjectionEvent(row: row) { | ||
| events.append(event) | ||
| } | ||
| } | ||
| return events | ||
| } | ||
|
|
||
| // MARK: - brain_digest: rule-based entity extraction | ||
|
|
||
| func digest(content: String) throws -> [String: Any] { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| import SwiftUI | ||
|
|
||
| struct KGCanvasView: View { | ||
| @ObservedObject var viewModel: KGViewModel | ||
|
|
||
| @State private var offset: CGSize = .zero | ||
| @State private var scale: CGFloat = 1.0 | ||
| @State private var draggedNodeId: String? | ||
| @State private var timerActive = true | ||
|
|
||
| var body: some View { | ||
| HStack(spacing: 0) { | ||
| graphCanvas | ||
| if viewModel.selectedEntity != nil { | ||
| KGSidebarView( | ||
| entity: viewModel.selectedEntity, | ||
| chunks: viewModel.selectedEntityChunks, | ||
| onClose: { viewModel.selectNode(id: nil) } | ||
| ) | ||
| } | ||
| } | ||
| .onAppear { | ||
| viewModel.loadGraph() | ||
| startSimulation() | ||
| } | ||
|
Comment on lines
+22
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium
.onAppear {
viewModel.loadGraph()
+ timerActive = true
startSimulation()
}🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
| .onDisappear { timerActive = false } | ||
| } | ||
|
|
||
| private var graphCanvas: some View { | ||
| Canvas { context, size in | ||
| var ctx = context | ||
| // Apply pan + zoom transform | ||
| ctx.translateBy(x: offset.width + size.width / 2, y: offset.height + size.height / 2) | ||
| ctx.scaleBy(x: scale, y: scale) | ||
| ctx.translateBy(x: -size.width / 2, y: -size.height / 2) | ||
|
|
||
| let environment = EnvironmentValues() | ||
| let nodeIndex = Dictionary(uniqueKeysWithValues: viewModel.nodes.map { ($0.id, $0) }) | ||
|
|
||
| // Draw edges first (behind nodes) | ||
| for edge in viewModel.edges { | ||
| guard let src = nodeIndex[edge.sourceId], let tgt = nodeIndex[edge.targetId] else { continue } | ||
| let highlighted = viewModel.selectedNodeId == edge.sourceId || viewModel.selectedNodeId == edge.targetId | ||
| KGEdgeRenderer.draw( | ||
| edge: edge, sourcePos: src.position, targetPos: tgt.position, | ||
| isHighlighted: highlighted, in: &ctx, environment: environment | ||
| ) | ||
| } | ||
|
|
||
| // Draw nodes | ||
| for node in viewModel.nodes { | ||
| KGNodeRenderer.draw( | ||
| node: node, | ||
| isSelected: node.id == viewModel.selectedNodeId, | ||
| in: &ctx, environment: environment | ||
| ) | ||
| } | ||
| } | ||
| .background(Color.black.opacity(0.85)) | ||
| .gesture(tapGesture) | ||
| .gesture(dragGesture) | ||
| .gesture(magnifyGesture) | ||
| .overlay(alignment: .topLeading) { statsOverlay } | ||
| } | ||
|
|
||
| // MARK: - Gestures | ||
|
|
||
| private var tapGesture: some Gesture { | ||
| SpatialTapGesture() | ||
| .onEnded { value in | ||
| let point = canvasPoint(from: value.location, in: .zero) | ||
| if let node = viewModel.nodeAt(point: point) { | ||
| viewModel.selectNode(id: node.id) | ||
| } else { | ||
| viewModel.selectNode(id: nil) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private var dragGesture: some Gesture { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High The - private var dragGesture: some Gesture {
- DragGesture()
- .onChanged { value in
- offset = CGSize(
- width: value.translation.width,
- height: value.translation.height
- )
- }
- }🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
| DragGesture() | ||
| .onChanged { value in | ||
| offset = CGSize( | ||
| width: value.translation.width, | ||
| height: value.translation.height | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private var magnifyGesture: some Gesture { | ||
| MagnifyGesture() | ||
| .onChanged { value in | ||
| scale = max(0.2, min(3.0, value.magnification)) | ||
| } | ||
| } | ||
|
Comment on lines
+90
to
+95
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High The - private var magnifyGesture: some Gesture {
+ @State private var lastScale: CGFloat = 1.0
+
+ private var magnifyGesture: some Gesture {
MagnifyGesture()
+ .onChanged { value in
+ scale = max(0.2, min(3.0, lastScale * value.magnification))
+ }
+ .onEnded { _ in
+ lastScale = scale
+ }
+ }
+}
- .onChanged { value in
- scale = max(0.2, min(3.0, value.magnification))
- }
- }🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
|
|
||
| // MARK: - Coordinate transform | ||
|
|
||
| private func canvasPoint(from screenPoint: CGPoint, in size: CGSize) -> CGPoint { | ||
| CGPoint( | ||
| x: (screenPoint.x - offset.width) / scale, | ||
| y: (screenPoint.y - offset.height) / scale | ||
| ) | ||
| } | ||
|
Comment on lines
+99
to
+104
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High
- private func canvasPoint(from screenPoint: CGPoint, in size: CGSize) -> CGPoint {
- CGPoint(
- x: (screenPoint.x - offset.width) / scale,
- y: (screenPoint.y - offset.height) / scale
- )
- }🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
|
|
||
| // MARK: - Simulation timer | ||
|
|
||
| private func startSimulation() { | ||
| Task { @MainActor in | ||
| while timerActive { | ||
| try? await Task.sleep(for: .milliseconds(33)) // ~30fps | ||
| viewModel.tick() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Stats overlay | ||
|
|
||
| private var statsOverlay: some View { | ||
| Text("\(viewModel.nodes.count) nodes · \(viewModel.edges.count) edges") | ||
| .font(.caption2) | ||
| .foregroundColor(.white.opacity(0.6)) | ||
| .padding(6) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import Foundation | ||
|
|
||
| struct KGEdge: Identifiable, Equatable { | ||
| let sourceId: String | ||
| let targetId: String | ||
| let relationType: String | ||
|
|
||
| var id: String { "\(sourceId)-\(relationType)-\(targetId)" } | ||
|
Comment on lines
+3
to
+8
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low The computed - var id: String { "\(sourceId)-\(relationType)-\(targetId)" }
+ var id: String { "\(sourceId)\u{001F}\(relationType)\u{001F}\(targetId)" }🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟢 Low
BrainBar/BrainDatabase.swift:1475The
while sqlite3_step(stmt) == SQLITE_ROWloop exits on any non-SQLITE_ROWresult, including error codes likeSQLITE_BUSYorSQLITE_CORRUPT. This silently returns partial results when the database encounters an error mid-iteration. Consider checkingsqlite3_errcode(db)after the loop and throwing if it indicates an error rather thanSQLITE_DONE.while sqlite3_step(stmt) == SQLITE_ROW { rows.append(KGEntityRow( id: columnText(stmt, 0) ?? "", name: columnText(stmt, 1) ?? "", entityType: columnText(stmt, 2) ?? "", description: columnText(stmt, 3), importance: sqlite3_column_double(stmt, 4) )) } + let rc = sqlite3_errcode(db) + guard rc == SQLITE_DONE || rc == SQLITE_OK else { + throw DBError.query(rc) + } return rows🚀 Reply "fix it for me" or copy this AI Prompt for your agent: