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/server/server.js CHANGED
@@ -1,22 +1,31 @@
1
1
  // wiki-plugin-similarity — server-side component
2
2
  //
3
- // Registers three routes with the wiki's Express app via the startServer(params)
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 /system/indexed-domains.json?pattern=glob1,glob2
8
- // Returns [{domain, page_count}] for all domains whose semantic-vectors.json
9
- // exists and whose name matches any of the supplied glob patterns.
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
- // GET /system/semantic-vectors.json
12
- // Serves {farm}/{req.hostname}/status/semantic-vectors.json.
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 EMBED_URL = process.env.WIKI_EMBED_URL || 'http://localhost:8000/embed'
31
- // Optional additional farm roots to search when a domain's vectors aren't found
32
- // in the primary farm. Colon-separated list of absolute paths, e.g.:
33
- // WIKI_EXTRA_FARMS=/Users/david/Nextcloud/fedwiki:/Users/david/other-farm
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
- const findIndexedDomains = (farmRoot, patterns) => {
111
- // primary farm is 'local'; extra farms (Nextcloud mirror) are 'public'
112
- const farms = [[farmRoot, 'local'], ...EXTRA_FARMS.map(f => [f, 'public'])]
113
- const seen = new Set()
114
- const results = []
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
- results.sort((a, b) => a.domain.localeCompare(b.domain))
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(`embed response parse error: ${e.message}`)) }
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, farm:', farmRoot)
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
- // ── OPTIONS pre-flight ─────────────────────────────────────────────────────
186
- app.options('/system/indexed-domains.json', (req, res) => {
187
- cors(res); res.sendStatus(204)
188
- })
189
- app.options('/system/semantic-vectors.json', (req, res) => {
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 = findIndexedDomains(farmRoot, patterns)
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(farmRoot, domain, 'status/semantic-vectors.json')
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
- const result = await postJson(EMBED_URL, { text })
236
- res.json(result)
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
- console.error('[wiki-plugin-similarity] embed proxy error:', e.message)
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