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.
@@ -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