openvibers/openvibe.search Commits
A question puts answers first (articles before recordings in a tier) (#191)
Changes
@@@ -184,18 +184,26 @@ async function dealsOrder(fused, { match, store }) {// - a title match (3) outranks a summary-only match (2), which outranks a body-only one (1);// - a thin username-titled VOD ranks below a real title+summary inside the same tier.// The fused order is the final tie-break, so relevance still decides among equals.++// A question ("how do I stream", "can I go live without OBS") wants an answer: inside its tier, an article (Help's++// answers, a site's docs) comes before a recording or a post that only shares a word (2026-11-02: "how do I stream"++// ranked four VODs titled "… Stream" above Help's "Can I stream without OBS?").++const QUESTION_WORDS = new Set(['how', 'what', 'why', 'when', 'where', 'which', 'who', 'can', 'do', 'does', 'is', 'are', 'should', 'will']);++const isQuestion = (match) => Boolean(match && match.words.length >= 2 && QUESTION_WORDS.has(match.words[0]));++const isAnswer = (doc) => Boolean(doc && doc.type === 'article');++async function rankOrder(fused, { match, store }) {if (!match || !match.words.length) return fused;const hosts = siteHosts(match);++ const ask = isQuestion(match);const rows = new Map();for (const hit of fused) rows.set(hit.rid, hit.found !== undefined ? hit.found : await store.byRid(hit.rid));return fused.filter((hit) => rows.get(hit.rid)).map((hit, i) => {const found = rows.get(hit.rid);-- return { hit, found, i, tier: docTier(found.doc, match, hosts), thin: isThin(found.doc) };++ return { hit, found, i, tier: docTier(found.doc, match, hosts), thin: isThin(found.doc), answer: ask && isAnswer(found.doc) ? 1 : 0 };})-- .sort((a, b) => b.tier - a.tier || (a.thin === b.thin ? 0 : a.thin ? 1 : -1) || a.i - b.i)++ .sort((a, b) => b.tier - a.tier || b.answer - a.answer || (a.thin === b.thin ? 0 : a.thin ? 1 : -1) || a.i - b.i).map(({ hit, found }) => ({ ...hit, found }));}@@@ -457,4 +465,4 @@ function queryRouter({ config, store, engine, auth, limits, vectorStore = null,return router;}--module.exports = { queryRouter, createSearcher, withViewer, parseFilters, QueryError, canView, projection, toMatch, one, fuseRrf };++module.exports = { queryRouter, createSearcher, withViewer, parseFilters, QueryError, canView, projection, toMatch, one, fuseRrf, rankOrder, isQuestion };nnew file mode 100644@@@ -0,0 +1,29 @@++'use strict';++/**++ * A question wants an answer: "how do I stream" ranked four VODs titled "… Stream" above Help's "Can I stream without++ * OBS?" (production, 2026-11-02). Inside a tier, an article comes before a recording when the query starts with a++ * question word; a plain query keeps the fused order; the tiers themselves never move.++ */++const assert = require('assert');++const { rankOrder, isQuestion, toMatch } = require('../server/api/query');++++const docs = {++ 1: { type: 'vod', owner: 'live', title: 'Desktop Stream session with chat', summary: 'A long recorded stream with chat, music and talk about the week, the plans and the people.' },++ 2: { type: 'vod', owner: 'live', title: 'Test Stream from the new camera rig', summary: 'Checking the new camera, the light and the sound before the weekend stream with friends.' },++ 3: { type: 'article', owner: 'help', title: 'Can I stream without OBS?', summary: 'Yes. Go live from your browser on OpenVibe.Live: press Go Live and you are live in seconds.' },++ 4: { type: 'vod', owner: 'live', title: 'Evening chat', summary: 'Talking about how to stream, the gear and the setup that works on a small channel today.' },++};++const store = { byRid: async (rid) => ({ doc: { ...docs[rid], canonical_url: `https://example.test/${rid}` }, row: {} }) };++const fused = [1, 2, 3, 4].map((rid) => ({ rid }));++const order = async (q) => (await rankOrder(fused, { match: toMatch(q), store })).map((h) => h.rid);++++(async () => {++ assert.ok(isQuestion(toMatch('how do I stream')) && isQuestion(toMatch('can I go live')), 'questions');++ assert.ok(!isQuestion(toMatch('stream')) && !isQuestion(toMatch('streaming how')), 'not questions');++ const asked = await order('how do I stream');++ assert.strictEqual(asked[0], 3, `the answer first among the title matches (${asked})`);++ assert.ok(asked.indexOf(4) > asked.indexOf(2), 'a summary mention stays below the title tier');++ const plain = await order('stream');++ assert.deepStrictEqual(plain.slice(0, 3), [1, 2, 3], 'a plain query keeps the fused order inside its tier');++ console.log('question answers: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });
@@ -184,18 +184,26 @@ async function dealsOrder(fused, { match, store }) {
@@ -184,18 +184,26 @@ async function dealsOrder(fused, { match, store }) {
// - a title match (3) outranks a summary-only match (2), which outranks a body-only one (1);
// - a title match (3) outranks a summary-only match (2), which outranks a body-only one (1);
// - a thin username-titled VOD ranks below a real title+summary inside the same tier.
// - a thin username-titled VOD ranks below a real title+summary inside the same tier.
// The fused order is the final tie-break, so relevance still decides among equals.
// The fused order is the final tie-break, so relevance still decides among equals.
+// A question ("how do I stream", "can I go live without OBS") wants an answer: inside its tier, an article (Help's
+// answers, a site's docs) comes before a recording or a post that only shares a word (2026-11-02: "how do I stream"
+// ranked four VODs titled "… Stream" above Help's "Can I stream without OBS?").
+const QUESTION_WORDS = new Set(['how', 'what', 'why', 'when', 'where', 'which', 'who', 'can', 'do', 'does', 'is', 'are', 'should', 'will']);
+const isQuestion = (match) => Boolean(match && match.words.length >= 2 && QUESTION_WORDS.has(match.words[0]));
+const isAnswer = (doc) => Boolean(doc && doc.type === 'article');
+
async function rankOrder(fused, { match, store }) {
async function rankOrder(fused, { match, store }) {
if (!match || !match.words.length) return fused;
if (!match || !match.words.length) return fused;
const hosts = siteHosts(match);
const hosts = siteHosts(match);
+ const ask = isQuestion(match);
const rows = new Map();
const rows = new Map();
for (const hit of fused) rows.set(hit.rid, hit.found !== undefined ? hit.found : await store.byRid(hit.rid));
for (const hit of fused) rows.set(hit.rid, hit.found !== undefined ? hit.found : await store.byRid(hit.rid));
return fused
return fused
.filter((hit) => rows.get(hit.rid))
.filter((hit) => rows.get(hit.rid))
.map((hit, i) => {
.map((hit, i) => {
const found = rows.get(hit.rid);
const found = rows.get(hit.rid);
- return { hit, found, i, tier: docTier(found.doc, match, hosts), thin: isThin(found.doc) };
+ return { hit, found, i, tier: docTier(found.doc, match, hosts), thin: isThin(found.doc), answer: ask && isAnswer(found.doc) ? 1 : 0 };
})
})
- .sort((a, b) => b.tier - a.tier || (a.thin === b.thin ? 0 : a.thin ? 1 : -1) || a.i - b.i)
+ .sort((a, b) => b.tier - a.tier || b.answer - a.answer || (a.thin === b.thin ? 0 : a.thin ? 1 : -1) || a.i - b.i)
.map(({ hit, found }) => ({ ...hit, found }));
.map(({ hit, found }) => ({ ...hit, found }));
}
}
@@ -457,4 +465,4 @@ function queryRouter({ config, store, engine, auth, limits, vectorStore = null,
@@ -457,4 +465,4 @@ function queryRouter({ config, store, engine, auth, limits, vectorStore = null,
return router;
return router;
}
}
-module.exports = { queryRouter, createSearcher, withViewer, parseFilters, QueryError, canView, projection, toMatch, one, fuseRrf };
+module.exports = { queryRouter, createSearcher, withViewer, parseFilters, QueryError, canView, projection, toMatch, one, fuseRrf, rankOrder, isQuestion };
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+'use strict';
+/**
+ * A question wants an answer: "how do I stream" ranked four VODs titled "… Stream" above Help's "Can I stream without
+ * OBS?" (production, 2026-11-02). Inside a tier, an article comes before a recording when the query starts with a
+ * question word; a plain query keeps the fused order; the tiers themselves never move.
+ */
+const assert = require('assert');
+const { rankOrder, isQuestion, toMatch } = require('../server/api/query');
+
+const docs = {
+ 1: { type: 'vod', owner: 'live', title: 'Desktop Stream session with chat', summary: 'A long recorded stream with chat, music and talk about the week, the plans and the people.' },
+ 2: { type: 'vod', owner: 'live', title: 'Test Stream from the new camera rig', summary: 'Checking the new camera, the light and the sound before the weekend stream with friends.' },
+ 3: { type: 'article', owner: 'help', title: 'Can I stream without OBS?', summary: 'Yes. Go live from your browser on OpenVibe.Live: press Go Live and you are live in seconds.' },
+ 4: { type: 'vod', owner: 'live', title: 'Evening chat', summary: 'Talking about how to stream, the gear and the setup that works on a small channel today.' },
+};
+const store = { byRid: async (rid) => ({ doc: { ...docs[rid], canonical_url: `https://example.test/${rid}` }, row: {} }) };
+const fused = [1, 2, 3, 4].map((rid) => ({ rid }));
+const order = async (q) => (await rankOrder(fused, { match: toMatch(q), store })).map((h) => h.rid);
+
+(async () => {
+ assert.ok(isQuestion(toMatch('how do I stream')) && isQuestion(toMatch('can I go live')), 'questions');
+ assert.ok(!isQuestion(toMatch('stream')) && !isQuestion(toMatch('streaming how')), 'not questions');
+ const asked = await order('how do I stream');
+ assert.strictEqual(asked[0], 3, `the answer first among the title matches (${asked})`);
+ assert.ok(asked.indexOf(4) > asked.indexOf(2), 'a summary mention stays below the title tier');
+ const plain = await order('stream');
+ assert.deepStrictEqual(plain.slice(0, 3), [1, 2, 3], 'a plain query keeps the fused order inside its tier');
+ console.log('question answers: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });