knowledge-master 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- knowledge_master/__init__.py +0 -0
- knowledge_master/__main__.py +4 -0
- knowledge_master/chunking.py +106 -0
- knowledge_master/cli.py +344 -0
- knowledge_master/embeddings.py +21 -0
- knowledge_master/intelligence.py +254 -0
- knowledge_master/parsers/__init__.py +0 -0
- knowledge_master/parsers/git_repo.py +115 -0
- knowledge_master/parsers/markdown.py +58 -0
- knowledge_master/server.py +194 -0
- knowledge_master/store.py +164 -0
- knowledge_master/watcher.py +104 -0
- knowledge_master/web.py +568 -0
- knowledge_master-0.1.0.dist-info/METADATA +275 -0
- knowledge_master-0.1.0.dist-info/RECORD +19 -0
- knowledge_master-0.1.0.dist-info/WHEEL +5 -0
- knowledge_master-0.1.0.dist-info/entry_points.txt +3 -0
- knowledge_master-0.1.0.dist-info/licenses/LICENSE +21 -0
- knowledge_master-0.1.0.dist-info/top_level.txt +1 -0
knowledge_master/web.py
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""Web UI for Knowledge Master β FastAPI + htmx (no JS build step, works everywhere)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, Form
|
|
7
|
+
from fastapi.responses import HTMLResponse
|
|
8
|
+
|
|
9
|
+
from . import embeddings, store
|
|
10
|
+
from .parsers import git_repo, markdown
|
|
11
|
+
|
|
12
|
+
HTML_TEMPLATE = """<!DOCTYPE html>
|
|
13
|
+
<html lang="en">
|
|
14
|
+
<head>
|
|
15
|
+
<meta charset="UTF-8">
|
|
16
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
17
|
+
<title>Knowledge Master</title>
|
|
18
|
+
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
|
19
|
+
<style>
|
|
20
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
21
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0d1117; color: #e6edf3; padding: 2rem; max-width: 1200px; margin: 0 auto; }
|
|
22
|
+
h1 { color: #58a6ff; margin-bottom: 1rem; }
|
|
23
|
+
h2 { color: #8b949e; margin: 1.5rem 0 0.5rem; font-size: 1.1rem; }
|
|
24
|
+
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1.2rem; margin-bottom: 1rem; }
|
|
25
|
+
input[type=text], input[type=number] { background: #0d1117; border: 1px solid #30363d; color: #e6edf3; padding: 0.6rem 1rem; border-radius: 6px; width: 100%; font-size: 1rem; }
|
|
26
|
+
input:focus { outline: none; border-color: #58a6ff; }
|
|
27
|
+
button, .btn { background: #238636; color: #fff; border: none; padding: 0.6rem 1.2rem; border-radius: 6px; cursor: pointer; font-size: 0.9rem; }
|
|
28
|
+
button:hover { background: #2ea043; }
|
|
29
|
+
.btn-danger { background: #da3633; }
|
|
30
|
+
.btn-danger:hover { background: #f85149; }
|
|
31
|
+
.result { border-left: 3px solid #58a6ff; padding: 0.8rem 1rem; margin: 0.5rem 0; background: #0d1117; border-radius: 4px; }
|
|
32
|
+
.result .score { color: #58a6ff; font-weight: bold; }
|
|
33
|
+
.result .source { color: #8b949e; font-size: 0.85rem; }
|
|
34
|
+
.result .text { margin-top: 0.3rem; font-size: 0.9rem; white-space: pre-wrap; }
|
|
35
|
+
.stat { display: inline-block; background: #21262d; padding: 0.4rem 0.8rem; border-radius: 4px; margin: 0.2rem; }
|
|
36
|
+
.flex { display: flex; gap: 0.5rem; align-items: center; }
|
|
37
|
+
.source-item { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0; border-bottom: 1px solid #21262d; }
|
|
38
|
+
.msg { padding: 0.6rem 1rem; border-radius: 6px; margin: 0.5rem 0; }
|
|
39
|
+
.msg-ok { background: #1b4332; color: #95d5b2; }
|
|
40
|
+
.msg-err { background: #3d1f1f; color: #fca5a5; }
|
|
41
|
+
.tabs { display: flex; gap: 0; margin-bottom: 1.5rem; }
|
|
42
|
+
.tab { padding: 0.7rem 1.5rem; background: #21262d; border: 1px solid #30363d; cursor: pointer; color: #8b949e; }
|
|
43
|
+
.tab:first-child { border-radius: 6px 0 0 6px; }
|
|
44
|
+
.tab:last-child { border-radius: 0 6px 6px 0; }
|
|
45
|
+
.tab.active, .tab:target { background: #161b22; color: #58a6ff; border-color: #58a6ff; }
|
|
46
|
+
#results, #index-result, #sources { min-height: 50px; }
|
|
47
|
+
.progress-bar { width: 100%; height: 24px; background: #21262d; border-radius: 4px; overflow: hidden; margin: 0.5rem 0; }
|
|
48
|
+
.progress-fill { height: 100%; background: #238636; transition: width 0.3s; display: flex; align-items: center; padding-left: 0.5rem; font-size: 0.75rem; color: #fff; }
|
|
49
|
+
.browse-row { padding: 0.4rem 1.2rem; cursor: pointer; color: #e6edf3; display: flex; align-items: center; gap: 0.5rem; }
|
|
50
|
+
.browse-row:hover { background: #21262d; }
|
|
51
|
+
.git-badge { background: #238636; color: #fff; font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 3px; margin-left: 0.5rem; }
|
|
52
|
+
.htmx-indicator { display: none; }
|
|
53
|
+
.htmx-request .htmx-indicator { display: inline; }
|
|
54
|
+
</style>
|
|
55
|
+
</head>
|
|
56
|
+
<body>
|
|
57
|
+
<h1>β‘ Knowledge Master</h1>
|
|
58
|
+
<div style="margin-bottom:1rem"><a href="/graph" class="btn" style="background:#238636;color:#fff;text-decoration:none;padding:0.5rem 1rem;border-radius:6px;">πΈοΈ View Knowledge Graph</a></div>
|
|
59
|
+
<div id="stats" hx-get="/api/stats" hx-trigger="load" hx-swap="innerHTML"></div>
|
|
60
|
+
|
|
61
|
+
<h2>π Search</h2>
|
|
62
|
+
<div class="card">
|
|
63
|
+
<form class="flex" hx-post="/api/search" hx-target="#results" hx-swap="innerHTML">
|
|
64
|
+
<input type="text" name="query" placeholder="Search your knowledge base..." required style="flex:1">
|
|
65
|
+
<input type="number" name="top_k" value="10" style="width:80px" min="1" max="50">
|
|
66
|
+
<button type="submit">Search</button>
|
|
67
|
+
</form>
|
|
68
|
+
<div id="results" style="margin-top:1rem"></div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<h2>π₯ Index New Source</h2>
|
|
72
|
+
<div class="card">
|
|
73
|
+
<form class="flex" id="index-form" onsubmit="startIndex(event)">
|
|
74
|
+
<input type="text" name="path" id="path-input" placeholder="Path to git repo or docs directory" required style="flex:1">
|
|
75
|
+
<select name="type" id="type-input" style="background:#0d1117;border:1px solid #30363d;color:#e6edf3;padding:0.6rem;border-radius:6px;">
|
|
76
|
+
<option value="auto">Auto-detect</option>
|
|
77
|
+
<option value="repo">Git Repo</option>
|
|
78
|
+
<option value="docs">Documents</option>
|
|
79
|
+
</select>
|
|
80
|
+
<button type="submit" id="index-btn">Index</button>
|
|
81
|
+
<button type="button" onclick="openBrowser()" style="background:#58a6ff;font-size:1.2rem;padding:0.6rem 0.9rem;">+</button>
|
|
82
|
+
</form>
|
|
83
|
+
<div id="index-result" style="margin-top:0.5rem"></div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<!-- File Browser Modal -->
|
|
87
|
+
<div id="browser-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:100;align-items:center;justify-content:center;">
|
|
88
|
+
<div style="background:#161b22;border:1px solid #30363d;border-radius:12px;width:600px;max-height:80vh;display:flex;flex-direction:column;">
|
|
89
|
+
<div style="padding:1rem 1.2rem;border-bottom:1px solid #30363d;display:flex;justify-content:space-between;align-items:center;">
|
|
90
|
+
<h3 style="color:#e6edf3;font-size:1rem;">Browse Files</h3>
|
|
91
|
+
<button onclick="closeBrowser()" style="background:none;border:none;color:#8b949e;font-size:1.2rem;cursor:pointer;">β</button>
|
|
92
|
+
</div>
|
|
93
|
+
<div id="browser-path" style="padding:0.5rem 1.2rem;color:#58a6ff;font-size:0.85rem;font-family:monospace;border-bottom:1px solid #21262d;"></div>
|
|
94
|
+
<div id="browser-list" style="overflow-y:auto;flex:1;padding:0.5rem 0;"></div>
|
|
95
|
+
<div style="padding:1rem 1.2rem;border-top:1px solid #30363d;display:flex;gap:0.5rem;justify-content:flex-end;">
|
|
96
|
+
<button onclick="selectCurrent()" style="background:#238636;color:#fff;border:none;padding:0.5rem 1.2rem;border-radius:6px;cursor:pointer;">Select This Folder</button>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<script>
|
|
102
|
+
let currentBrowsePath = '';
|
|
103
|
+
function openBrowser() {
|
|
104
|
+
document.getElementById('browser-modal').style.display = 'flex';
|
|
105
|
+
browseTo('~');
|
|
106
|
+
}
|
|
107
|
+
function closeBrowser() { document.getElementById('browser-modal').style.display = 'none'; }
|
|
108
|
+
function selectCurrent() {
|
|
109
|
+
document.getElementById('path-input').value = currentBrowsePath;
|
|
110
|
+
closeBrowser();
|
|
111
|
+
}
|
|
112
|
+
function selectPath(p) {
|
|
113
|
+
document.getElementById('path-input').value = p;
|
|
114
|
+
closeBrowser();
|
|
115
|
+
}
|
|
116
|
+
async function browseTo(path) {
|
|
117
|
+
const res = await fetch('/api/browse?path=' + encodeURIComponent(path));
|
|
118
|
+
const data = await res.json();
|
|
119
|
+
currentBrowsePath = data.current;
|
|
120
|
+
document.getElementById('browser-path').textContent = data.current;
|
|
121
|
+
const list = document.getElementById('browser-list');
|
|
122
|
+
list.innerHTML = '';
|
|
123
|
+
if (data.parent) {
|
|
124
|
+
const row = document.createElement('div');
|
|
125
|
+
row.className = 'browse-row';
|
|
126
|
+
row.textContent = 'β¬ ..';
|
|
127
|
+
row.onclick = function() { browseTo(data.parent); };
|
|
128
|
+
list.appendChild(row);
|
|
129
|
+
}
|
|
130
|
+
data.items.forEach(function(item) {
|
|
131
|
+
const row = document.createElement('div');
|
|
132
|
+
row.className = 'browse-row';
|
|
133
|
+
const icon = item.is_git ? 'π¦' : item.is_dir ? 'π' : 'π';
|
|
134
|
+
row.innerHTML = icon + ' ' + item.name + (item.is_git ? ' <span class="git-badge">git</span>' : '');
|
|
135
|
+
row.onclick = function() {
|
|
136
|
+
if (item.is_dir) { browseTo(item.path); }
|
|
137
|
+
else { selectPath(item.path); }
|
|
138
|
+
};
|
|
139
|
+
list.appendChild(row);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function startIndex(e) {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
var path = document.getElementById('path-input').value;
|
|
145
|
+
var type = document.getElementById('type-input').value;
|
|
146
|
+
var btn = document.getElementById('index-btn');
|
|
147
|
+
var result = document.getElementById('index-result');
|
|
148
|
+
btn.disabled = true;
|
|
149
|
+
btn.textContent = 'Indexing...';
|
|
150
|
+
result.innerHTML = '<div class="progress-bar"><div class="progress-fill" id="pbar" style="width:0%">0%</div></div><div id="pfile" style="font-size:0.8rem;color:#8b949e;margin-top:0.3rem;"></div>';
|
|
151
|
+
var es = new EventSource('/api/index_stream?path=' + encodeURIComponent(path) + '&type=' + encodeURIComponent(type));
|
|
152
|
+
es.onmessage = function(ev) {
|
|
153
|
+
var d = JSON.parse(ev.data);
|
|
154
|
+
if (d.done) {
|
|
155
|
+
es.close();
|
|
156
|
+
btn.disabled = false;
|
|
157
|
+
btn.textContent = 'Index';
|
|
158
|
+
result.innerHTML = '<div class="msg msg-ok">' + d.message + '</div>';
|
|
159
|
+
htmx.ajax('GET', '/api/sources', {target:'#sources'});
|
|
160
|
+
htmx.ajax('GET', '/api/stats', {target:'#stats'});
|
|
161
|
+
} else if (d.error) {
|
|
162
|
+
es.close();
|
|
163
|
+
btn.disabled = false;
|
|
164
|
+
btn.textContent = 'Index';
|
|
165
|
+
result.innerHTML = '<div class="msg msg-err">' + d.error + '</div>';
|
|
166
|
+
} else {
|
|
167
|
+
var pct = Math.round(d.current / d.total * 100);
|
|
168
|
+
document.getElementById('pbar').style.width = pct + '%';
|
|
169
|
+
document.getElementById('pbar').textContent = pct + '% (' + d.current + '/' + d.total + ')';
|
|
170
|
+
document.getElementById('pfile').textContent = d.file;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
es.onerror = function() { es.close(); btn.disabled = false; btn.textContent = 'Index'; };
|
|
174
|
+
}
|
|
175
|
+
</script>
|
|
176
|
+
|
|
177
|
+
<h2>π Indexed Sources</h2>
|
|
178
|
+
<div class="card">
|
|
179
|
+
<div id="sources" hx-get="/api/sources" hx-trigger="load" hx-swap="innerHTML"></div>
|
|
180
|
+
</div>
|
|
181
|
+
</body>
|
|
182
|
+
</html>"""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
GRAPH_PAGE = """<!DOCTYPE html>
|
|
186
|
+
<html lang="en">
|
|
187
|
+
<head>
|
|
188
|
+
<meta charset="UTF-8">
|
|
189
|
+
<title>Knowledge Graph</title>
|
|
190
|
+
<script src="https://unpkg.com/d3@7.9.0/dist/d3.min.js"></script>
|
|
191
|
+
<style>
|
|
192
|
+
* { margin: 0; padding: 0; }
|
|
193
|
+
body { background: #0d1117; overflow: hidden; font-family: -apple-system, sans-serif; }
|
|
194
|
+
svg { width: 100vw; height: 100vh; }
|
|
195
|
+
.controls { position: fixed; top: 1rem; left: 1rem; z-index: 10; display: flex; gap: 0.5rem; }
|
|
196
|
+
.controls button, .controls a { background: #21262d; color: #e6edf3; border: 1px solid #30363d; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; text-decoration: none; font-size: 0.85rem; }
|
|
197
|
+
.controls button:hover, .controls a:hover { background: #30363d; }
|
|
198
|
+
.tooltip { position: fixed; background: #161b22; border: 1px solid #30363d; color: #e6edf3; padding: 0.6rem 1rem; border-radius: 6px; font-size: 0.8rem; pointer-events: none; display: none; max-width: 400px; white-space: pre-wrap; }
|
|
199
|
+
</style>
|
|
200
|
+
</head>
|
|
201
|
+
<body>
|
|
202
|
+
<div class="controls">
|
|
203
|
+
<a href="/">β Back</a>
|
|
204
|
+
<button onclick="resetZoom()">Reset Zoom</button>
|
|
205
|
+
</div>
|
|
206
|
+
<div class="tooltip" id="tooltip"></div>
|
|
207
|
+
<svg></svg>
|
|
208
|
+
<script>
|
|
209
|
+
const colors = { Repo: '#58a6ff', Document: '#7ee787', Chunk: '#484f58', Person: '#d2a8ff', File: '#ffa657', Tech: '#f97583', Service: '#ffa657', Convention: '#d2a8ff' };
|
|
210
|
+
const sizes = { Repo: 16, Person: 14, Document: 8, Chunk: 4, File: 6, Tech: 12, Service: 14, Convention: 10 };
|
|
211
|
+
|
|
212
|
+
let simulation, svg, g, zoom;
|
|
213
|
+
|
|
214
|
+
async function loadGraph() {
|
|
215
|
+
const res = await fetch('/api/graph');
|
|
216
|
+
const data = await res.json();
|
|
217
|
+
|
|
218
|
+
svg = d3.select('svg');
|
|
219
|
+
const width = window.innerWidth, height = window.innerHeight;
|
|
220
|
+
|
|
221
|
+
zoom = d3.zoom().scaleExtent([0.1, 8]).on('zoom', e => g.attr('transform', e.transform));
|
|
222
|
+
svg.call(zoom);
|
|
223
|
+
g = svg.append('g');
|
|
224
|
+
|
|
225
|
+
simulation = d3.forceSimulation(data.nodes)
|
|
226
|
+
.force('link', d3.forceLink(data.links).id(d => d.id).distance(60))
|
|
227
|
+
.force('charge', d3.forceManyBody().strength(-120))
|
|
228
|
+
.force('center', d3.forceCenter(width/2, height/2))
|
|
229
|
+
.force('collision', d3.forceCollide().radius(d => sizes[d.type] + 2));
|
|
230
|
+
|
|
231
|
+
const link = g.selectAll('line').data(data.links).join('line')
|
|
232
|
+
.attr('stroke', '#30363d').attr('stroke-width', 1).attr('stroke-opacity', 0.6);
|
|
233
|
+
|
|
234
|
+
const linkLabel = g.selectAll('.link-label').data(data.links).join('text')
|
|
235
|
+
.attr('class', 'link-label').attr('fill', '#484f58').attr('font-size', '7px')
|
|
236
|
+
.attr('text-anchor', 'middle').text(d => d.type);
|
|
237
|
+
|
|
238
|
+
const node = g.selectAll('circle').data(data.nodes).join('circle')
|
|
239
|
+
.attr('r', d => sizes[d.type] || 6)
|
|
240
|
+
.attr('fill', d => colors[d.type] || '#8b949e')
|
|
241
|
+
.attr('stroke', '#0d1117').attr('stroke-width', 1.5)
|
|
242
|
+
.call(d3.drag().on('start', dragstart).on('drag', dragged).on('end', dragend));
|
|
243
|
+
|
|
244
|
+
const label = g.selectAll('.label').data(data.nodes.filter(d => d.type !== 'Chunk')).join('text')
|
|
245
|
+
.attr('class', 'label').attr('fill', '#8b949e').attr('font-size', '9px')
|
|
246
|
+
.attr('dx', d => sizes[d.type] + 4).attr('dy', 3).text(d => d.label);
|
|
247
|
+
|
|
248
|
+
const tooltip = document.getElementById('tooltip');
|
|
249
|
+
node.on('mouseover', (e, d) => {
|
|
250
|
+
tooltip.style.display = 'block';
|
|
251
|
+
tooltip.style.left = e.clientX + 12 + 'px';
|
|
252
|
+
tooltip.style.top = e.clientY + 12 + 'px';
|
|
253
|
+
let info = `[${d.type}] ${d.label}`;
|
|
254
|
+
if (d.text) info += '\\n\\n' + d.text.slice(0, 200);
|
|
255
|
+
tooltip.textContent = info;
|
|
256
|
+
}).on('mouseout', () => tooltip.style.display = 'none');
|
|
257
|
+
|
|
258
|
+
simulation.on('tick', () => {
|
|
259
|
+
link.attr('x1', d=>d.source.x).attr('y1', d=>d.source.y).attr('x2', d=>d.target.x).attr('y2', d=>d.target.y);
|
|
260
|
+
linkLabel.attr('x', d=>(d.source.x+d.target.x)/2).attr('y', d=>(d.source.y+d.target.y)/2);
|
|
261
|
+
node.attr('cx', d=>d.x).attr('cy', d=>d.y);
|
|
262
|
+
label.attr('x', d=>d.x).attr('y', d=>d.y);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function dragstart(e,d) { if(!e.active) simulation.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; }
|
|
267
|
+
function dragged(e,d) { d.fx=e.x; d.fy=e.y; }
|
|
268
|
+
function dragend(e,d) { if(!e.active) simulation.alphaTarget(0); d.fx=null; d.fy=null; }
|
|
269
|
+
function resetZoom() { svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity); }
|
|
270
|
+
|
|
271
|
+
loadGraph();
|
|
272
|
+
</script>
|
|
273
|
+
</body>
|
|
274
|
+
</html>"""
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def create_app() -> FastAPI:
|
|
278
|
+
app = FastAPI(title="Knowledge Master")
|
|
279
|
+
|
|
280
|
+
@app.get("/", response_class=HTMLResponse)
|
|
281
|
+
async def home():
|
|
282
|
+
return HTML_TEMPLATE
|
|
283
|
+
|
|
284
|
+
@app.get("/graph", response_class=HTMLResponse)
|
|
285
|
+
async def graph_page():
|
|
286
|
+
return GRAPH_PAGE
|
|
287
|
+
|
|
288
|
+
@app.get("/api/graph")
|
|
289
|
+
async def graph_data():
|
|
290
|
+
graph = store.get_graph()
|
|
291
|
+
nodes = []
|
|
292
|
+
links = []
|
|
293
|
+
seen = set()
|
|
294
|
+
|
|
295
|
+
# Get repos
|
|
296
|
+
result = graph.query("MATCH (r:Repo) RETURN id(r), r.name, r.path")
|
|
297
|
+
for nid, name, path in (result.result_set or []):
|
|
298
|
+
nodes.append({"id": f"repo_{nid}", "type": "Repo", "label": name or path or "repo"})
|
|
299
|
+
seen.add(f"repo_{nid}")
|
|
300
|
+
|
|
301
|
+
# Get people
|
|
302
|
+
result = graph.query("MATCH (p:Person) RETURN id(p), p.name, p.email")
|
|
303
|
+
for nid, name, email in (result.result_set or []):
|
|
304
|
+
nodes.append({"id": f"person_{nid}", "type": "Person", "label": name or email})
|
|
305
|
+
seen.add(f"person_{nid}")
|
|
306
|
+
|
|
307
|
+
# Get documents
|
|
308
|
+
result = graph.query("MATCH (d:Document) RETURN id(d), d.path, d.type")
|
|
309
|
+
for nid, path, dtype in (result.result_set or []):
|
|
310
|
+
label = path.split("/")[-1] if path else "doc"
|
|
311
|
+
nodes.append({"id": f"doc_{nid}", "type": "Document", "label": label})
|
|
312
|
+
seen.add(f"doc_{nid}")
|
|
313
|
+
|
|
314
|
+
# Get techs
|
|
315
|
+
result = graph.query("MATCH (t:Tech) RETURN id(t), t.name, t.category")
|
|
316
|
+
for nid, name, cat in (result.result_set or []):
|
|
317
|
+
nodes.append({"id": f"tech_{nid}", "type": "Tech", "label": name, "category": cat or ""})
|
|
318
|
+
seen.add(f"tech_{nid}")
|
|
319
|
+
|
|
320
|
+
# Get services
|
|
321
|
+
result = graph.query("MATCH (s:Service) RETURN id(s), s.name, s.source")
|
|
322
|
+
for nid, name, source in (result.result_set or []):
|
|
323
|
+
nodes.append({"id": f"svc_{nid}", "type": "Service", "label": name, "source": source or ""})
|
|
324
|
+
seen.add(f"svc_{nid}")
|
|
325
|
+
|
|
326
|
+
# Get conventions
|
|
327
|
+
result = graph.query("MATCH (c:Convention) RETURN id(c), c.name, c.category")
|
|
328
|
+
for nid, name, cat in (result.result_set or []):
|
|
329
|
+
nodes.append({"id": f"conv_{nid}", "type": "Convention", "label": name, "category": cat or ""})
|
|
330
|
+
seen.add(f"conv_{nid}")
|
|
331
|
+
|
|
332
|
+
# Get chunks (limit)
|
|
333
|
+
result = graph.query("MATCH (c:Chunk) RETURN id(c), c.source, c.text LIMIT 50")
|
|
334
|
+
for nid, source, text in (result.result_set or []):
|
|
335
|
+
nodes.append({"id": f"chunk_{nid}", "type": "Chunk", "label": "", "text": text or ""})
|
|
336
|
+
seen.add(f"chunk_{nid}")
|
|
337
|
+
|
|
338
|
+
# Edges: Document -> Repo
|
|
339
|
+
result = graph.query("MATCH (d:Document)-[:IN_REPO]->(r:Repo) RETURN id(d), id(r)")
|
|
340
|
+
for did, rid in (result.result_set or []):
|
|
341
|
+
links.append({"source": f"doc_{did}", "target": f"repo_{rid}", "type": "IN_REPO"})
|
|
342
|
+
|
|
343
|
+
# Edges: Chunk -> Document
|
|
344
|
+
result = graph.query("MATCH (c:Chunk)-[:PART_OF]->(d:Document) RETURN id(c), id(d) LIMIT 100")
|
|
345
|
+
for cid, did in (result.result_set or []):
|
|
346
|
+
if f"chunk_{cid}" in seen and f"doc_{did}" in seen:
|
|
347
|
+
links.append({"source": f"chunk_{cid}", "target": f"doc_{did}", "type": "PART_OF"})
|
|
348
|
+
|
|
349
|
+
# Edges: Person -> Document
|
|
350
|
+
result = graph.query("MATCH (p:Person)-[:AUTHORED]->(d:Document) RETURN id(p), id(d)")
|
|
351
|
+
for pid, did in (result.result_set or []):
|
|
352
|
+
links.append({"source": f"person_{pid}", "target": f"doc_{did}", "type": "AUTHORED"})
|
|
353
|
+
|
|
354
|
+
# Edges: Repo -> Tech
|
|
355
|
+
result = graph.query("MATCH (r:Repo)-[:USES_TECH]->(t:Tech) RETURN id(r), id(t)")
|
|
356
|
+
for rid, tid in (result.result_set or []):
|
|
357
|
+
links.append({"source": f"repo_{rid}", "target": f"tech_{tid}", "type": "USES_TECH"})
|
|
358
|
+
|
|
359
|
+
# Edges: Repo -> Service
|
|
360
|
+
result = graph.query("MATCH (r:Repo)-[:DEFINES_SERVICE]->(s:Service) RETURN id(r), id(s)")
|
|
361
|
+
for rid, sid in (result.result_set or []):
|
|
362
|
+
links.append({"source": f"repo_{rid}", "target": f"svc_{sid}", "type": "DEFINES_SERVICE"})
|
|
363
|
+
|
|
364
|
+
# Edges: Service -> Service (depends_on)
|
|
365
|
+
result = graph.query("MATCH (a:Service)-[:DEPENDS_ON]->(b:Service) RETURN id(a), id(b)")
|
|
366
|
+
for aid, bid in (result.result_set or []):
|
|
367
|
+
links.append({"source": f"svc_{aid}", "target": f"svc_{bid}", "type": "DEPENDS_ON"})
|
|
368
|
+
|
|
369
|
+
# Edges: Repo -> Convention
|
|
370
|
+
result = graph.query("MATCH (r:Repo)-[:FOLLOWS]->(c:Convention) RETURN id(r), id(c)")
|
|
371
|
+
for rid, cid in (result.result_set or []):
|
|
372
|
+
links.append({"source": f"repo_{rid}", "target": f"conv_{cid}", "type": "FOLLOWS"})
|
|
373
|
+
|
|
374
|
+
return {"nodes": nodes, "links": links}
|
|
375
|
+
|
|
376
|
+
@app.get("/api/browse")
|
|
377
|
+
async def browse(path: str = "~"):
|
|
378
|
+
"""Browse local filesystem for selecting folders to index."""
|
|
379
|
+
target = Path(path).expanduser().resolve()
|
|
380
|
+
if not target.exists() or not target.is_dir():
|
|
381
|
+
target = Path.home()
|
|
382
|
+
|
|
383
|
+
items = []
|
|
384
|
+
try:
|
|
385
|
+
for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
|
|
386
|
+
if entry.name.startswith(".") and entry.name != ".git":
|
|
387
|
+
continue
|
|
388
|
+
if entry.name in ("node_modules", "__pycache__", "venv", ".venv", "target", "dist", "build"):
|
|
389
|
+
continue
|
|
390
|
+
if entry.is_dir():
|
|
391
|
+
is_git = (entry / ".git").exists()
|
|
392
|
+
items.append({"name": entry.name, "path": str(entry), "is_dir": True, "is_git": is_git})
|
|
393
|
+
elif entry.suffix in (".md", ".txt", ".pdf", ".docx", ".xlsx"):
|
|
394
|
+
items.append({"name": entry.name, "path": str(entry), "is_dir": False, "is_git": False})
|
|
395
|
+
except PermissionError:
|
|
396
|
+
pass
|
|
397
|
+
|
|
398
|
+
parent = str(target.parent) if target != target.parent else None
|
|
399
|
+
return {"current": str(target), "parent": parent, "items": items[:100]}
|
|
400
|
+
|
|
401
|
+
@app.get("/api/stats", response_class=HTMLResponse)
|
|
402
|
+
async def stats():
|
|
403
|
+
graph = store.get_graph()
|
|
404
|
+
s = store.get_stats(graph)
|
|
405
|
+
return f"""<div>
|
|
406
|
+
<span class="stat">π {s['chunks']} chunks</span>
|
|
407
|
+
<span class="stat">π {s['documents']} documents</span>
|
|
408
|
+
<span class="stat">π¦ {s['repos']} repos</span>
|
|
409
|
+
</div>"""
|
|
410
|
+
|
|
411
|
+
@app.post("/api/search", response_class=HTMLResponse)
|
|
412
|
+
async def search(query: str = Form(...), top_k: int = Form(10)):
|
|
413
|
+
graph = store.get_graph()
|
|
414
|
+
vec = embeddings.embed(query)
|
|
415
|
+
results = store.graph_context_search(graph, vec, top_k)
|
|
416
|
+
if not results:
|
|
417
|
+
return '<div class="msg msg-err">No results found.</div>'
|
|
418
|
+
html = ""
|
|
419
|
+
for r in results:
|
|
420
|
+
ctx = ""
|
|
421
|
+
if r.get("repo"):
|
|
422
|
+
ctx += f" Β· repo:{r['repo']}"
|
|
423
|
+
if r.get("author"):
|
|
424
|
+
ctx += f" Β· by:{r['author']}"
|
|
425
|
+
text = (r.get("text") or "")[:300]
|
|
426
|
+
html += f"""<div class="result">
|
|
427
|
+
<span class="score">{r.get('score',0):.3f}</span>
|
|
428
|
+
<span class="source">{r.get('source','')}{ctx}</span>
|
|
429
|
+
<div class="text">{text}</div>
|
|
430
|
+
</div>"""
|
|
431
|
+
return html
|
|
432
|
+
|
|
433
|
+
@app.post("/api/index", response_class=HTMLResponse)
|
|
434
|
+
async def index_source(path: str = Form(...), type: str = Form("auto")):
|
|
435
|
+
path = str(Path(path).expanduser().resolve())
|
|
436
|
+
if not Path(path).exists():
|
|
437
|
+
return f'<div class="msg msg-err">Path not found: {path}</div>'
|
|
438
|
+
|
|
439
|
+
graph = store.get_graph()
|
|
440
|
+
store.init_schema(graph)
|
|
441
|
+
|
|
442
|
+
if type == "auto":
|
|
443
|
+
type = "repo" if (Path(path) / ".git").exists() else "docs"
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
if type == "repo":
|
|
447
|
+
result = git_repo.index_repo(path, graph)
|
|
448
|
+
else:
|
|
449
|
+
result = markdown.index_directory(path, graph)
|
|
450
|
+
return f'<div class="msg msg-ok">β Indexed: {json.dumps(result)}</div>'
|
|
451
|
+
except Exception as e:
|
|
452
|
+
return f'<div class="msg msg-err">Error: {e}</div>'
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@app.get("/api/index_stream")
|
|
456
|
+
async def index_stream(path: str, type: str = "auto"):
|
|
457
|
+
"""SSE endpoint for indexing with progress."""
|
|
458
|
+
from starlette.responses import StreamingResponse
|
|
459
|
+
import queue
|
|
460
|
+
import threading
|
|
461
|
+
|
|
462
|
+
path = str(Path(path).expanduser().resolve())
|
|
463
|
+
|
|
464
|
+
def generate():
|
|
465
|
+
if not Path(path).exists():
|
|
466
|
+
yield f"data: {json.dumps({'error': 'Path not found: ' + path})}\n\n"
|
|
467
|
+
return
|
|
468
|
+
|
|
469
|
+
graph = store.get_graph()
|
|
470
|
+
store.init_schema(graph)
|
|
471
|
+
resolved_type = type
|
|
472
|
+
if resolved_type == "auto":
|
|
473
|
+
resolved_type = "repo" if (Path(path) / ".git").exists() else "docs"
|
|
474
|
+
|
|
475
|
+
q = queue.Queue()
|
|
476
|
+
|
|
477
|
+
def progress_cb(current, total, filepath):
|
|
478
|
+
q.put({"current": current, "total": total, "file": filepath})
|
|
479
|
+
|
|
480
|
+
def run_index():
|
|
481
|
+
try:
|
|
482
|
+
if resolved_type == "repo":
|
|
483
|
+
result = git_repo.index_repo(path, graph, on_progress=progress_cb)
|
|
484
|
+
else:
|
|
485
|
+
result = markdown.index_directory(path, graph)
|
|
486
|
+
result = result or {}
|
|
487
|
+
q.put({"done": True, "message": "Indexed " + str(result.get('files_indexed', 0)) + " files"})
|
|
488
|
+
except Exception as e:
|
|
489
|
+
q.put({"error": str(e)})
|
|
490
|
+
|
|
491
|
+
t = threading.Thread(target=run_index)
|
|
492
|
+
t.start()
|
|
493
|
+
|
|
494
|
+
while True:
|
|
495
|
+
try:
|
|
496
|
+
msg = q.get(timeout=30)
|
|
497
|
+
yield f"data: {json.dumps(msg)}\n\n"
|
|
498
|
+
if msg.get("done") or msg.get("error"):
|
|
499
|
+
break
|
|
500
|
+
except queue.Empty:
|
|
501
|
+
yield f"data: {json.dumps({'error': 'Timeout'})}\n\n"
|
|
502
|
+
break
|
|
503
|
+
|
|
504
|
+
t.join(timeout=5)
|
|
505
|
+
|
|
506
|
+
return StreamingResponse(generate(), media_type="text/event-stream")
|
|
507
|
+
|
|
508
|
+
@app.get("/api/sources", response_class=HTMLResponse)
|
|
509
|
+
async def sources():
|
|
510
|
+
graph = store.get_graph()
|
|
511
|
+
# Get repos
|
|
512
|
+
repos = graph.query("MATCH (r:Repo) RETURN r.name, r.path")
|
|
513
|
+
# Get standalone docs
|
|
514
|
+
docs = graph.query(
|
|
515
|
+
"MATCH (d:Document) WHERE NOT (d)-[:IN_REPO]->() RETURN d.path, d.type LIMIT 50"
|
|
516
|
+
)
|
|
517
|
+
html = ""
|
|
518
|
+
for name, path in (repos.result_set or []):
|
|
519
|
+
html += f"""<div class="source-item">
|
|
520
|
+
<span>π¦ <b>{name or path}</b> <span class="source">{path}</span></span>
|
|
521
|
+
<button class="btn-danger" hx-delete="/api/source?name={name}" hx-target="#sources" hx-swap="innerHTML" hx-confirm="Delete {name} and all its chunks?">Remove</button>
|
|
522
|
+
</div>"""
|
|
523
|
+
for dpath, dtype in (docs.result_set or []):
|
|
524
|
+
html += f"""<div class="source-item">
|
|
525
|
+
<span>π {dpath} <span class="source">({dtype})</span></span>
|
|
526
|
+
<button class="btn-danger" hx-delete="/api/source?path={dpath}" hx-target="#sources" hx-swap="innerHTML" hx-confirm="Delete {dpath}?">Remove</button>
|
|
527
|
+
</div>"""
|
|
528
|
+
if not html:
|
|
529
|
+
html = '<div class="msg">No sources indexed yet. Add one above.</div>'
|
|
530
|
+
return html
|
|
531
|
+
|
|
532
|
+
@app.delete("/api/source", response_class=HTMLResponse)
|
|
533
|
+
async def delete_source(name: str = None, path: str = None):
|
|
534
|
+
graph = store.get_graph()
|
|
535
|
+
if name:
|
|
536
|
+
graph.query(
|
|
537
|
+
"""MATCH (r:Repo {name: $name})
|
|
538
|
+
OPTIONAL MATCH (d:Document)-[:IN_REPO]->(r)
|
|
539
|
+
OPTIONAL MATCH (c:Chunk)-[:PART_OF]->(d)
|
|
540
|
+
DELETE c, d, r""",
|
|
541
|
+
params={"name": name},
|
|
542
|
+
)
|
|
543
|
+
elif path:
|
|
544
|
+
graph.query(
|
|
545
|
+
"""MATCH (d:Document {path: $path})
|
|
546
|
+
OPTIONAL MATCH (c:Chunk)-[:PART_OF]->(d)
|
|
547
|
+
DELETE c, d""",
|
|
548
|
+
params={"path": path},
|
|
549
|
+
)
|
|
550
|
+
# Return updated sources list
|
|
551
|
+
repos = graph.query("MATCH (r:Repo) RETURN r.name, r.path")
|
|
552
|
+
docs = graph.query(
|
|
553
|
+
"MATCH (d:Document) WHERE NOT (d)-[:IN_REPO]->() RETURN d.path, d.type LIMIT 50"
|
|
554
|
+
)
|
|
555
|
+
html = ""
|
|
556
|
+
for n, p in (repos.result_set or []):
|
|
557
|
+
html += f"""<div class="source-item">
|
|
558
|
+
<span>π¦ <b>{n or p}</b></span>
|
|
559
|
+
<button class="btn-danger" hx-delete="/api/source?name={n}" hx-target="#sources" hx-swap="innerHTML">Remove</button>
|
|
560
|
+
</div>"""
|
|
561
|
+
for dp, dt in (docs.result_set or []):
|
|
562
|
+
html += f"""<div class="source-item">
|
|
563
|
+
<span>π {dp} ({dt})</span>
|
|
564
|
+
<button class="btn-danger" hx-delete="/api/source?path={dp}" hx-target="#sources" hx-swap="innerHTML">Remove</button>
|
|
565
|
+
</div>"""
|
|
566
|
+
return html or '<div class="msg">No sources indexed.</div>'
|
|
567
|
+
|
|
568
|
+
return app
|