openvibers/openvibe.search Commits

A question puts answers first (articles before recordings in a tier) (#191)

OP OpenVibers committed 11 days ago · be8e12f316 · 2 files, +40 −3

Changes

ddiff --git a/server/api/query.js b/server/api/query.js
iindex 78e48e7..cbadc2e 100644
---- a/server/api/query.js
++++ b/server/api/query.js
@@@ -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 };
ddiff --git a/test/question-answers.test.js b/test/question-answers.test.js
nnew file mode 100644
iindex 0000000..526ef8d
---- /dev/null
++++ b/test/question-answers.test.js
@@@ -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); });