wiki-plugin-similarity 0.4.4 → 0.5.1
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.
- package/client/similarity.js +35 -35
- package/client/similarity.js.map +3 -3
- package/package.json +8 -3
- package/pages/about-similarity-plugin +90 -40
- package/server/embedder.js +71 -0
- package/server/farm-lib.js +102 -0
- package/server/farm-search.js +122 -0
- package/server/ort-hooks.mjs +6 -0
- package/server/ort-shim.mjs +2 -0
- package/server/search-report.js +0 -0
- package/server/server.js +153 -152
package/server/server.js
CHANGED
|
@@ -1,22 +1,31 @@
|
|
|
1
1
|
// wiki-plugin-similarity — server-side component
|
|
2
2
|
//
|
|
3
|
-
// Registers
|
|
4
|
-
// hook in wiki-server/lib/plugins.js.
|
|
3
|
+
// Registers same-origin /system routes with the wiki's Express app via the
|
|
4
|
+
// startServer(params) hook in wiki-server/lib/plugins.js. Everything a search
|
|
5
|
+
// needs runs in-process on whatever host serves the wiki — no HomeLab FastAPI
|
|
6
|
+
// dependency — so club members on the public farm get working search.
|
|
5
7
|
//
|
|
6
8
|
// Routes:
|
|
7
|
-
// GET
|
|
8
|
-
//
|
|
9
|
-
//
|
|
9
|
+
// GET /system/indexed-domains.json?pattern=glob1,glob2
|
|
10
|
+
// [{domain, page_count}] for domains with a semantic-vectors.json.
|
|
11
|
+
// GET /system/semantic-vectors.json[?domain=]
|
|
12
|
+
// Serves {farm}/{domain}/status/semantic-vectors.json.
|
|
13
|
+
// GET /system/embed.json?text=…
|
|
14
|
+
// 384-dim unit vector via the in-process transformers.js embedder
|
|
15
|
+
// (BAAI/bge-small-en-v1.5 — same model the indexes were built with).
|
|
16
|
+
// Set WIKI_EMBED_URL to proxy to an external embedder instead.
|
|
17
|
+
// POST /system/search-report.json {query, domains, limit, threshold, live}
|
|
18
|
+
// Ranked, stub-filtered, fork-bundled semantic report (page JSON).
|
|
19
|
+
// GET /system/farm-search.json?q=…&pattern=…&limit=…
|
|
20
|
+
// Galactic keyword search — reads each site's own per-edit MiniSearch
|
|
21
|
+
// index (status/site-index.json). No index building.
|
|
22
|
+
// GET /system/build-index.json?domains=…&force=…
|
|
23
|
+
// Proxy to the farm indexer (WIKI_INDEXER_URL) when configured; heavy
|
|
24
|
+
// embedding is the indexer's job (Pi5 on the Hitchhikers farm), never
|
|
25
|
+
// the wiki server's.
|
|
10
26
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// req.hostname selects the domain in a fedwiki farm.
|
|
14
|
-
//
|
|
15
|
-
// GET /system/embed?text=…
|
|
16
|
-
// Proxies to the local FastAPI /embed endpoint and returns {vector:[…]}.
|
|
17
|
-
// Requires the FastAPI server to be running on EMBED_URL (default localhost:8000).
|
|
18
|
-
//
|
|
19
|
-
// Farm root is derived from argv.status which is {farm}/{domain}/status.
|
|
27
|
+
// Farm root is derived from argv.status ({farm}/{domain}/status); extra farm
|
|
28
|
+
// roots come from WIKI_EXTRA_FARMS (colon-separated absolute paths).
|
|
20
29
|
//
|
|
21
30
|
// CommonJS on purpose (see sibling server/package.json): wiki-server's older
|
|
22
31
|
// require() loader throws ERR_REQUIRE_ESM on an ESM server.js and swallows the
|
|
@@ -27,118 +36,19 @@ const fs = require('node:fs')
|
|
|
27
36
|
const path = require('node:path')
|
|
28
37
|
const http = require('node:http')
|
|
29
38
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const EXTRA_FARMS = (process.env.WIKI_EXTRA_FARMS || '').split(':').filter(Boolean)
|
|
35
|
-
|
|
36
|
-
// ── Glob matching ─────────────────────────────────────────────────────────────
|
|
37
|
-
// Supports * (any chars) and ? (one char). No path separator semantics needed.
|
|
38
|
-
|
|
39
|
-
const globMatch = (pattern, str) => {
|
|
40
|
-
const p = pattern.length
|
|
41
|
-
const s = str.length
|
|
42
|
-
const dp = Array.from({ length: p + 1 }, () => new Array(s + 1).fill(false))
|
|
43
|
-
dp[0][0] = true
|
|
44
|
-
for (let i = 1; i <= p; i++) {
|
|
45
|
-
if (pattern[i - 1] === '*') dp[i][0] = dp[i - 1][0]
|
|
46
|
-
}
|
|
47
|
-
for (let i = 1; i <= p; i++) {
|
|
48
|
-
for (let j = 1; j <= s; j++) {
|
|
49
|
-
if (pattern[i - 1] === '*') {
|
|
50
|
-
dp[i][j] = dp[i - 1][j] || dp[i][j - 1]
|
|
51
|
-
} else if (pattern[i - 1] === '?' || pattern[i - 1] === str[j - 1]) {
|
|
52
|
-
dp[i][j] = dp[i - 1][j - 1]
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return dp[p][s]
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Scope keywords (exact uppercase, per DSL convention):
|
|
60
|
-
// * all farms
|
|
61
|
-
// PUBLIC domains in the extra farms (Nextcloud mirror)
|
|
62
|
-
// LOCAL domains in the primary (localhost) farm
|
|
63
|
-
// PRIVATE public domains marked "restricted": true in the farm config
|
|
64
|
-
|
|
65
|
-
// Collect restricted domains from config-*.json files at each public farm root
|
|
66
|
-
const loadRestricted = () => {
|
|
67
|
-
const restricted = new Set()
|
|
68
|
-
for (const farm of EXTRA_FARMS) {
|
|
69
|
-
let files
|
|
70
|
-
try { files = fs.readdirSync(farm) } catch { continue }
|
|
71
|
-
for (const f of files) {
|
|
72
|
-
if (!/^config-.*\.json$/.test(f)) continue
|
|
73
|
-
try {
|
|
74
|
-
const cfg = JSON.parse(fs.readFileSync(path.join(farm, f), 'utf8'))
|
|
75
|
-
for (const [domain, opts] of Object.entries(cfg.wikiDomains || {})) {
|
|
76
|
-
if (opts && opts.restricted) restricted.add(domain)
|
|
77
|
-
}
|
|
78
|
-
} catch { /* ignore malformed config */ }
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
return restricted
|
|
82
|
-
}
|
|
83
|
-
const RESTRICTED = loadRestricted()
|
|
84
|
-
|
|
85
|
-
// kind: 'local' for the primary farm, 'public' for extra farms
|
|
86
|
-
const matchesAny = (domain, kind, patterns) =>
|
|
87
|
-
patterns.some(p => {
|
|
88
|
-
if (p === '*') return true
|
|
89
|
-
if (p === 'PUBLIC') return kind === 'public'
|
|
90
|
-
if (p === 'LOCAL') return kind === 'local'
|
|
91
|
-
if (p === 'PRIVATE') return kind === 'public' && RESTRICTED.has(domain)
|
|
92
|
-
return globMatch(p, domain)
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
// ── Multi-farm helpers ────────────────────────────────────────────────────────
|
|
96
|
-
|
|
97
|
-
// Return the first path that exists across all farm roots for a given domain
|
|
98
|
-
// and relative sub-path (e.g. 'status/semantic-vectors.json').
|
|
99
|
-
const findInFarms = (farmRoot, domain, relPath) => {
|
|
100
|
-
const farms = [farmRoot, ...EXTRA_FARMS]
|
|
101
|
-
for (const farm of farms) {
|
|
102
|
-
const full = path.join(farm, domain, relPath)
|
|
103
|
-
try { fs.accessSync(full, fs.constants.F_OK); return full } catch { /* try next */ }
|
|
104
|
-
}
|
|
105
|
-
return null
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// ── Discover indexed domains ───────────────────────────────────────────────────
|
|
39
|
+
const { loadRestricted, matchesAny, listDomains, findInFarms } = require('./farm-lib')
|
|
40
|
+
const embedder = require('./embedder')
|
|
41
|
+
const { buildReport } = require('./search-report')
|
|
42
|
+
const { searchFarm, keywordReportPage } = require('./farm-search')
|
|
109
43
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
for (const [farm, kind] of farms) {
|
|
117
|
-
let entries
|
|
118
|
-
try { entries = fs.readdirSync(farm, { withFileTypes: true }) } catch { continue }
|
|
119
|
-
|
|
120
|
-
for (const ent of entries) {
|
|
121
|
-
if (!ent.isDirectory()) continue
|
|
122
|
-
const domain = ent.name
|
|
123
|
-
if (seen.has(domain)) continue // first farm wins
|
|
124
|
-
if (!matchesAny(domain, kind, patterns)) continue
|
|
125
|
-
const vecFile = path.join(farm, domain, 'status', 'semantic-vectors.json')
|
|
126
|
-
try { fs.accessSync(vecFile, fs.constants.F_OK) } catch { continue }
|
|
127
|
-
seen.add(domain)
|
|
128
|
-
let pageCount = null
|
|
129
|
-
try {
|
|
130
|
-
const pages = JSON.parse(fs.readFileSync(vecFile, 'utf8'))
|
|
131
|
-
pageCount = Array.isArray(pages) ? pages.length : null
|
|
132
|
-
} catch { /* ignore */ }
|
|
133
|
-
results.push({ domain, page_count: pageCount })
|
|
134
|
-
}
|
|
135
|
-
}
|
|
44
|
+
// Optional external embedder (proxy) — unset means embed in-process.
|
|
45
|
+
const EMBED_URL = process.env.WIKI_EMBED_URL || null
|
|
46
|
+
// Farm indexer for BUILD requests (HomeLab FastAPI, or unset on the public farm).
|
|
47
|
+
const INDEXER_URL = process.env.WIKI_INDEXER_URL || null
|
|
48
|
+
// Optional additional farm roots, colon-separated absolute paths.
|
|
49
|
+
const EXTRA_FARMS = (process.env.WIKI_EXTRA_FARMS || '').split(':').filter(Boolean)
|
|
136
50
|
|
|
137
|
-
|
|
138
|
-
return results
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// ── Fetch helper (node:http, avoids fetch() version concerns) ─────────────────
|
|
51
|
+
// ── HTTP helpers ──────────────────────────────────────────────────────────────
|
|
142
52
|
|
|
143
53
|
const postJson = (url, body) =>
|
|
144
54
|
new Promise((resolve, reject) => {
|
|
@@ -159,7 +69,7 @@ const postJson = (url, body) =>
|
|
|
159
69
|
res.on('data', chunk => { data += chunk })
|
|
160
70
|
res.on('end', () => {
|
|
161
71
|
try { resolve(JSON.parse(data)) }
|
|
162
|
-
catch (e) { reject(new Error(`
|
|
72
|
+
catch (e) { reject(new Error(`response parse error: ${e.message}`)) }
|
|
163
73
|
})
|
|
164
74
|
})
|
|
165
75
|
req.on('error', reject)
|
|
@@ -167,31 +77,64 @@ const postJson = (url, body) =>
|
|
|
167
77
|
req.end()
|
|
168
78
|
})
|
|
169
79
|
|
|
80
|
+
const getJson = url =>
|
|
81
|
+
new Promise((resolve, reject) => {
|
|
82
|
+
const u = new URL(url)
|
|
83
|
+
http.get(u, res => {
|
|
84
|
+
let data = ''
|
|
85
|
+
res.on('data', chunk => { data += chunk })
|
|
86
|
+
res.on('end', () => {
|
|
87
|
+
try { resolve(JSON.parse(data)) }
|
|
88
|
+
catch (e) { reject(new Error(`response parse error: ${e.message}`)) }
|
|
89
|
+
})
|
|
90
|
+
}).on('error', reject)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// Read a JSON request body without assuming a body-parser is mounted.
|
|
94
|
+
const readBody = req =>
|
|
95
|
+
new Promise((resolve, reject) => {
|
|
96
|
+
if (req.body && typeof req.body === 'object') return resolve(req.body)
|
|
97
|
+
let data = ''
|
|
98
|
+
req.on('data', chunk => { data += chunk })
|
|
99
|
+
req.on('end', () => {
|
|
100
|
+
try { resolve(data ? JSON.parse(data) : {}) }
|
|
101
|
+
catch (e) { reject(new Error(`invalid JSON body: ${e.message}`)) }
|
|
102
|
+
})
|
|
103
|
+
req.on('error', reject)
|
|
104
|
+
})
|
|
105
|
+
|
|
170
106
|
// ── startServer — called by wiki-server/lib/plugins.js ────────────────────────
|
|
171
107
|
|
|
172
108
|
const startServer = ({ argv, app }) => {
|
|
173
109
|
// Farm root: argv.status = {farm}/{thisDomain}/status → go up two levels
|
|
174
110
|
const farmRoot = path.dirname(path.dirname(argv.status))
|
|
111
|
+
// primary farm is 'local'; extra farms (Nextcloud mirror) are 'public'
|
|
112
|
+
const farms = [[farmRoot, 'local'], ...EXTRA_FARMS.map(f => [f, 'public'])]
|
|
113
|
+
const restricted = loadRestricted(EXTRA_FARMS)
|
|
114
|
+
const ctx = { farms, restricted, embed: embedText }
|
|
175
115
|
|
|
176
|
-
console.log('[wiki-plugin-similarity] registering /system routes,
|
|
116
|
+
console.log('[wiki-plugin-similarity] registering /system routes, farms:',
|
|
117
|
+
farms.map(([f]) => f).join(', '))
|
|
118
|
+
|
|
119
|
+
// Warm the embedding model in the background so the first query is fast.
|
|
120
|
+
if (!EMBED_URL) embedder.warm()
|
|
121
|
+
|
|
122
|
+
async function embedText(text) {
|
|
123
|
+
if (EMBED_URL) return (await postJson(EMBED_URL, { text })).vector
|
|
124
|
+
return embedder.embed(text)
|
|
125
|
+
}
|
|
177
126
|
|
|
178
|
-
// CORS helper
|
|
179
127
|
const cors = res => {
|
|
180
128
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
181
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
|
129
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
182
130
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
183
131
|
}
|
|
184
132
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
cors(res); res.sendStatus(204)
|
|
191
|
-
})
|
|
192
|
-
app.options('/system/embed.json', (req, res) => {
|
|
193
|
-
cors(res); res.sendStatus(204)
|
|
194
|
-
})
|
|
133
|
+
for (const route of ['/system/indexed-domains.json', '/system/semantic-vectors.json',
|
|
134
|
+
'/system/embed.json', '/system/search-report.json',
|
|
135
|
+
'/system/farm-search.json', '/system/build-index.json']) {
|
|
136
|
+
app.options(route, (req, res) => { cors(res); res.sendStatus(204) })
|
|
137
|
+
}
|
|
195
138
|
|
|
196
139
|
// ── GET /system/indexed-domains.json?pattern=glob1,glob2 ──────────────────
|
|
197
140
|
app.get('/system/indexed-domains.json', (req, res) => {
|
|
@@ -199,22 +142,25 @@ const startServer = ({ argv, app }) => {
|
|
|
199
142
|
const raw = req.query.pattern || '*'
|
|
200
143
|
const patterns = raw.split(',').map(p => p.trim()).filter(Boolean)
|
|
201
144
|
const limit = parseInt(req.query.limit) || null
|
|
202
|
-
let results
|
|
145
|
+
let results = listDomains(farms, patterns, restricted, 'status/semantic-vectors.json')
|
|
146
|
+
.map(({ farm, domain }) => {
|
|
147
|
+
let pageCount = null
|
|
148
|
+
try {
|
|
149
|
+
const pages = JSON.parse(fs.readFileSync(
|
|
150
|
+
path.join(farm, domain, 'status', 'semantic-vectors.json'), 'utf8'))
|
|
151
|
+
pageCount = Array.isArray(pages) ? pages.length : null
|
|
152
|
+
} catch { /* ignore */ }
|
|
153
|
+
return { domain, page_count: pageCount }
|
|
154
|
+
})
|
|
203
155
|
if (limit) results = results.slice(0, limit)
|
|
204
156
|
res.json(results)
|
|
205
157
|
})
|
|
206
158
|
|
|
207
|
-
// ── GET /system/semantic-vectors.json
|
|
208
|
-
// Two modes:
|
|
209
|
-
// ?domain=david.hitchhikers.earth — local proxy: searches all farm roots
|
|
210
|
-
// on disk (private farm + WIKI_EXTRA_FARMS). Lets localhost clients load
|
|
211
|
-
// vectors for any domain without CORS or remote plugin requirements.
|
|
212
|
-
// (no ?domain) — req.hostname selects the domain; works for farm requests
|
|
213
|
-
// where Caddy routes each hostname to this server.
|
|
159
|
+
// ── GET /system/semantic-vectors.json[?domain=] ────────────────────────────
|
|
214
160
|
app.get('/system/semantic-vectors.json', (req, res) => {
|
|
215
161
|
cors(res)
|
|
216
162
|
const domain = req.query.domain || req.hostname || 'localhost'
|
|
217
|
-
const vecFile = findInFarms(
|
|
163
|
+
const vecFile = findInFarms(farms, domain, 'status/semantic-vectors.json')
|
|
218
164
|
if (!vecFile) {
|
|
219
165
|
return res.status(404).json({ error: `vectors not found for ${domain}` })
|
|
220
166
|
}
|
|
@@ -226,17 +172,72 @@ const startServer = ({ argv, app }) => {
|
|
|
226
172
|
})
|
|
227
173
|
})
|
|
228
174
|
|
|
229
|
-
// ── GET /system/embed.json?text=…
|
|
175
|
+
// ── GET /system/embed.json?text=… ──────────────────────────────────────────
|
|
230
176
|
app.get('/system/embed.json', async (req, res) => {
|
|
231
177
|
cors(res)
|
|
232
178
|
const text = req.query.text
|
|
233
179
|
if (!text) return res.status(400).json({ error: 'text parameter required' })
|
|
234
180
|
try {
|
|
235
|
-
|
|
236
|
-
|
|
181
|
+
res.json({ vector: await embedText(text) })
|
|
182
|
+
} catch (e) {
|
|
183
|
+
console.error('[wiki-plugin-similarity] embed error:', e.message)
|
|
184
|
+
res.status(502).json({ error: `embedding unavailable: ${e.message}` })
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// ── POST /system/search-report.json ────────────────────────────────────────
|
|
189
|
+
app.post('/system/search-report.json', async (req, res) => {
|
|
190
|
+
cors(res)
|
|
191
|
+
try {
|
|
192
|
+
const body = await readBody(req)
|
|
193
|
+
if (!body.query) return res.status(400).json({ error: 'query required' })
|
|
194
|
+
const page = await buildReport(
|
|
195
|
+
body.query, body.domains || ['*'], body.limit || 10, ctx,
|
|
196
|
+
body.threshold ?? null, !!body.live)
|
|
197
|
+
res.json(page)
|
|
198
|
+
} catch (e) {
|
|
199
|
+
console.error('[wiki-plugin-similarity] search-report error:', e.message)
|
|
200
|
+
res.status(500).json({ error: `search-report failed: ${e.message}` })
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// ── GET /system/farm-search.json?q=…&pattern=…&limit=… ────────────────────
|
|
205
|
+
app.get('/system/farm-search.json', (req, res) => {
|
|
206
|
+
cors(res)
|
|
207
|
+
const q = (req.query.q || '').trim()
|
|
208
|
+
if (!q) return res.status(400).json({ error: 'q parameter required' })
|
|
209
|
+
const patterns = (req.query.pattern || '*').split(',').map(p => p.trim()).filter(Boolean)
|
|
210
|
+
const limit = parseInt(req.query.limit) || 10
|
|
211
|
+
try {
|
|
212
|
+
const outcome = searchFarm(farms, patterns, restricted, q, limit)
|
|
213
|
+
if (req.query.format === 'flat') return res.json(outcome)
|
|
214
|
+
res.json(keywordReportPage(q, outcome, limit, patterns))
|
|
215
|
+
} catch (e) {
|
|
216
|
+
console.error('[wiki-plugin-similarity] farm-search error:', e.message)
|
|
217
|
+
res.status(500).json({ error: `farm-search failed: ${e.message}` })
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
// ── GET /system/build-index.json?domains=…&force=… ────────────────────────
|
|
222
|
+
// Heavy embedding is the farm indexer's job — proxy when configured, else
|
|
223
|
+
// explain where indexing happens.
|
|
224
|
+
app.get('/system/build-index.json', async (req, res) => {
|
|
225
|
+
cors(res)
|
|
226
|
+
if (!INDEXER_URL) {
|
|
227
|
+
return res.status(501).json({
|
|
228
|
+
error: 'no farm indexer configured on this server',
|
|
229
|
+
hint: 'Indexing runs on the farm indexer (Pi5 on the Hitchhikers farm); ' +
|
|
230
|
+
'indexes arrive by sync. Set WIKI_INDEXER_URL to enable proxying.',
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
const qs = new URLSearchParams({
|
|
235
|
+
domains: req.query.domains || '*',
|
|
236
|
+
force: req.query.force || '0',
|
|
237
|
+
})
|
|
238
|
+
res.json(await getJson(`${INDEXER_URL}?${qs}`))
|
|
237
239
|
} catch (e) {
|
|
238
|
-
|
|
239
|
-
res.status(502).json({ error: `embed service unavailable: ${e.message}` })
|
|
240
|
+
res.status(502).json({ error: `farm indexer unavailable: ${e.message}` })
|
|
240
241
|
}
|
|
241
242
|
})
|
|
242
243
|
|