Add Knowledge Hub books FTS and remove training UI (0.16.0).

Index Assistent/books search.jsonl, expose knowledge hops/catalog in chat, and drop QLoRA/HF training stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-23 23:15:27 +03:00
co-authored by Cursor
parent baa75f2858
commit e1b5743795
25 changed files with 1611 additions and 5024 deletions
+458
View File
@@ -0,0 +1,458 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using Microsoft.Data.Sqlite;
using Newtonsoft.Json.Linq;
using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>Reference books (search.jsonl) — FTS only, seeded by gpu-rent to Assistent/books/.</summary>
public sealed partial class AssistentMemory
{
const string BooksMetaPrefix = "books_fp:";
void TryIndexBooks()
{
try
{
EnsureBooksIndex();
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory books index: {ex.Message}");
}
}
public string BooksRoot()
=> Path.Combine(_dataRoot, "Assistent", "books");
void EnsureBooksSchema()
{
Exec(
"""
CREATE TABLE IF NOT EXISTS books_rows (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '',
text TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
rating TEXT NOT NULL DEFAULT '',
meta_json TEXT NOT NULL DEFAULT '{}',
search_blob TEXT NOT NULL DEFAULT ''
);
""");
Exec("CREATE INDEX IF NOT EXISTS idx_books_rows_book ON books_rows(book_id);");
Exec(
"""
CREATE VIRTUAL TABLE IF NOT EXISTS books_fts USING fts5(
book_id,
title,
tags,
text,
body,
search_blob,
tokenize = 'unicode61 remove_diacritics 2'
);
""");
Exec(
"""
CREATE TRIGGER IF NOT EXISTS books_fts_ai AFTER INSERT ON books_rows BEGIN
INSERT INTO books_fts(rowid, book_id, title, tags, text, body, search_blob)
VALUES (new.rowid, new.book_id, new.title, new.tags, new.text, new.body, new.search_blob);
END;
""");
Exec(
"""
CREATE TRIGGER IF NOT EXISTS books_fts_ad AFTER DELETE ON books_rows BEGIN
INSERT INTO books_fts(books_fts, rowid) VALUES('delete', old.rowid);
END;
""");
Exec(
"""
CREATE TRIGGER IF NOT EXISTS books_fts_au AFTER UPDATE ON books_rows BEGIN
INSERT INTO books_fts(books_fts, rowid) VALUES('delete', old.rowid);
INSERT INTO books_fts(rowid, book_id, title, tags, text, body, search_blob)
VALUES (new.rowid, new.book_id, new.title, new.tags, new.text, new.body, new.search_blob);
END;
""");
}
static string ReadBookContentSha(string bookDir)
{
foreach (string name in new[] { ".gpu-rent-meta.json", "meta.json" })
{
string path = Path.Combine(bookDir, name);
if (!File.Exists(path))
{
continue;
}
try
{
JObject meta = JObject.Parse(File.ReadAllText(path, Encoding.UTF8));
string sha = meta["content_sha"]?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(sha))
{
return sha;
}
}
catch
{
// ignore
}
}
string jsonl = Path.Combine(bookDir, "search.jsonl");
if (File.Exists(jsonl))
{
return FileFingerprint(jsonl);
}
return null;
}
/// <summary>Reindex changed books from disk. Returns total row count.</summary>
public int EnsureBooksIndex()
{
string root = BooksRoot();
if (!Directory.Exists(root))
{
return BookRowCount();
}
int total = 0;
foreach (string bookDir in Directory.GetDirectories(root).OrderBy(d => d, StringComparer.OrdinalIgnoreCase))
{
string bookId = Path.GetFileName(bookDir);
if (string.IsNullOrWhiteSpace(bookId))
{
continue;
}
string jsonl = Path.Combine(bookDir, "search.jsonl");
if (!File.Exists(jsonl))
{
continue;
}
string fp = ReadBookContentSha(bookDir) ?? FileFingerprint(jsonl);
lock (_lock)
{
EnsureOpen();
EnsureBooksSchema();
string metaKey = BooksMetaPrefix + bookId;
if (string.Equals(GetMeta(metaKey), fp, StringComparison.Ordinal))
{
continue;
}
}
int n = IndexBookJsonl(bookId, jsonl, fp);
total += n;
}
if (total > 0)
{
Logs.Info($"AssistentMemory: indexed {total} book rows under {root}");
}
return BookRowCount();
}
int IndexBookJsonl(string bookId, string jsonlPath, string fingerprint)
{
List<(string id, string title, string tags, string text, string body, string rating, string metaJson, string blob)> rows = [];
foreach (string line in File.ReadLines(jsonlPath, Encoding.UTF8))
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
JObject o;
try
{
o = JObject.Parse(line);
}
catch
{
continue;
}
string id = o["id"]?.ToString()?.Trim();
if (string.IsNullOrWhiteSpace(id))
{
id = $"{bookId}:{rows.Count + 1}";
}
string title = o["title"]?.ToString() ?? "";
string tagsJoined = "";
if (o["tags"] is JArray tagArr)
{
tagsJoined = string.Join(", ", tagArr.Select(t => t?.ToString()?.Trim()).Where(t => !string.IsNullOrWhiteSpace(t)));
}
string text = o["text"]?.ToString() ?? "";
string body = o["body"]?.ToString() ?? text;
if (string.IsNullOrWhiteSpace(text) && string.IsNullOrWhiteSpace(body))
{
continue;
}
string rating = o["rating"]?.ToString() ?? "";
string metaJson = (o["meta"] as JObject)?.ToString(Newtonsoft.Json.Formatting.None) ?? "{}";
string blob = $"{title}\n{tagsJoined}\n{text}\n{body}\n{rating}";
rows.Add((id, title, tagsJoined, text, body, rating, metaJson, blob));
}
lock (_lock)
{
EnsureOpen();
EnsureBooksSchema();
using SqliteTransaction tx = _conn.BeginTransaction();
using (SqliteCommand del = _conn.CreateCommand())
{
del.Transaction = tx;
del.CommandText = "DELETE FROM books_rows WHERE book_id = $b";
del.Parameters.AddWithValue("$b", bookId);
del.ExecuteNonQuery();
}
using (SqliteCommand ins = _conn.CreateCommand())
{
ins.Transaction = tx;
ins.CommandText =
"""
INSERT OR REPLACE INTO books_rows(
id, book_id, title, tags, text, body, rating, meta_json, search_blob)
VALUES($id,$b,$t,$tg,$tx,$bd,$r,$mj,$bl)
""";
var pid = ins.Parameters.Add("$id", SqliteType.Text);
var pb = ins.Parameters.Add("$b", SqliteType.Text);
var pt = ins.Parameters.Add("$t", SqliteType.Text);
var ptg = ins.Parameters.Add("$tg", SqliteType.Text);
var ptx = ins.Parameters.Add("$tx", SqliteType.Text);
var pbd = ins.Parameters.Add("$bd", SqliteType.Text);
var pr = ins.Parameters.Add("$r", SqliteType.Text);
var pmj = ins.Parameters.Add("$mj", SqliteType.Text);
var pbl = ins.Parameters.Add("$bl", SqliteType.Text);
foreach (var row in rows)
{
pid.Value = row.id;
pb.Value = bookId;
pt.Value = row.title;
ptg.Value = row.tags;
ptx.Value = row.text;
pbd.Value = row.body;
pr.Value = row.rating;
pmj.Value = row.metaJson;
pbl.Value = row.blob;
ins.ExecuteNonQuery();
}
}
tx.Commit();
SetMeta(BooksMetaPrefix + bookId, fingerprint);
Logs.Info($"AssistentMemory: indexed book {bookId} ({rows.Count} rows)");
return rows.Count;
}
}
public int BookRowCount()
{
lock (_lock)
{
EnsureOpen();
if (!TableExists("books_rows"))
{
return 0;
}
using SqliteCommand c = _conn.CreateCommand();
c.CommandText = "SELECT COUNT(*) FROM books_rows";
return Convert.ToInt32(c.ExecuteScalar());
}
}
public JArray ListBooksOnDisk()
{
JArray list = [];
string root = BooksRoot();
if (!Directory.Exists(root))
{
return list;
}
foreach (string bookDir in Directory.GetDirectories(root).OrderBy(d => d, StringComparer.OrdinalIgnoreCase))
{
string id = Path.GetFileName(bookDir);
if (string.IsNullOrWhiteSpace(id))
{
continue;
}
JObject spec = new() { ["id"] = id };
string yaml = Path.Combine(bookDir, "book.yaml");
if (File.Exists(yaml))
{
foreach (string rawLine in File.ReadAllLines(yaml, Encoding.UTF8))
{
string line = rawLine.Trim();
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
{
continue;
}
int colon = line.IndexOf(':');
if (colon <= 0)
{
continue;
}
string key = line[..colon].Trim();
string val = line[(colon + 1)..].Trim().Trim('"', '\'');
if (key is "title" or "description" or "content_kind" or "language")
{
spec[key] = val;
}
}
}
string jsonl = Path.Combine(bookDir, "search.jsonl");
spec["indexed"] = File.Exists(jsonl);
spec["content_sha"] = ReadBookContentSha(bookDir) ?? "";
list.Add(spec);
}
return list;
}
/// <summary>FTS lookup over attached books (filter by book_id list when provided).</summary>
public JArray LookupBooks(string query, int limit = 8, IEnumerable<string> bookIds = null, string rating = null)
{
query = (query ?? "").Trim();
if (query.Length < 1)
{
return [];
}
TryIndexBooks();
int cap = Math.Clamp(limit, 1, 30);
HashSet<string> bookFilter = null;
if (bookIds is not null)
{
bookFilter = bookIds
.Select(b => (b ?? "").Trim())
.Where(b => !string.IsNullOrWhiteSpace(b))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (bookFilter.Count == 0)
{
return [];
}
}
string ratingFilter = string.IsNullOrWhiteSpace(rating) ? null : rating.Trim().ToLowerInvariant();
List<JObject> hits = [];
HashSet<string> seen = [];
void Add(SqliteDataReader reader)
{
string id = reader.GetString(0);
if (!seen.Add(id))
{
return;
}
string bookId = reader.IsDBNull(1) ? "" : reader.GetString(1);
if (bookFilter is not null && !bookFilter.Contains(bookId))
{
seen.Remove(id);
return;
}
string title = reader.IsDBNull(2) ? "" : reader.GetString(2);
string tags = reader.IsDBNull(3) ? "" : reader.GetString(3);
string text = reader.IsDBNull(4) ? "" : reader.GetString(4);
string body = reader.IsDBNull(5) ? "" : reader.GetString(5);
string rowRating = reader.IsDBNull(6) ? "" : reader.GetString(6);
JToken metaTok = new JObject();
if (!reader.IsDBNull(7))
{
try { metaTok = JToken.Parse(reader.GetString(7)); } catch { metaTok = new JObject(); }
}
hits.Add(new JObject
{
["id"] = id,
["book"] = bookId,
["source"] = "book",
["title"] = title,
["tags"] = tags,
["text"] = text.Length > 600 ? text[..600] + "…" : text,
["body"] = body.Length > 1200 ? body[..1200] + "…" : body,
["rating"] = rowRating,
["meta"] = metaTok,
["note"] = "BOOK reference — remix style/ideas, do not paste long verbatim",
});
}
lock (_lock)
{
EnsureOpen();
if (!TableExists("books_rows"))
{
return [];
}
string match = BuildFtsMatch(query);
if (!string.IsNullOrWhiteSpace(match) && TableExists("books_fts"))
{
try
{
using SqliteCommand cmd = _conn.CreateCommand();
string sql =
"""
SELECT b.id, b.book_id, b.title, b.tags, b.text, b.body, b.rating, b.meta_json
FROM books_rows b
WHERE b.rowid IN (SELECT rowid FROM books_fts WHERE books_fts MATCH $q)
""";
if (ratingFilter is not null)
{
sql += " AND lower(b.rating) = $r";
}
sql += " LIMIT $lim";
cmd.CommandText = sql;
cmd.Parameters.AddWithValue("$q", match);
if (ratingFilter is not null)
{
cmd.Parameters.AddWithValue("$r", ratingFilter);
}
cmd.Parameters.AddWithValue("$lim", cap);
using SqliteDataReader reader = cmd.ExecuteReader();
while (reader.Read() && hits.Count < cap)
{
Add(reader);
}
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory books FTS: {ex.Message}");
}
}
if (hits.Count < cap)
{
try
{
using SqliteCommand cmd = _conn.CreateCommand();
string sql =
"""
SELECT id, book_id, title, tags, text, body, rating, meta_json
FROM books_rows
WHERE (title LIKE $p ESCAPE '\' OR tags LIKE $p ESCAPE '\' OR text LIKE $p ESCAPE '\' OR body LIKE $p ESCAPE '\' OR search_blob LIKE $p ESCAPE '\')
""";
if (ratingFilter is not null)
{
sql += " AND lower(rating) = $r";
}
sql += " LIMIT $lim";
cmd.CommandText = sql;
cmd.Parameters.AddWithValue("$p", "%" + EscapeLike(query) + "%");
if (ratingFilter is not null)
{
cmd.Parameters.AddWithValue("$r", ratingFilter);
}
cmd.Parameters.AddWithValue("$lim", cap);
using SqliteDataReader reader = cmd.ExecuteReader();
while (reader.Read() && hits.Count < cap)
{
Add(reader);
}
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory books LIKE: {ex.Message}");
}
}
}
return new JArray(hits.Take(cap));
}
}