mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(relationships): rank active close contacts accurately
This commit is contained in:
1 parent
3afc2a7329
commit
d04eddc37c
7 files changed
+218
-26
No files matched your search
@@ -236,6 +236,25 @@ describe('contact query helpers', () => {
|
||||
assert.equal(result.type === 'ok' ? result.lastMessageTs : null, 1704103200)
|
||||
})
|
||||
|
||||
it('does not mark private sessions ambiguous when extra members are inactive in the selected range', () => {
|
||||
raw.exec("UPDATE meta SET type = 'private'")
|
||||
const insert = raw.prepare(
|
||||
'INSERT INTO message (id, sender_id, ts, type, content, platform_message_id) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
insert.run(1, 3, 1600000000, 0, 'old bob residue', 'old-bob')
|
||||
insert.run(2, 1, 1704103200, 0, 'from owner', 'owner-1')
|
||||
insert.run(3, 2, 1704103260, 0, 'from alice', 'alice-1')
|
||||
|
||||
const owner = resolveOwnerMember(db)
|
||||
assert.ok(owner)
|
||||
|
||||
const result = getPrivateContactFacts(db, owner.id, { startTs: 1700000000 })
|
||||
|
||||
assert.equal(result.type, 'ok')
|
||||
assert.equal(result.type === 'ok' ? result.contact.platformId : null, 'alice-pid')
|
||||
assert.equal(result.type === 'ok' ? result.privateMessageCount : null, 2)
|
||||
})
|
||||
|
||||
it('marks private sessions with multiple non-owner members as ambiguous', () => {
|
||||
raw.exec("UPDATE meta SET type = 'private'")
|
||||
const owner = resolveOwnerMember(db)
|
||||
|
||||
@@ -127,9 +127,25 @@ export function getPrivateContactFacts(
|
||||
): PrivateContactFacts {
|
||||
const candidates = getNonSystemMembersForContacts(db).filter((member) => member.id !== ownerMemberId)
|
||||
if (candidates.length === 0) return { type: 'missing' }
|
||||
if (candidates.length > 1) return { type: 'ambiguous', candidates }
|
||||
|
||||
const timeFilter = createMessageTimeFilter('msg', options.startTs)
|
||||
const candidateById = new Map(candidates.map((candidate) => [candidate.id, candidate]))
|
||||
const activeCandidateRows = db
|
||||
.prepare(
|
||||
`SELECT msg.sender_id as senderId
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
WHERE ${nonSystemMessageCondition('msg', 'm')}
|
||||
AND msg.sender_id <> ?${timeFilter.sql}
|
||||
GROUP BY msg.sender_id`
|
||||
)
|
||||
.all(ownerMemberId, ...timeFilter.params) as Array<{ senderId: number }>
|
||||
const activeCandidates = activeCandidateRows
|
||||
.map((row) => candidateById.get(row.senderId))
|
||||
.filter((candidate): candidate is ContactMemberRef => Boolean(candidate))
|
||||
const resolvedCandidates = activeCandidates.length > 0 ? activeCandidates : candidates
|
||||
if (resolvedCandidates.length > 1) return { type: 'ambiguous', candidates: resolvedCandidates }
|
||||
|
||||
const countRow = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as count
|
||||
@@ -160,7 +176,7 @@ export function getPrivateContactFacts(
|
||||
|
||||
return {
|
||||
type: 'ok',
|
||||
contact: candidates[0],
|
||||
contact: resolvedCandidates[0],
|
||||
privateMessageCount: countRow?.count ?? 0,
|
||||
activeMonths: monthRows.map((row) => row.month).filter(Boolean),
|
||||
lastMessageTs: lastRow?.lastMessageTs ?? null,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { CHAT_DB_SCHEMA } from '@openchatlab/core'
|
||||
import type { DatabaseAdapter } from '@openchatlab/core'
|
||||
import { openBetterSqliteDatabase } from '../../../better-sqlite3-adapter'
|
||||
import type { SessionRuntimeAdapter } from '../../adapters'
|
||||
import { computePeopleRelationshipsSnapshot } from './compute'
|
||||
import { computePeopleRelationshipsSnapshot, PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION } from './compute'
|
||||
|
||||
const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node')
|
||||
|
||||
@@ -180,7 +180,7 @@ test('computes a cropped relationship galaxy from private contacts and group int
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(snapshot.algorithmVersion, 'people-relationships-v1')
|
||||
assert.equal(snapshot.algorithmVersion, PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION)
|
||||
const owner = snapshot.nodes.find((node) => node.kind === 'owner')
|
||||
assert.ok(owner)
|
||||
assert.equal(owner.displayName, 'Me')
|
||||
@@ -218,3 +218,97 @@ test('computes a cropped relationship galaxy from private contacts and group int
|
||||
assert.equal(snapshot.diagnostics.processedGroupSessions, 1)
|
||||
assert.equal(snapshot.diagnostics.processedPrivateSessions, 1)
|
||||
})
|
||||
|
||||
test('prefers recent owner edges when cropping dense relationship graph edges', (t) => {
|
||||
const env = new TestEnv()
|
||||
t.after(() => env.cleanup())
|
||||
const day = 24 * 60 * 60
|
||||
const staleTs = 1700000000
|
||||
const recentTs = staleTs + day * 240
|
||||
|
||||
env.seed({
|
||||
id: 'private-stale',
|
||||
platform: 'weixin',
|
||||
type: 'private',
|
||||
ownerId: 'owner',
|
||||
members: [
|
||||
{ id: 1, platformId: 'owner', accountName: 'Me' },
|
||||
{ id: 2, platformId: 'stale', accountName: 'Stale' },
|
||||
],
|
||||
messages: Array.from({ length: 100 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
senderId: index % 2 === 0 ? 1 : 2,
|
||||
ts: staleTs + index,
|
||||
})),
|
||||
})
|
||||
env.seed({
|
||||
id: 'private-recent',
|
||||
platform: 'weixin',
|
||||
type: 'private',
|
||||
ownerId: 'owner',
|
||||
members: [
|
||||
{ id: 1, platformId: 'owner', accountName: 'Me' },
|
||||
{ id: 2, platformId: 'recent', accountName: 'Recent' },
|
||||
],
|
||||
messages: Array.from({ length: 60 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
senderId: index % 2 === 0 ? 1 : 2,
|
||||
ts: recentTs + index,
|
||||
})),
|
||||
})
|
||||
|
||||
const snapshot = computePeopleRelationshipsSnapshot({
|
||||
adapter: env.adapter,
|
||||
signature: 'sig-1',
|
||||
timeRangePreset: 'all',
|
||||
limits: {
|
||||
coreNodeLimit: 10,
|
||||
coreEdgeLimit: 10,
|
||||
perNodeEdgeLimit: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const owner = snapshot.graph.nodes.find((node) => node.kind === 'owner')
|
||||
assert.ok(owner)
|
||||
const ownerEdges = snapshot.graph.edges.filter((edge) => edge.sourceKey === owner.key || edge.targetKey === owner.key)
|
||||
|
||||
assert.equal(ownerEdges.length, 1)
|
||||
assert.equal(ownerEdges[0]?.targetKey, 'weixin:recent')
|
||||
})
|
||||
|
||||
test('keeps enough owner edges for the collapsed connection ranking', (t) => {
|
||||
const env = new TestEnv()
|
||||
t.after(() => env.cleanup())
|
||||
|
||||
for (let index = 0; index < 11; index++) {
|
||||
const contactId = `contact-${index + 1}`
|
||||
env.seed({
|
||||
id: `private-${contactId}`,
|
||||
platform: 'weixin',
|
||||
type: 'private',
|
||||
ownerId: 'owner',
|
||||
members: [
|
||||
{ id: 1, platformId: 'owner', accountName: 'Me' },
|
||||
{ id: 2, platformId: contactId, accountName: contactId },
|
||||
],
|
||||
messages: [
|
||||
{ id: 1, senderId: 1, ts: 1704103200 + index * 100 },
|
||||
{ id: 2, senderId: 2, ts: 1704103201 + index * 100 },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const snapshot = computePeopleRelationshipsSnapshot({
|
||||
adapter: env.adapter,
|
||||
signature: 'sig-1',
|
||||
timeRangePreset: 'all',
|
||||
})
|
||||
|
||||
const owner = snapshot.graph.nodes.find((node) => node.kind === 'owner')
|
||||
assert.ok(owner)
|
||||
|
||||
assert.equal(
|
||||
snapshot.graph.edges.filter((edge) => edge.sourceKey === owner.key || edge.targetKey === owner.key).length >= 10,
|
||||
true
|
||||
)
|
||||
})
|
||||
@@ -46,6 +46,8 @@ const PRIVATE_COMMUNITY_ID = 'private'
|
||||
const OWNER_KEY_PREFIX = 'owner'
|
||||
const REPLY_WEIGHT = 3
|
||||
const CO_OCCURRENCE_COUNT_WEIGHT = 0.05
|
||||
const EDGE_RECENCY_HALF_LIFE_SECONDS = 120 * 24 * 60 * 60
|
||||
const EDGE_RECENCY_FLOOR = 0.1
|
||||
|
||||
const COMMUNITY_COLORS = [
|
||||
'#7dd3fc',
|
||||
@@ -200,19 +202,18 @@ export function buildPeopleRelationshipsNeighborhoodGraph(
|
||||
const center = snapshot.nodes.find((node) => node.key === contactKey)
|
||||
if (!center) return { nodes: [], edges: [], communities: [] }
|
||||
|
||||
const relatedEdges = snapshot.edges
|
||||
.filter((edge) => edge.sourceKey === contactKey || edge.targetKey === contactKey)
|
||||
.sort(compareEdges)
|
||||
const relatedEdges = sortEdgesForDisplay(
|
||||
snapshot.edges.filter((edge) => edge.sourceKey === contactKey || edge.targetKey === contactKey)
|
||||
)
|
||||
const nodeKeys = new Set<string>([contactKey])
|
||||
for (const edge of relatedEdges) {
|
||||
if (nodeKeys.size >= normalizedLimits.neighborhoodNodeLimit) break
|
||||
nodeKeys.add(edge.sourceKey === contactKey ? edge.targetKey : edge.sourceKey)
|
||||
}
|
||||
|
||||
const edges = snapshot.edges
|
||||
.filter((edge) => nodeKeys.has(edge.sourceKey) && nodeKeys.has(edge.targetKey))
|
||||
.sort(compareEdges)
|
||||
.slice(0, normalizedLimits.neighborhoodEdgeLimit)
|
||||
const edges = sortEdgesForDisplay(
|
||||
snapshot.edges.filter((edge) => nodeKeys.has(edge.sourceKey) && nodeKeys.has(edge.targetKey))
|
||||
).slice(0, normalizedLimits.neighborhoodEdgeLimit)
|
||||
const nodes = snapshot.nodes.filter((node) => nodeKeys.has(node.key)).sort(compareNodes)
|
||||
return {
|
||||
nodes,
|
||||
@@ -564,7 +565,7 @@ function cropEdges(
|
||||
): PeopleRelationshipGraphEdge[] {
|
||||
const counts = new Map<string, number>()
|
||||
const kept: PeopleRelationshipGraphEdge[] = []
|
||||
for (const edge of edges) {
|
||||
for (const edge of sortEdgesForDisplay(edges)) {
|
||||
if (kept.length >= limits.coreEdgeLimit) break
|
||||
const sourceCount = counts.get(edge.sourceKey) ?? 0
|
||||
const targetCount = counts.get(edge.targetKey) ?? 0
|
||||
@@ -963,7 +964,7 @@ function normalizeLimits(limits: PeopleRelationshipsComputeLimits = {}): Require
|
||||
return {
|
||||
coreNodeLimit: normalizePositiveLimit(limits.coreNodeLimit, 1500),
|
||||
coreEdgeLimit: normalizePositiveLimit(limits.coreEdgeLimit, 6000),
|
||||
perNodeEdgeLimit: normalizePositiveLimit(limits.perNodeEdgeLimit, 8),
|
||||
perNodeEdgeLimit: normalizePositiveLimit(limits.perNodeEdgeLimit, 12),
|
||||
neighborhoodNodeLimit: normalizePositiveLimit(limits.neighborhoodNodeLimit, 80),
|
||||
neighborhoodEdgeLimit: normalizePositiveLimit(limits.neighborhoodEdgeLimit, 240),
|
||||
searchResultLimit: normalizePositiveLimit(limits.searchResultLimit, 20),
|
||||
@@ -1025,6 +1026,30 @@ export function compareEdges(a: PeopleRelationshipGraphEdge, b: PeopleRelationsh
|
||||
return b.weight - a.weight || a.id.localeCompare(b.id)
|
||||
}
|
||||
|
||||
function sortEdgesForDisplay(edges: PeopleRelationshipGraphEdge[]): PeopleRelationshipGraphEdge[] {
|
||||
const anchorTs = edges.reduce((max, edge) => Math.max(max, edge.lastInteractionTs ?? 0), 0)
|
||||
return [...edges].sort((a, b) => compareEdgesForDisplay(a, b, anchorTs))
|
||||
}
|
||||
|
||||
function compareEdgesForDisplay(
|
||||
a: PeopleRelationshipGraphEdge,
|
||||
b: PeopleRelationshipGraphEdge,
|
||||
anchorTs: number
|
||||
): number {
|
||||
const bRecentWeight = getRecentEdgeWeight(b, anchorTs)
|
||||
const aRecentWeight = getRecentEdgeWeight(a, anchorTs)
|
||||
if (bRecentWeight !== aRecentWeight) return bRecentWeight - aRecentWeight
|
||||
return compareEdges(a, b)
|
||||
}
|
||||
|
||||
function getRecentEdgeWeight(edge: PeopleRelationshipGraphEdge, anchorTs: number): number {
|
||||
if (!edge.lastInteractionTs || anchorTs <= 0) return edge.weight
|
||||
const ageSeconds = Math.max(0, anchorTs - edge.lastInteractionTs)
|
||||
const recencyFactor =
|
||||
EDGE_RECENCY_FLOOR + (1 - EDGE_RECENCY_FLOOR) * Math.pow(0.5, ageSeconds / EDGE_RECENCY_HALF_LIFE_SECONDS)
|
||||
return edge.weight * recencyFactor
|
||||
}
|
||||
|
||||
function roundNum(value: number, digits = 4): number {
|
||||
const factor = 10 ** digits
|
||||
return Math.round(value * factor) / factor
|
||||
|
||||
@@ -139,6 +139,10 @@ function makeAdapter(signatureSeed = 'stable'): SessionRuntimeAdapter {
|
||||
} as SessionRuntimeAdapter
|
||||
}
|
||||
|
||||
function makeFreshSignature(): string {
|
||||
return `algorithm:${PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION}|range:1y|session-a:missing`
|
||||
}
|
||||
|
||||
test('returns search results from all snapshot nodes, including nodes outside the core graph', () => {
|
||||
const dir = makeTempDir()
|
||||
try {
|
||||
@@ -149,7 +153,7 @@ test('returns search results from all snapshot nodes, including nodes outside th
|
||||
throw new Error('runner should not be called for fresh injected snapshot')
|
||||
},
|
||||
})
|
||||
service.replaceSnapshotForTests?.(makeSnapshot('algorithm:people-relationships-v1|range:1y|session-a:missing'))
|
||||
service.replaceSnapshotForTests?.(makeSnapshot(makeFreshSignature()))
|
||||
|
||||
const response = service.getGraph({ acceptStale: true, query: 'carol' })
|
||||
|
||||
@@ -176,7 +180,7 @@ test('returns the owner node from full snapshot search', () => {
|
||||
throw new Error('runner should not be called for fresh injected snapshot')
|
||||
},
|
||||
})
|
||||
service.replaceSnapshotForTests?.(makeSnapshot('algorithm:people-relationships-v1|range:1y|session-a:missing'))
|
||||
service.replaceSnapshotForTests?.(makeSnapshot(makeFreshSignature()))
|
||||
|
||||
const response = service.getGraph({ acceptStale: true, query: '我' })
|
||||
|
||||
@@ -199,7 +203,7 @@ test('returns neighborhood graph for a searched node outside the core graph', ()
|
||||
throw new Error('runner should not be called for fresh injected snapshot')
|
||||
},
|
||||
})
|
||||
service.replaceSnapshotForTests?.(makeSnapshot('algorithm:people-relationships-v1|range:1y|session-a:missing'))
|
||||
service.replaceSnapshotForTests?.(makeSnapshot(makeFreshSignature()))
|
||||
|
||||
const response = service.getNeighborhood('weixin:carol', { acceptStale: true })
|
||||
|
||||
@@ -235,7 +239,7 @@ test('starts a background task when snapshot is missing and returns task state i
|
||||
assert.equal(response.cache.status, 'missing')
|
||||
assert.equal(response.task?.status, 'running')
|
||||
assert.equal(runnerCalls, 1)
|
||||
releaseRunner(makeSnapshot('algorithm:people-relationships-v1|range:1y|session-a:missing'))
|
||||
releaseRunner(makeSnapshot(makeFreshSignature()))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await service.close()
|
||||
} finally {
|
||||
|
||||
@@ -94,6 +94,22 @@ test('builds selected node connection ranking by edge weight', () => {
|
||||
assert.equal(ranking.items[0]?.edge.weight, 12)
|
||||
})
|
||||
|
||||
test('prefers recent selected-node connections over stale high-volume edges', () => {
|
||||
const day = 24 * 60 * 60
|
||||
const data: PeopleRelationshipsGraphData = {
|
||||
nodes: [node('owner', 1), node('recent', 2), node('stale', 3)],
|
||||
edges: [edge('owner', 'stale', 100, 100), edge('owner', 'recent', 60, 100 + day * 240)],
|
||||
communities: [],
|
||||
}
|
||||
|
||||
const ranking = buildRelationshipConnectionRanking(data, 'owner', { expanded: true })
|
||||
|
||||
assert.deepEqual(
|
||||
ranking.items.map((item) => item.node.key),
|
||||
['recent', 'stale']
|
||||
)
|
||||
})
|
||||
|
||||
test('expands selected node connection ranking to all visible neighbors', () => {
|
||||
const ranking = buildRelationshipConnectionRanking(graph(), 'owner', { expanded: true })
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface RelationshipConnectionRanking {
|
||||
}
|
||||
|
||||
const DEFAULT_COLLAPSED_LIMIT = 10
|
||||
const CONNECTION_RECENCY_HALF_LIFE_SECONDS = 120 * 24 * 60 * 60
|
||||
const CONNECTION_RECENCY_FLOOR = 0.1
|
||||
|
||||
export function buildRelationshipConnectionRanking(
|
||||
graph: PeopleRelationshipsGraphData,
|
||||
@@ -30,14 +32,14 @@ export function buildRelationshipConnectionRanking(
|
||||
if (!selectedKey) return { items: [], total: 0, hasMore: false }
|
||||
|
||||
const nodeByKey = new Map(graph.nodes.map((node) => [node.key, node]))
|
||||
const items = graph.edges
|
||||
.flatMap((edge): RelationshipConnectionRankingItem[] => {
|
||||
if (edge.sourceKey !== selectedKey && edge.targetKey !== selectedKey) return []
|
||||
const otherKey = edge.sourceKey === selectedKey ? edge.targetKey : edge.sourceKey
|
||||
const node = nodeByKey.get(otherKey)
|
||||
return node ? [{ node, edge }] : []
|
||||
})
|
||||
.sort(compareConnectionItems)
|
||||
const items = graph.edges.flatMap((edge): RelationshipConnectionRankingItem[] => {
|
||||
if (edge.sourceKey !== selectedKey && edge.targetKey !== selectedKey) return []
|
||||
const otherKey = edge.sourceKey === selectedKey ? edge.targetKey : edge.sourceKey
|
||||
const node = nodeByKey.get(otherKey)
|
||||
return node ? [{ node, edge }] : []
|
||||
})
|
||||
const anchorTs = items.reduce((max, item) => Math.max(max, item.edge.lastInteractionTs ?? 0), 0)
|
||||
items.sort((a, b) => compareConnectionItems(a, b, anchorTs))
|
||||
|
||||
const collapsedLimit = options.collapsedLimit ?? DEFAULT_COLLAPSED_LIMIT
|
||||
const visibleItems = options.expanded ? items : items.slice(0, collapsedLimit)
|
||||
@@ -49,7 +51,14 @@ export function buildRelationshipConnectionRanking(
|
||||
}
|
||||
}
|
||||
|
||||
function compareConnectionItems(a: RelationshipConnectionRankingItem, b: RelationshipConnectionRankingItem): number {
|
||||
function compareConnectionItems(
|
||||
a: RelationshipConnectionRankingItem,
|
||||
b: RelationshipConnectionRankingItem,
|
||||
anchorTs: number
|
||||
): number {
|
||||
const bRecentWeight = getRecentConnectionWeight(b.edge, anchorTs)
|
||||
const aRecentWeight = getRecentConnectionWeight(a.edge, anchorTs)
|
||||
if (bRecentWeight !== aRecentWeight) return bRecentWeight - aRecentWeight
|
||||
if (b.edge.weight !== a.edge.weight) return b.edge.weight - a.edge.weight
|
||||
const bLast = b.edge.lastInteractionTs ?? 0
|
||||
const aLast = a.edge.lastInteractionTs ?? 0
|
||||
@@ -57,3 +66,12 @@ function compareConnectionItems(a: RelationshipConnectionRankingItem, b: Relatio
|
||||
if (a.node.rank !== b.node.rank) return a.node.rank - b.node.rank
|
||||
return a.node.key.localeCompare(b.node.key)
|
||||
}
|
||||
|
||||
function getRecentConnectionWeight(edge: PeopleRelationshipGraphEdge, anchorTs: number): number {
|
||||
if (!edge.lastInteractionTs || anchorTs <= 0) return edge.weight
|
||||
const ageSeconds = Math.max(0, anchorTs - edge.lastInteractionTs)
|
||||
const recencyFactor =
|
||||
CONNECTION_RECENCY_FLOOR +
|
||||
(1 - CONNECTION_RECENCY_FLOOR) * Math.pow(0.5, ageSeconds / CONNECTION_RECENCY_HALF_LIFE_SECONDS)
|
||||
return edge.weight * recencyFactor
|
||||
}
|
||||
Reference in new issue
Block a user