entity isolation and estimateTokenFix
This commit is contained in:
@@ -74,4 +74,17 @@ function getEpisodeIdsByEntities(entityIds) {
|
|||||||
).all(...entityIds).map(r => r.episode_id);
|
).all(...entityIds).map(r => r.episode_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getNeighborhood, getEntityNeighbors, getEpisodeIdsByEntities };
|
//Entity IDs linked (via entity_episodes) to any episode in a given session
|
||||||
|
//Scopes non-project entity search to the session's own entities under the "isolated chats" model.
|
||||||
|
//Entities globally deduped
|
||||||
|
function getEntityIdsBySession(sessionId){
|
||||||
|
const db = getDB();
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT DISTINCT ee.entity_id
|
||||||
|
FROM entity_episodes ee
|
||||||
|
JOIN episodes e on e.id = ee.episode_id
|
||||||
|
WHERE e.session_id = ?
|
||||||
|
`).all(sessionId).map(r => r.entity_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getNeighborhood, getEntityNeighbors, getEpisodeIdsByEntities, getEntityIdsBySession };
|
||||||
|
|||||||
@@ -162,6 +162,13 @@ app.get('/sessions/:id/episode-stats', (req, res) => {
|
|||||||
res.json(episodic.getSessionEpisodeStats(Number(req.params.id)));
|
res.json(episodic.getSessionEpisodeStats(Number(req.params.id)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
//Entity IDs linked to this session's episodes: sesion-scoped entity search
|
||||||
|
app.get('/sessions/:id/entity-ids', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
entityIds: graph.getEntityIdsBySession(Number(req.params.id))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Episodes newer than :afterId, chronological — the un-summarized tail.
|
// Episodes newer than :afterId, chronological — the un-summarized tail.
|
||||||
app.get('/sessions/:id/episodes/since/:afterId', (req, res) => {
|
app.get('/sessions/:id/episodes/since/:afterId', (req, res) => {
|
||||||
const episodes = episodic.getEpisodesSince(Number(req.params.id), Number(req.params.afterId));
|
const episodes = episodic.getEpisodesSince(Number(req.params.id), Number(req.params.afterId));
|
||||||
|
|||||||
@@ -127,16 +127,21 @@ async function getSemanticEpisodes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRelevantEntities(vector, projectId = null) {
|
async function getRelevantEntities(vector, { projectId = null, sessionId } = {}) {
|
||||||
if (!vector) return [];
|
if (!vector) return [];
|
||||||
try {
|
try {
|
||||||
const results = await qdrant.searchEntities(vector, { projectId });
|
let allowedIds;
|
||||||
logger.info(
|
if (projectId === null || projectId === undefined) {
|
||||||
'[orchestration] Entity search results:',
|
// Non-project chat is its own island — scope to entities linked to
|
||||||
results.map((r) => ({ name: r.payload?.name, score: r.score })),
|
// THIS session. No links yet ⇒ nothing to retrieve, and we return
|
||||||
);
|
// early so searchEntities is never called unfiltered.
|
||||||
// Include the Qdrant point ID (== SQLite entity ID) for graph traversal
|
allowedIds = await memory.getEntityIdsBySession(sessionId);
|
||||||
return results.map((r) => r.payload ? { id: r.id, ...r.payload } : null).filter(Boolean);
|
if (allowedIds.length === 0) return [];
|
||||||
|
}
|
||||||
|
const results = await qdrant.searchEntities(vector, { projectId, allowedIds });
|
||||||
|
logger.info('[orchestration] Entity search results:',
|
||||||
|
results.map(r => ({ name: r.payload?.name, score: r.score })));
|
||||||
|
return results.map(r => r.payload ? { id: r.id, ...r.payload } : null).filter(Boolean);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug('[orchestration] Entity search failed, continuing without:', err.message);
|
logger.debug('[orchestration] Entity search failed, continuing without:', err.message);
|
||||||
return [];
|
return [];
|
||||||
@@ -302,7 +307,7 @@ async function assembleContext(externalId, userMessage) {
|
|||||||
|
|
||||||
[fusedWithScores, entityResults] = await Promise.all([
|
[fusedWithScores, entityResults] = await Promise.all([
|
||||||
getFusedEpisodes(userMessage, session, recentIds, projectSessionIds, queryVector, { semanticLimit, scoreThreshold, semanticWeight, keywordWeight }),
|
getFusedEpisodes(userMessage, session, recentIds, projectSessionIds, queryVector, { semanticLimit, scoreThreshold, semanticWeight, keywordWeight }),
|
||||||
getRelevantEntities(queryVector, session.project_id ?? null),
|
getRelevantEntities(queryVector, { projectId: session.project_id ?? null, sessionId: session.id }),
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
logger.debug('[orchestration] Trivial turn — skipping semantic/keyword/entity retrieval');
|
logger.debug('[orchestration] Trivial turn — skipping semantic/keyword/entity retrieval');
|
||||||
|
|||||||
@@ -216,6 +216,14 @@ async function getEpisodesByEntities(entityIds) {
|
|||||||
return res.json(); // { episodeIds: [...] }
|
return res.json(); // { episodeIds: [...] }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getEntityIdsBySession(sessionId){
|
||||||
|
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/entity-ids`);
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Entity-ids-by-session error: ${res.status}`);
|
||||||
|
const {entityIds} = await res.jaon();
|
||||||
|
return entityIds;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getSessionByExternalId,
|
getSessionByExternalId,
|
||||||
createSession,
|
createSession,
|
||||||
|
|||||||
@@ -29,38 +29,28 @@ async function searchEpisodes( vector, {limit = ORCHESTRATION.RECENT_EPISODE_LIM
|
|||||||
return data.result;
|
return data.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function searchEntities(vector, { limit = ORCHESTRATION.ENTITIES_LIMIT, scoreThreshold = ORCHESTRATION.ENTITIES_THRESHOLD, projectId = undefined } = {}) {
|
async function searchEntities(vector, { limit = ORCHESTRATION.ENTITIES_LIMIT, scoreThreshold = ORCHESTRATION.ENTITIES_THRESHOLD, projectId, allowedIds } = {}) {
|
||||||
const body = { vector, limit, score_threshold: scoreThreshold, with_payload: true };
|
const body = { vector, limit, score_threshold: scoreThreshold, with_payload: true };
|
||||||
|
|
||||||
// non-project chats must also be filters to the "no project" pool
|
|
||||||
if (projectId !== null && projectId !== undefined) {
|
if (projectId !== null && projectId !== undefined) {
|
||||||
body.filter = {
|
// Project chat: entities shared across the project's sessions.
|
||||||
must: [{ key: 'projectId', match: { value: projectId } }]
|
body.filter = { must: [{ key: 'projectId', match: { value: projectId } }] };
|
||||||
};
|
} else if (allowedIds && allowedIds.length > 0) {
|
||||||
} else {
|
// Non-project chat: restrict to this session's own entities (Model 2).
|
||||||
//entities from project sessions carry a projectId;
|
body.filter = { must: [{ has_id: allowedIds }] };
|
||||||
//without this branch, a non-project chat searches ALL entities and project knowledge leaks into the common pool.
|
|
||||||
//is_empty matches null AND missing payload keys, so pre-isolation-era entities are covered too
|
|
||||||
body.filter = {
|
|
||||||
must: [{ is_empty: {key: 'projectId'} }]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// No else: the caller returns early when a non-project session has no linked
|
||||||
|
// entities, so an unfiltered (leaky) search is never reached.
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${BASE_URL}/collections/${COLLECTIONS.ENTITIES}/points/search`,
|
`${BASE_URL}/collections/${COLLECTIONS.ENTITIES}/points/search`,
|
||||||
{
|
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.text();
|
const text = await res.text();
|
||||||
throw new Error(`Qdrant error: ${res.status} - ${body}`);
|
throw new Error(`Qdrant error: ${res.status} - ${text}`);
|
||||||
}
|
}
|
||||||
|
return (await res.json()).result;
|
||||||
const data = await res.json();
|
|
||||||
return data.result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { searchEpisodes, searchEntities };
|
module.exports = { searchEpisodes, searchEntities };
|
||||||
Reference in New Issue
Block a user