mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(contacts): add core analysis primitives
This commit is contained in:
1 parent
ef71543237
commit
39a97cae95
8 files changed
+1156
-29
No files matched your search
@@ -54,6 +54,24 @@ export {
|
||||
getLongMessageCount,
|
||||
getMemberMonthlyTrend,
|
||||
getTextLengthPercentiles,
|
||||
getGroupContactFacts,
|
||||
getNonSystemMembersForContacts,
|
||||
getPrivateContactFacts,
|
||||
isValidContactPlatformId,
|
||||
resolveOwnerMember,
|
||||
MIN_NON_FRIEND_INTERACTIONS_FOR_HIGH,
|
||||
MIN_NON_FRIENDS_FOR_TIERS,
|
||||
MIN_PRIVATE_MESSAGES_FOR_CORE,
|
||||
MIN_PRIVATE_SESSIONS_FOR_CONTACTS,
|
||||
applyContactOverride,
|
||||
assignFriendTiers,
|
||||
assignNonFriendTiers,
|
||||
computeFriendScore,
|
||||
computeFriendScores,
|
||||
computeNonFriendScore,
|
||||
computeNonFriendScores,
|
||||
computePrivateRegularity,
|
||||
rankPercentiles,
|
||||
queryMessages,
|
||||
searchMessagesLike,
|
||||
searchMessagesByKeywords,
|
||||
@@ -132,6 +150,18 @@ export type {
|
||||
TextStats,
|
||||
TextLengthPercentiles,
|
||||
MemberMonthlyTrend,
|
||||
ContactMemberRef,
|
||||
GroupContactFacts,
|
||||
PrivateContactFacts,
|
||||
AppliedContactOverride,
|
||||
ContactScoringResult,
|
||||
ContactTierAssignment,
|
||||
FriendScoreComponents,
|
||||
FriendScoreInput,
|
||||
FriendTierInput,
|
||||
NonFriendScoreComponents,
|
||||
NonFriendScoreInput,
|
||||
NonFriendTierInput,
|
||||
QueryMessagesOptions,
|
||||
QueryMessagesResult,
|
||||
MessageResult,
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Tests for single-session contact query helpers.
|
||||
*
|
||||
* Run: pnpm test -- packages/core/src/query/__tests__/contact-queries.test.ts
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import Database from 'better-sqlite3'
|
||||
import {
|
||||
getGroupContactFacts,
|
||||
getNonSystemMembersForContacts,
|
||||
getPrivateContactFacts,
|
||||
resolveOwnerMember,
|
||||
} from '../contact-queries'
|
||||
import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../interfaces'
|
||||
|
||||
const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node')
|
||||
|
||||
class Stmt implements PreparedStatement {
|
||||
readonly?: boolean
|
||||
|
||||
constructor(private stmt: Database.Statement) {
|
||||
this.readonly = stmt.readonly
|
||||
}
|
||||
|
||||
get(...p: unknown[]) {
|
||||
return this.stmt.get(...p) as Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
all(...p: unknown[]) {
|
||||
return this.stmt.all(...p) as Record<string, unknown>[]
|
||||
}
|
||||
|
||||
run(...p: unknown[]): RunResult {
|
||||
const r = this.stmt.run(...p)
|
||||
return { changes: r.changes, lastInsertRowid: r.lastInsertRowid }
|
||||
}
|
||||
}
|
||||
|
||||
class Adapter implements DatabaseAdapter {
|
||||
constructor(private db: Database.Database) {}
|
||||
|
||||
exec(sql: string) {
|
||||
this.db.exec(sql)
|
||||
}
|
||||
|
||||
prepare(sql: string) {
|
||||
return new Stmt(this.db.prepare(sql))
|
||||
}
|
||||
|
||||
transaction<T>(fn: () => T): T {
|
||||
return this.db.transaction(fn)()
|
||||
}
|
||||
|
||||
pragma(p: string) {
|
||||
return this.db.pragma(p)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.db.close()
|
||||
}
|
||||
}
|
||||
|
||||
describe('contact query helpers', () => {
|
||||
let raw: Database.Database
|
||||
let db: Adapter
|
||||
|
||||
beforeEach(() => {
|
||||
raw = new Database(':memory:', { nativeBinding })
|
||||
raw.exec(`
|
||||
CREATE TABLE meta (
|
||||
name TEXT,
|
||||
platform TEXT,
|
||||
type TEXT,
|
||||
imported_at INTEGER,
|
||||
owner_id TEXT
|
||||
);
|
||||
CREATE TABLE member (
|
||||
id INTEGER PRIMARY KEY,
|
||||
platform_id TEXT,
|
||||
account_name TEXT,
|
||||
group_nickname TEXT,
|
||||
avatar TEXT
|
||||
);
|
||||
CREATE TABLE message (
|
||||
id INTEGER PRIMARY KEY,
|
||||
sender_id INTEGER,
|
||||
ts INTEGER,
|
||||
type INTEGER,
|
||||
content TEXT,
|
||||
platform_message_id TEXT,
|
||||
reply_to_message_id TEXT
|
||||
);
|
||||
INSERT INTO meta (name, platform, type, imported_at, owner_id)
|
||||
VALUES ('Group', 'wechat', 'group', 1700000000, 'owner-pid');
|
||||
INSERT INTO member (id, platform_id, account_name, group_nickname, avatar) VALUES
|
||||
(1, 'owner-pid', 'Owner', NULL, NULL),
|
||||
(2, 'alice-pid', 'Alice', 'Alice G', 'alice.png'),
|
||||
(3, 'bob-pid', 'Bob', NULL, NULL),
|
||||
(99, 'sys-pid', '系统消息', NULL, NULL);
|
||||
`)
|
||||
db = new Adapter(raw)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
raw.close()
|
||||
})
|
||||
|
||||
it('resolves meta.owner_id to the matching member id', () => {
|
||||
assert.deepEqual(resolveOwnerMember(db), {
|
||||
id: 1,
|
||||
platformId: 'owner-pid',
|
||||
name: 'Owner',
|
||||
avatar: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when owner_id is missing or cannot be matched', () => {
|
||||
raw.exec('UPDATE meta SET owner_id = NULL')
|
||||
assert.equal(resolveOwnerMember(db), null)
|
||||
|
||||
raw.exec("UPDATE meta SET owner_id = 'missing-pid'")
|
||||
assert.equal(resolveOwnerMember(db), null)
|
||||
})
|
||||
|
||||
it('filters out system-message members from contact candidates', () => {
|
||||
const members = getNonSystemMembersForContacts(db)
|
||||
|
||||
assert.deepEqual(
|
||||
members.map((m) => m.platformId),
|
||||
['owner-pid', 'alice-pid', 'bob-pid']
|
||||
)
|
||||
assert.equal(
|
||||
members.find((m) => m.platformId === 'sys-pid'),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('returns private contact facts when the counterpart is unique', () => {
|
||||
raw.exec("UPDATE meta SET type = 'private'")
|
||||
raw.exec('DELETE FROM member WHERE id = 3')
|
||||
const insert = raw.prepare(
|
||||
'INSERT INTO message (id, sender_id, ts, type, content, platform_message_id) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
insert.run(1, 1, 1704103200, 0, 'from owner', 'm1')
|
||||
insert.run(2, 2, 1704103260, 0, 'from alice', 'm2')
|
||||
insert.run(3, 99, 1704103320, 0, 'system', 'm3')
|
||||
insert.run(4, 2, 1706781600, 0, 'next month', 'm4')
|
||||
|
||||
const owner = resolveOwnerMember(db)
|
||||
assert.ok(owner)
|
||||
|
||||
assert.deepEqual(getPrivateContactFacts(db, owner.id), {
|
||||
type: 'ok',
|
||||
contact: {
|
||||
id: 2,
|
||||
platformId: 'alice-pid',
|
||||
name: 'Alice G',
|
||||
avatar: 'alice.png',
|
||||
},
|
||||
privateMessageCount: 3,
|
||||
activeMonths: ['2024-01', '2024-02'],
|
||||
lastMessageTs: 1706781600,
|
||||
})
|
||||
})
|
||||
|
||||
it('marks private sessions with multiple non-owner members as ambiguous', () => {
|
||||
raw.exec("UPDATE meta SET type = 'private'")
|
||||
const owner = resolveOwnerMember(db)
|
||||
assert.ok(owner)
|
||||
|
||||
const result = getPrivateContactFacts(db, owner.id)
|
||||
|
||||
assert.equal(result.type, 'ambiguous')
|
||||
assert.deepEqual(result.type === 'ambiguous' ? result.candidates.map((m) => m.platformId) : [], [
|
||||
'alice-pid',
|
||||
'bob-pid',
|
||||
])
|
||||
})
|
||||
|
||||
it('marks private sessions with no valid counterpart as missing', () => {
|
||||
raw.exec("UPDATE meta SET type = 'private'; DELETE FROM member WHERE id IN (2, 3)")
|
||||
const owner = resolveOwnerMember(db)
|
||||
assert.ok(owner)
|
||||
|
||||
assert.deepEqual(getPrivateContactFacts(db, owner.id), { type: 'missing' })
|
||||
})
|
||||
|
||||
it('returns one group contact fact per non-owner non-system member', () => {
|
||||
const owner = resolveOwnerMember(db)
|
||||
assert.ok(owner)
|
||||
const insert = raw.prepare(
|
||||
'INSERT INTO message (id, sender_id, ts, type, content, platform_message_id) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
insert.run(1, 1, 1704103200, 0, 'owner', 'owner-1')
|
||||
insert.run(2, 2, 1704103260, 0, 'alice', 'alice-1')
|
||||
insert.run(3, 2, 1704103320, 0, 'alice again', 'alice-2')
|
||||
insert.run(4, 99, 1704103380, 0, 'system', 'sys-1')
|
||||
|
||||
const facts = getGroupContactFacts(db, owner.id)
|
||||
|
||||
assert.deepEqual(
|
||||
facts.map((fact) => [fact.contact.platformId, fact.messageCount]),
|
||||
[
|
||||
['alice-pid', 2],
|
||||
['bob-pid', 0],
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
it('counts structured reply interactions in both directions', () => {
|
||||
const owner = resolveOwnerMember(db)
|
||||
assert.ok(owner)
|
||||
const insert = raw.prepare(
|
||||
`INSERT INTO message
|
||||
(id, sender_id, ts, type, content, platform_message_id, reply_to_message_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
insert.run(1, 1, 1704103200, 0, 'owner to group', 'owner-1', null)
|
||||
insert.run(2, 2, 1704103260, 0, 'alice replies owner', 'alice-1', 'owner-1')
|
||||
insert.run(3, 3, 1704103320, 0, 'bob to group', 'bob-1', null)
|
||||
insert.run(4, 1, 1704103380, 0, 'owner replies bob', 'owner-2', 'bob-1')
|
||||
|
||||
const facts = getGroupContactFacts(db, owner.id)
|
||||
const alice = facts.find((fact) => fact.contact.platformId === 'alice-pid')
|
||||
const bob = facts.find((fact) => fact.contact.platformId === 'bob-pid')
|
||||
|
||||
assert.ok(alice)
|
||||
assert.equal(alice.repliesFromContactToOwner, 1)
|
||||
assert.equal(alice.repliesFromOwnerToContact, 0)
|
||||
assert.equal(alice.replyInteractionCount, 1)
|
||||
assert.equal(alice.lastInteractionTs, 1704103260)
|
||||
|
||||
assert.ok(bob)
|
||||
assert.equal(bob.repliesFromContactToOwner, 0)
|
||||
assert.equal(bob.repliesFromOwnerToContact, 1)
|
||||
assert.equal(bob.replyInteractionCount, 1)
|
||||
assert.equal(bob.lastInteractionTs, 1704103380)
|
||||
})
|
||||
|
||||
it('computes owner-contact co-occurrence from nearby group messages', () => {
|
||||
const owner = resolveOwnerMember(db)
|
||||
assert.ok(owner)
|
||||
const insert = raw.prepare(
|
||||
'INSERT INTO message (id, sender_id, ts, type, content, platform_message_id) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
insert.run(1, 1, 1704103200, 0, 'owner starts', 'owner-1')
|
||||
insert.run(2, 2, 1704103201, 0, 'alice follows', 'alice-1')
|
||||
insert.run(3, 1, 1704103202, 0, 'owner again', 'owner-2')
|
||||
insert.run(4, 2, 1704103203, 0, 'alice follows again', 'alice-2')
|
||||
insert.run(5, 3, 1704103800, 0, 'bob much later', 'bob-1')
|
||||
|
||||
const facts = getGroupContactFacts(db, owner.id)
|
||||
const alice = facts.find((fact) => fact.contact.platformId === 'alice-pid')
|
||||
const bob = facts.find((fact) => fact.contact.platformId === 'bob-pid')
|
||||
|
||||
assert.ok(alice)
|
||||
assert.ok(bob)
|
||||
assert.ok(alice.coOccurrenceCount > bob.coOccurrenceCount)
|
||||
assert.ok(alice.coOccurrenceRawScore > bob.coOccurrenceRawScore)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Tests for pure contact scoring and tiering helpers.
|
||||
*
|
||||
* Run: pnpm test -- packages/core/src/query/__tests__/contact-scoring.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
MIN_NON_FRIEND_INTERACTIONS_FOR_HIGH,
|
||||
MIN_NON_FRIENDS_FOR_TIERS,
|
||||
MIN_PRIVATE_MESSAGES_FOR_CORE,
|
||||
applyContactOverride,
|
||||
assignFriendTiers,
|
||||
assignNonFriendTiers,
|
||||
computeFriendScores,
|
||||
computeNonFriendScores,
|
||||
computePrivateRegularity,
|
||||
rankPercentiles,
|
||||
} from '../contact-scoring'
|
||||
|
||||
describe('contact scoring helpers', () => {
|
||||
it('scores private message volume with log1p percentile and bounded components', () => {
|
||||
const contacts = [
|
||||
{ key: 'quiet', privateMessageCount: 0, activeMonths: [], commonGroupCount: 0 },
|
||||
{ key: 'medium', privateMessageCount: 9, activeMonths: ['2024-01'], commonGroupCount: 1 },
|
||||
{ key: 'active', privateMessageCount: 99, activeMonths: ['2024-01', '2024-02'], commonGroupCount: 2 },
|
||||
]
|
||||
|
||||
const expectedMessageScores = rankPercentiles(contacts, (contact) => Math.log1p(contact.privateMessageCount))
|
||||
const scores = computeFriendScores(contacts)
|
||||
|
||||
for (const contact of contacts) {
|
||||
const result = scores.get(contact)
|
||||
assert.ok(result)
|
||||
assert.equal(result.scoreBreakdown.privateMessageScore, expectedMessageScores.get(contact))
|
||||
assert.ok(result.score >= 0 && result.score <= 1)
|
||||
assert.ok((result.scoreBreakdown.privateMessageScore ?? -1) >= 0)
|
||||
assert.ok((result.scoreBreakdown.privateMessageScore ?? 2) <= 1)
|
||||
assert.ok((result.scoreBreakdown.privateRegularityScore ?? -1) >= 0)
|
||||
assert.ok((result.scoreBreakdown.privateRegularityScore ?? 2) <= 1)
|
||||
assert.ok((result.scoreBreakdown.commonGroupScore ?? -1) >= 0)
|
||||
assert.ok((result.scoreBreakdown.commonGroupScore ?? 2) <= 1)
|
||||
}
|
||||
})
|
||||
|
||||
it('computes active-month regularity from active count and span ratio', () => {
|
||||
assert.equal(computePrivateRegularity(['2024-01', '2024-02', '2024-03']), 3)
|
||||
assert.equal(computePrivateRegularity(['2024-01', '2024-12']), 1 / 3)
|
||||
assert.equal(computePrivateRegularity(['2024-05']), 1)
|
||||
assert.ok(computePrivateRegularity(['2024-01', '2024-12']) < computePrivateRegularity(['2024-01', '2024-02']))
|
||||
})
|
||||
|
||||
it('scores non-friends with co-occurrence, common groups, and reply interactions', () => {
|
||||
const contacts = [
|
||||
{ key: 'low', coOccurrenceRawScore: 0, commonGroupCount: 1, replyInteractionCount: 0 },
|
||||
{ key: 'group-overlap', coOccurrenceRawScore: 1, commonGroupCount: 5, replyInteractionCount: 1 },
|
||||
{ key: 'interactive', coOccurrenceRawScore: 5, commonGroupCount: 2, replyInteractionCount: 8 },
|
||||
]
|
||||
|
||||
const scores = computeNonFriendScores(contacts)
|
||||
|
||||
assert.ok(scores.get(contacts[2])!.score > scores.get(contacts[0])!.score)
|
||||
for (const contact of contacts) {
|
||||
const result = scores.get(contact)
|
||||
assert.ok(result)
|
||||
assert.ok(result.score >= 0 && result.score <= 1)
|
||||
assert.ok((result.scoreBreakdown.coOccurrenceScore ?? -1) >= 0)
|
||||
assert.ok((result.scoreBreakdown.coOccurrenceScore ?? 2) <= 1)
|
||||
assert.ok((result.scoreBreakdown.commonGroupScore ?? -1) >= 0)
|
||||
assert.ok((result.scoreBreakdown.commonGroupScore ?? 2) <= 1)
|
||||
assert.ok((result.scoreBreakdown.replyInteractionScore ?? -1) >= 0)
|
||||
assert.ok((result.scoreBreakdown.replyInteractionScore ?? 2) <= 1)
|
||||
}
|
||||
})
|
||||
|
||||
it('assigns friend tiers by top 20, next 50, and last 30 percent cuts', () => {
|
||||
const contacts = Array.from({ length: 10 }, (_, index) => ({
|
||||
key: `friend-${index}`,
|
||||
score: 10 - index,
|
||||
privateMessageCount: MIN_PRIVATE_MESSAGES_FOR_CORE,
|
||||
}))
|
||||
|
||||
const result = assignFriendTiers(contacts)
|
||||
|
||||
assert.equal(result.grouped, true)
|
||||
assert.deepEqual(
|
||||
contacts.map((contact) => result.tiers.get(contact)),
|
||||
['core', 'core', 'friend', 'friend', 'friend', 'friend', 'friend', 'acquaintance', 'acquaintance', 'acquaintance']
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps low-message friends out of core without promoting low-ranked contacts', () => {
|
||||
const contacts = Array.from({ length: 10 }, (_, index) => ({
|
||||
key: `friend-${index}`,
|
||||
score: 10 - index,
|
||||
privateMessageCount:
|
||||
index === 0 || index === 9 ? MIN_PRIVATE_MESSAGES_FOR_CORE - 1 : MIN_PRIVATE_MESSAGES_FOR_CORE,
|
||||
}))
|
||||
|
||||
const result = assignFriendTiers(contacts)
|
||||
|
||||
assert.equal(result.tiers.get(contacts[0]), 'friend')
|
||||
assert.equal(result.tiers.get(contacts[9]), 'acquaintance')
|
||||
})
|
||||
|
||||
it('assigns non-friend tiers, clamps weak high interactions, and supports ungrouped small pools', () => {
|
||||
const contacts = Array.from({ length: MIN_NON_FRIENDS_FOR_TIERS }, (_, index) => ({
|
||||
key: `non-friend-${index}`,
|
||||
score: MIN_NON_FRIENDS_FOR_TIERS - index,
|
||||
coOccurrenceCount: index === 0 ? MIN_NON_FRIEND_INTERACTIONS_FOR_HIGH - 2 : MIN_NON_FRIEND_INTERACTIONS_FOR_HIGH,
|
||||
replyInteractionCount: index === 0 ? 1 : 0,
|
||||
}))
|
||||
|
||||
const grouped = assignNonFriendTiers(contacts)
|
||||
|
||||
assert.equal(grouped.grouped, true)
|
||||
assert.equal(grouped.tiers.get(contacts[0]), 'medium_interaction')
|
||||
assert.equal(grouped.tiers.get(contacts[1]), 'high_interaction')
|
||||
assert.equal(grouped.tiers.get(contacts[7]), 'low_interaction')
|
||||
|
||||
const smallPool = assignNonFriendTiers(contacts.slice(0, MIN_NON_FRIENDS_FOR_TIERS - 1))
|
||||
assert.equal(smallPool.grouped, false)
|
||||
assert.deepEqual(
|
||||
contacts.slice(0, MIN_NON_FRIENDS_FOR_TIERS - 1).map((contact) => smallPool.tiers.get(contact)),
|
||||
Array.from({ length: MIN_NON_FRIENDS_FOR_TIERS - 1 }, () => 'medium_interaction')
|
||||
)
|
||||
})
|
||||
|
||||
it('applies manual locked tiers over algorithm tiers', () => {
|
||||
assert.deepEqual(applyContactOverride('friend', { lockedTier: 'core' }), {
|
||||
tier: 'core',
|
||||
algorithmTier: 'friend',
|
||||
lockedTier: 'core',
|
||||
})
|
||||
assert.deepEqual(applyContactOverride('core', { lockedTier: null }), {
|
||||
tier: 'core',
|
||||
algorithmTier: 'core',
|
||||
lockedTier: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -513,6 +513,18 @@ export interface ClusterGraphData {
|
||||
}
|
||||
}
|
||||
|
||||
export interface CoOccurrenceMessage {
|
||||
senderId: number
|
||||
ts: number
|
||||
}
|
||||
|
||||
export interface CoOccurrencePairStats {
|
||||
sourceId: number
|
||||
targetId: number
|
||||
rawScore: number
|
||||
coOccurrenceCount: number
|
||||
}
|
||||
|
||||
const DEFAULT_CLUSTER_OPTIONS = { lookAhead: 3, decaySeconds: 120, topEdges: 100 }
|
||||
|
||||
function roundNum(value: number, digits = 4): number {
|
||||
@@ -524,6 +536,48 @@ function clusterPairKey(aId: number, bId: number): string {
|
||||
return aId < bId ? `${aId}-${bId}` : `${bId}-${aId}`
|
||||
}
|
||||
|
||||
export function accumulateCoOccurrencePairs(
|
||||
messages: CoOccurrenceMessage[],
|
||||
options?: ClusterGraphOptions
|
||||
): CoOccurrencePairStats[] {
|
||||
const opts = { ...DEFAULT_CLUSTER_OPTIONS, ...options }
|
||||
const pairRawScore = new Map<string, number>()
|
||||
const pairCoOccurrence = new Map<string, number>()
|
||||
|
||||
// 与小团体图保持同一口径:按消息顺序向后寻找不同发言人,并用时间衰减和位置权重累计关系强度。
|
||||
for (let i = 0; i < messages.length - 1; i++) {
|
||||
const anchor = messages[i]
|
||||
const seenPartners = new Set<number>()
|
||||
let partnersFound = 0
|
||||
for (let j = i + 1; j < messages.length && partnersFound < opts.lookAhead; j++) {
|
||||
const candidate = messages[j]
|
||||
if (candidate.senderId === anchor.senderId || seenPartners.has(candidate.senderId)) continue
|
||||
seenPartners.add(candidate.senderId)
|
||||
partnersFound++
|
||||
const deltaSeconds = (candidate.ts - anchor.ts) / 1000
|
||||
const decayWeight = Math.exp(-deltaSeconds / opts.decaySeconds)
|
||||
const positionWeight = 1 - (partnersFound - 1) * 0.2
|
||||
const weight = decayWeight * positionWeight
|
||||
const key = clusterPairKey(anchor.senderId, candidate.senderId)
|
||||
pairRawScore.set(key, (pairRawScore.get(key) || 0) + weight)
|
||||
pairCoOccurrence.set(key, (pairCoOccurrence.get(key) || 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const pairs: CoOccurrencePairStats[] = []
|
||||
for (const [key, rawScore] of pairRawScore) {
|
||||
const [sourceIdStr, targetIdStr] = key.split('-')
|
||||
pairs.push({
|
||||
sourceId: parseInt(sourceIdStr),
|
||||
targetId: parseInt(targetIdStr),
|
||||
rawScore,
|
||||
coOccurrenceCount: pairCoOccurrence.get(key) || 0,
|
||||
})
|
||||
}
|
||||
|
||||
return pairs
|
||||
}
|
||||
|
||||
export function getClusterGraph(
|
||||
db: DatabaseAdapter,
|
||||
filter?: TimeFilter,
|
||||
@@ -577,28 +631,6 @@ export function getClusterGraph(
|
||||
for (const msg of messages) memberMsgCount.set(msg.senderId, (memberMsgCount.get(msg.senderId) || 0) + 1)
|
||||
const totalMessages = messages.length
|
||||
|
||||
const pairRawScore = new Map<string, number>()
|
||||
const pairCoOccurrence = new Map<string, number>()
|
||||
|
||||
for (let i = 0; i < messages.length - 1; i++) {
|
||||
const anchor = messages[i]
|
||||
const seenPartners = new Set<number>()
|
||||
let partnersFound = 0
|
||||
for (let j = i + 1; j < messages.length && partnersFound < opts.lookAhead; j++) {
|
||||
const candidate = messages[j]
|
||||
if (candidate.senderId === anchor.senderId || seenPartners.has(candidate.senderId)) continue
|
||||
seenPartners.add(candidate.senderId)
|
||||
partnersFound++
|
||||
const deltaSeconds = (candidate.ts - anchor.ts) / 1000
|
||||
const decayWeight = Math.exp(-deltaSeconds / opts.decaySeconds)
|
||||
const positionWeight = 1 - (partnersFound - 1) * 0.2
|
||||
const weight = decayWeight * positionWeight
|
||||
const key = clusterPairKey(anchor.senderId, candidate.senderId)
|
||||
pairRawScore.set(key, (pairRawScore.get(key) || 0) + weight)
|
||||
pairCoOccurrence.set(key, (pairCoOccurrence.get(key) || 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const lookAheadFactor = opts.lookAhead * 0.8
|
||||
const rawEdges: Array<{
|
||||
sourceId: number
|
||||
@@ -609,21 +641,20 @@ export function getClusterGraph(
|
||||
coOccurrenceCount: number
|
||||
}> = []
|
||||
|
||||
for (const [key, rawScore] of pairRawScore) {
|
||||
const [aIdStr, bIdStr] = key.split('-')
|
||||
const aId = parseInt(aIdStr)
|
||||
const bId = parseInt(bIdStr)
|
||||
for (const pair of accumulateCoOccurrencePairs(messages, opts)) {
|
||||
const aId = pair.sourceId
|
||||
const bId = pair.targetId
|
||||
const aMsgCount = memberMsgCount.get(aId) || 0
|
||||
const bMsgCount = memberMsgCount.get(bId) || 0
|
||||
const expectedScore = ((aMsgCount * bMsgCount) / totalMessages) * lookAheadFactor
|
||||
const normalizedScore = expectedScore > 0 ? rawScore / expectedScore : 0
|
||||
const normalizedScore = expectedScore > 0 ? pair.rawScore / expectedScore : 0
|
||||
rawEdges.push({
|
||||
sourceId: aId,
|
||||
targetId: bId,
|
||||
rawScore,
|
||||
rawScore: pair.rawScore,
|
||||
expectedScore,
|
||||
normalizedScore,
|
||||
coOccurrenceCount: pairCoOccurrence.get(key) || 0,
|
||||
coOccurrenceCount: pair.coOccurrenceCount,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { DatabaseAdapter } from '../interfaces'
|
||||
import { accumulateCoOccurrencePairs } from './advanced/social'
|
||||
|
||||
export interface ContactMemberRef {
|
||||
id: number
|
||||
platformId: string
|
||||
name: string
|
||||
avatar: string | null
|
||||
}
|
||||
|
||||
export type PrivateContactFacts =
|
||||
| {
|
||||
type: 'ok'
|
||||
contact: ContactMemberRef
|
||||
privateMessageCount: number
|
||||
activeMonths: string[]
|
||||
lastMessageTs: number | null
|
||||
}
|
||||
| { type: 'missing' }
|
||||
| { type: 'ambiguous'; candidates: ContactMemberRef[] }
|
||||
|
||||
export interface GroupContactFacts {
|
||||
contact: ContactMemberRef
|
||||
messageCount: number
|
||||
coOccurrenceCount: number
|
||||
coOccurrenceRawScore: number
|
||||
replyInteractionCount: number
|
||||
repliesFromOwnerToContact: number
|
||||
repliesFromContactToOwner: number
|
||||
lastInteractionTs: number | null
|
||||
}
|
||||
|
||||
export function isValidContactPlatformId(platformId: string | null | undefined): platformId is string {
|
||||
return typeof platformId === 'string' && platformId.trim().length > 0
|
||||
}
|
||||
|
||||
export function resolveOwnerMember(db: DatabaseAdapter): ContactMemberRef | null {
|
||||
const meta = db.prepare('SELECT owner_id FROM meta LIMIT 1').get() as { owner_id: string | null } | undefined
|
||||
if (!isValidContactPlatformId(meta?.owner_id)) return null
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
id,
|
||||
platform_id as platformId,
|
||||
COALESCE(group_nickname, account_name, platform_id) as name,
|
||||
avatar
|
||||
FROM member
|
||||
WHERE platform_id = ? AND COALESCE(account_name, '') != '系统消息'
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(meta.owner_id) as ContactMemberRef | undefined
|
||||
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
export function getNonSystemMembersForContacts(db: DatabaseAdapter): ContactMemberRef[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
id,
|
||||
platform_id as platformId,
|
||||
COALESCE(group_nickname, account_name, platform_id) as name,
|
||||
avatar
|
||||
FROM member
|
||||
WHERE COALESCE(account_name, '') != '系统消息'
|
||||
ORDER BY id ASC`
|
||||
)
|
||||
.all() as unknown as ContactMemberRef[]
|
||||
|
||||
return rows.filter((row) => isValidContactPlatformId(row.platformId))
|
||||
}
|
||||
|
||||
export function getPrivateContactFacts(db: DatabaseAdapter, ownerMemberId: number): 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 countRow = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
WHERE COALESCE(m.account_name, '') != '系统消息'`
|
||||
)
|
||||
.get() as { count: number } | undefined
|
||||
|
||||
const monthRows = db
|
||||
.prepare(
|
||||
`SELECT DISTINCT strftime('%Y-%m', msg.ts, 'unixepoch', 'localtime') as month
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
WHERE COALESCE(m.account_name, '') != '系统消息'
|
||||
ORDER BY month ASC`
|
||||
)
|
||||
.all() as Array<{ month: string }>
|
||||
|
||||
const lastRow = db
|
||||
.prepare(
|
||||
`SELECT MAX(msg.ts) as lastMessageTs
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
WHERE COALESCE(m.account_name, '') != '系统消息'`
|
||||
)
|
||||
.get() as { lastMessageTs: number | null } | undefined
|
||||
|
||||
return {
|
||||
type: 'ok',
|
||||
contact: candidates[0],
|
||||
privateMessageCount: countRow?.count ?? 0,
|
||||
activeMonths: monthRows.map((row) => row.month).filter(Boolean),
|
||||
lastMessageTs: lastRow?.lastMessageTs ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function getGroupContactFacts(db: DatabaseAdapter, ownerMemberId: number): GroupContactFacts[] {
|
||||
const contacts = getNonSystemMembersForContacts(db).filter((member) => member.id !== ownerMemberId)
|
||||
const messageRows = db
|
||||
.prepare(
|
||||
`SELECT msg.sender_id as senderId, COUNT(*) as messageCount
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
WHERE COALESCE(m.account_name, '') != '系统消息'
|
||||
GROUP BY msg.sender_id`
|
||||
)
|
||||
.all() as Array<{ senderId: number; messageCount: number }>
|
||||
|
||||
const messageCounts = new Map(messageRows.map((row) => [row.senderId, row.messageCount]))
|
||||
const contactById = new Map(contacts.map((contact) => [contact.id, contact]))
|
||||
const coOccurrenceRows = db
|
||||
.prepare(
|
||||
`SELECT msg.sender_id as senderId, msg.ts as ts
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
WHERE COALESCE(m.account_name, '') != '系统消息'
|
||||
ORDER BY msg.ts ASC, msg.id ASC`
|
||||
)
|
||||
.all() as Array<{ senderId: number; ts: number }>
|
||||
const coOccurrenceStats = new Map<number, { coOccurrenceCount: number; coOccurrenceRawScore: number }>()
|
||||
|
||||
// 共现算法会产出任意成员对;联系人页只消费 owner 与候选联系人的关系边。
|
||||
for (const pair of accumulateCoOccurrencePairs(coOccurrenceRows)) {
|
||||
const contactId =
|
||||
pair.sourceId === ownerMemberId && contactById.has(pair.targetId)
|
||||
? pair.targetId
|
||||
: pair.targetId === ownerMemberId && contactById.has(pair.sourceId)
|
||||
? pair.sourceId
|
||||
: null
|
||||
if (contactId === null) continue
|
||||
coOccurrenceStats.set(contactId, {
|
||||
coOccurrenceCount: pair.coOccurrenceCount,
|
||||
coOccurrenceRawScore: pair.rawScore,
|
||||
})
|
||||
}
|
||||
const replyStats = new Map<
|
||||
number,
|
||||
{
|
||||
repliesFromOwnerToContact: number
|
||||
repliesFromContactToOwner: number
|
||||
lastInteractionTs: number | null
|
||||
}
|
||||
>()
|
||||
|
||||
const replyRows = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
msg.sender_id as replySenderId,
|
||||
msg.ts as replyTs,
|
||||
target.sender_id as targetSenderId
|
||||
FROM message msg
|
||||
JOIN message target ON msg.reply_to_message_id = target.platform_message_id
|
||||
JOIN member sender ON msg.sender_id = sender.id
|
||||
JOIN member targetMember ON target.sender_id = targetMember.id
|
||||
WHERE msg.reply_to_message_id IS NOT NULL
|
||||
AND COALESCE(sender.account_name, '') != '系统消息'
|
||||
AND COALESCE(targetMember.account_name, '') != '系统消息'`
|
||||
)
|
||||
.all() as Array<{ replySenderId: number; replyTs: number; targetSenderId: number }>
|
||||
|
||||
const ensureReplyStats = (contactId: number) => {
|
||||
const existing = replyStats.get(contactId)
|
||||
if (existing) return existing
|
||||
const created = { repliesFromOwnerToContact: 0, repliesFromContactToOwner: 0, lastInteractionTs: null }
|
||||
replyStats.set(contactId, created)
|
||||
return created
|
||||
}
|
||||
|
||||
for (const row of replyRows) {
|
||||
if (row.replySenderId === ownerMemberId && contactById.has(row.targetSenderId)) {
|
||||
const stats = ensureReplyStats(row.targetSenderId)
|
||||
stats.repliesFromOwnerToContact++
|
||||
stats.lastInteractionTs = Math.max(stats.lastInteractionTs ?? 0, row.replyTs)
|
||||
} else if (row.targetSenderId === ownerMemberId && contactById.has(row.replySenderId)) {
|
||||
const stats = ensureReplyStats(row.replySenderId)
|
||||
stats.repliesFromContactToOwner++
|
||||
stats.lastInteractionTs = Math.max(stats.lastInteractionTs ?? 0, row.replyTs)
|
||||
}
|
||||
}
|
||||
|
||||
return contacts.map((contact) => {
|
||||
const stats = replyStats.get(contact.id) ?? {
|
||||
repliesFromOwnerToContact: 0,
|
||||
repliesFromContactToOwner: 0,
|
||||
lastInteractionTs: null,
|
||||
}
|
||||
const coOccurrence = coOccurrenceStats.get(contact.id)
|
||||
const replyInteractionCount = stats.repliesFromOwnerToContact + stats.repliesFromContactToOwner
|
||||
return {
|
||||
contact,
|
||||
messageCount: messageCounts.get(contact.id) ?? 0,
|
||||
coOccurrenceCount: coOccurrence?.coOccurrenceCount ?? 0,
|
||||
coOccurrenceRawScore: coOccurrence?.coOccurrenceRawScore ?? 0,
|
||||
replyInteractionCount,
|
||||
repliesFromOwnerToContact: stats.repliesFromOwnerToContact,
|
||||
repliesFromContactToOwner: stats.repliesFromContactToOwner,
|
||||
lastInteractionTs: stats.lastInteractionTs,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import type { ContactOverride, ContactScoreBreakdown, ContactTier } from '@openchatlab/shared-types'
|
||||
|
||||
export const MIN_PRIVATE_SESSIONS_FOR_CONTACTS = 10
|
||||
export const MIN_PRIVATE_MESSAGES_FOR_CORE = 50
|
||||
export const MIN_NON_FRIENDS_FOR_TIERS = 10
|
||||
export const MIN_NON_FRIEND_INTERACTIONS_FOR_HIGH = 5
|
||||
|
||||
const FRIEND_SCORE_WEIGHTS = {
|
||||
privateMessage: 0.55,
|
||||
privateRegularity: 0.25,
|
||||
commonGroup: 0.2,
|
||||
}
|
||||
|
||||
const NON_FRIEND_SCORE_WEIGHTS = {
|
||||
coOccurrence: 0.5,
|
||||
commonGroup: 0.25,
|
||||
replyInteraction: 0.25,
|
||||
}
|
||||
|
||||
export interface FriendScoreInput {
|
||||
privateMessageCount: number
|
||||
activeMonths: readonly string[]
|
||||
commonGroupCount: number
|
||||
}
|
||||
|
||||
export interface NonFriendScoreInput {
|
||||
coOccurrenceRawScore: number
|
||||
commonGroupCount: number
|
||||
replyInteractionCount: number
|
||||
coOccurrenceCount?: number
|
||||
repliesFromOwnerToContact?: number
|
||||
repliesFromContactToOwner?: number
|
||||
}
|
||||
|
||||
export interface FriendScoreComponents {
|
||||
privateMessageScore: number
|
||||
privateRegularityScore: number
|
||||
commonGroupScore: number
|
||||
privateMessageCount?: number
|
||||
activePrivateMonths?: number
|
||||
commonGroupCount?: number
|
||||
}
|
||||
|
||||
export interface NonFriendScoreComponents {
|
||||
coOccurrenceScore: number
|
||||
commonGroupScore: number
|
||||
replyInteractionScore: number
|
||||
commonGroupCount?: number
|
||||
coOccurrenceCount?: number
|
||||
coOccurrenceRawScore?: number
|
||||
replyInteractionCount?: number
|
||||
repliesFromOwnerToContact?: number
|
||||
repliesFromContactToOwner?: number
|
||||
}
|
||||
|
||||
export interface ContactScoringResult {
|
||||
score: number
|
||||
scoreBreakdown: ContactScoreBreakdown
|
||||
}
|
||||
|
||||
export interface FriendTierInput {
|
||||
score: number
|
||||
privateMessageCount: number
|
||||
}
|
||||
|
||||
export interface NonFriendTierInput {
|
||||
score: number
|
||||
coOccurrenceCount: number
|
||||
replyInteractionCount: number
|
||||
}
|
||||
|
||||
export interface ContactTierAssignment<T> {
|
||||
grouped: boolean
|
||||
tiers: Map<T, ContactTier>
|
||||
}
|
||||
|
||||
export interface AppliedContactOverride {
|
||||
tier: ContactTier
|
||||
algorithmTier: ContactTier
|
||||
lockedTier: ContactTier | null
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
if (value < 0) return 0
|
||||
if (value > 1) return 1
|
||||
return value
|
||||
}
|
||||
|
||||
function nonNegative(value: number | null | undefined): number {
|
||||
return Number.isFinite(value) && value !== null && value !== undefined ? Math.max(0, value) : 0
|
||||
}
|
||||
|
||||
function parseMonthIndex(month: string): number | null {
|
||||
const match = /^(\d{4})-(\d{2})$/.exec(month)
|
||||
if (!match) return null
|
||||
|
||||
const year = Number.parseInt(match[1], 10)
|
||||
const monthNumber = Number.parseInt(match[2], 10)
|
||||
if (!Number.isFinite(year) || monthNumber < 1 || monthNumber > 12) return null
|
||||
|
||||
return year * 12 + monthNumber - 1
|
||||
}
|
||||
|
||||
function getActiveMonthIndexes(activeMonths: readonly string[]): number[] {
|
||||
return [...new Set(activeMonths.map(parseMonthIndex).filter((month): month is number => month !== null))].sort(
|
||||
(a, b) => a - b
|
||||
)
|
||||
}
|
||||
|
||||
export function rankPercentiles<T>(items: readonly T[], valueSelector: (item: T) => number): Map<T, number> {
|
||||
const result = new Map<T, number>()
|
||||
if (items.length === 0) return result
|
||||
|
||||
const values = items.map((item) => ({ item, value: nonNegative(valueSelector(item)) }))
|
||||
const min = Math.min(...values.map((entry) => entry.value))
|
||||
const max = Math.max(...values.map((entry) => entry.value))
|
||||
|
||||
if (min === max) {
|
||||
const percentile = max > 0 ? 1 : 0
|
||||
for (const entry of values) result.set(entry.item, percentile)
|
||||
return result
|
||||
}
|
||||
|
||||
const sorted = [...values].sort((a, b) => a.value - b.value)
|
||||
let index = 0
|
||||
while (index < sorted.length) {
|
||||
let end = index
|
||||
while (end + 1 < sorted.length && sorted[end + 1].value === sorted[index].value) end++
|
||||
const percentile = (index + end) / 2 / (sorted.length - 1)
|
||||
for (let i = index; i <= end; i++) result.set(sorted[i].item, percentile)
|
||||
index = end + 1
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function computePrivateRegularity(activeMonths: readonly string[]): number {
|
||||
const monthIndexes = getActiveMonthIndexes(activeMonths)
|
||||
if (monthIndexes.length === 0) return 0
|
||||
|
||||
const activeCount = monthIndexes.length
|
||||
const spanMonths = monthIndexes[monthIndexes.length - 1] - monthIndexes[0] + 1
|
||||
|
||||
// 定期性衡量持续规律:活跃月越多越高,跨度很大但只零星出现会被均匀度压低。
|
||||
return activeCount * (activeCount / spanMonths)
|
||||
}
|
||||
|
||||
export function computeFriendScore(components: FriendScoreComponents): ContactScoringResult {
|
||||
const privateMessageScore = clamp01(components.privateMessageScore)
|
||||
const privateRegularityScore = clamp01(components.privateRegularityScore)
|
||||
const commonGroupScore = clamp01(components.commonGroupScore)
|
||||
const score =
|
||||
FRIEND_SCORE_WEIGHTS.privateMessage * privateMessageScore +
|
||||
FRIEND_SCORE_WEIGHTS.privateRegularity * privateRegularityScore +
|
||||
FRIEND_SCORE_WEIGHTS.commonGroup * commonGroupScore
|
||||
|
||||
return {
|
||||
score,
|
||||
scoreBreakdown: {
|
||||
privateMessageScore,
|
||||
privateRegularityScore,
|
||||
commonGroupScore,
|
||||
privateMessageCount: components.privateMessageCount,
|
||||
activePrivateMonths: components.activePrivateMonths,
|
||||
commonGroupCount: components.commonGroupCount,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function computeFriendScores<T extends FriendScoreInput>(items: readonly T[]): Map<T, ContactScoringResult> {
|
||||
const messageScores = rankPercentiles(items, (item) => Math.log1p(nonNegative(item.privateMessageCount)))
|
||||
const regularityByItem = new Map(items.map((item) => [item, computePrivateRegularity(item.activeMonths)]))
|
||||
const regularityScores = rankPercentiles(items, (item) => regularityByItem.get(item) ?? 0)
|
||||
const groupScores = rankPercentiles(items, (item) => nonNegative(item.commonGroupCount))
|
||||
const result = new Map<T, ContactScoringResult>()
|
||||
|
||||
for (const item of items) {
|
||||
result.set(
|
||||
item,
|
||||
computeFriendScore({
|
||||
privateMessageScore: messageScores.get(item) ?? 0,
|
||||
privateRegularityScore: regularityScores.get(item) ?? 0,
|
||||
commonGroupScore: groupScores.get(item) ?? 0,
|
||||
privateMessageCount: nonNegative(item.privateMessageCount),
|
||||
activePrivateMonths: getActiveMonthIndexes(item.activeMonths).length,
|
||||
commonGroupCount: nonNegative(item.commonGroupCount),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function computeNonFriendScore(components: NonFriendScoreComponents): ContactScoringResult {
|
||||
const coOccurrenceScore = clamp01(components.coOccurrenceScore)
|
||||
const commonGroupScore = clamp01(components.commonGroupScore)
|
||||
const replyInteractionScore = clamp01(components.replyInteractionScore)
|
||||
const score =
|
||||
NON_FRIEND_SCORE_WEIGHTS.coOccurrence * coOccurrenceScore +
|
||||
NON_FRIEND_SCORE_WEIGHTS.commonGroup * commonGroupScore +
|
||||
NON_FRIEND_SCORE_WEIGHTS.replyInteraction * replyInteractionScore
|
||||
|
||||
return {
|
||||
score,
|
||||
scoreBreakdown: {
|
||||
coOccurrenceScore,
|
||||
commonGroupScore,
|
||||
replyInteractionScore,
|
||||
commonGroupCount: components.commonGroupCount,
|
||||
coOccurrenceCount: components.coOccurrenceCount,
|
||||
coOccurrenceRawScore: components.coOccurrenceRawScore,
|
||||
replyInteractionCount: components.replyInteractionCount,
|
||||
repliesFromOwnerToContact: components.repliesFromOwnerToContact,
|
||||
repliesFromContactToOwner: components.repliesFromContactToOwner,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function computeNonFriendScores<T extends NonFriendScoreInput>(
|
||||
items: readonly T[]
|
||||
): Map<T, ContactScoringResult> {
|
||||
const coOccurrenceScores = rankPercentiles(items, (item) => nonNegative(item.coOccurrenceRawScore))
|
||||
const groupScores = rankPercentiles(items, (item) => nonNegative(item.commonGroupCount))
|
||||
const replyScores = rankPercentiles(items, (item) => nonNegative(item.replyInteractionCount))
|
||||
const result = new Map<T, ContactScoringResult>()
|
||||
|
||||
for (const item of items) {
|
||||
result.set(
|
||||
item,
|
||||
computeNonFriendScore({
|
||||
coOccurrenceScore: coOccurrenceScores.get(item) ?? 0,
|
||||
commonGroupScore: groupScores.get(item) ?? 0,
|
||||
replyInteractionScore: replyScores.get(item) ?? 0,
|
||||
commonGroupCount: nonNegative(item.commonGroupCount),
|
||||
coOccurrenceCount: nonNegative(item.coOccurrenceCount),
|
||||
coOccurrenceRawScore: nonNegative(item.coOccurrenceRawScore),
|
||||
replyInteractionCount: nonNegative(item.replyInteractionCount),
|
||||
repliesFromOwnerToContact: nonNegative(item.repliesFromOwnerToContact),
|
||||
repliesFromContactToOwner: nonNegative(item.repliesFromContactToOwner),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function assignByPercentileCuts<T extends { score: number }>(
|
||||
items: readonly T[],
|
||||
highTier: ContactTier,
|
||||
mediumTier: ContactTier,
|
||||
lowTier: ContactTier,
|
||||
clampHighTier?: (item: T) => ContactTier | null
|
||||
): ContactTierAssignment<T> {
|
||||
const sorted = [...items].sort((a, b) => nonNegative(b.score) - nonNegative(a.score))
|
||||
const tiers = new Map<T, ContactTier>()
|
||||
if (sorted.length === 0) return { grouped: true, tiers }
|
||||
|
||||
const highCount = Math.ceil(sorted.length * 0.2)
|
||||
const mediumCut = Math.ceil(sorted.length * 0.7)
|
||||
|
||||
for (let index = 0; index < sorted.length; index++) {
|
||||
const item = sorted[index]
|
||||
let tier = index < highCount ? highTier : index < mediumCut ? mediumTier : lowTier
|
||||
if (tier === highTier) tier = clampHighTier?.(item) ?? tier
|
||||
tiers.set(item, tier)
|
||||
}
|
||||
|
||||
return { grouped: true, tiers }
|
||||
}
|
||||
|
||||
export function assignFriendTiers<T extends FriendTierInput>(items: readonly T[]): ContactTierAssignment<T> {
|
||||
return assignByPercentileCuts(items, 'core', 'friend', 'acquaintance', (item) =>
|
||||
nonNegative(item.privateMessageCount) < MIN_PRIVATE_MESSAGES_FOR_CORE ? 'friend' : null
|
||||
)
|
||||
}
|
||||
|
||||
export function assignNonFriendTiers<T extends NonFriendTierInput>(items: readonly T[]): ContactTierAssignment<T> {
|
||||
if (items.length < MIN_NON_FRIENDS_FOR_TIERS) {
|
||||
return {
|
||||
grouped: false,
|
||||
tiers: new Map(items.map((item) => [item, 'medium_interaction' as ContactTier])),
|
||||
}
|
||||
}
|
||||
|
||||
return assignByPercentileCuts(items, 'high_interaction', 'medium_interaction', 'low_interaction', (item) => {
|
||||
const totalInteractions = nonNegative(item.coOccurrenceCount) + nonNegative(item.replyInteractionCount)
|
||||
return totalInteractions < MIN_NON_FRIEND_INTERACTIONS_FOR_HIGH ? 'medium_interaction' : null
|
||||
})
|
||||
}
|
||||
|
||||
export function applyContactOverride(
|
||||
algorithmTier: ContactTier,
|
||||
override?: ContactOverride | null
|
||||
): AppliedContactOverride {
|
||||
const lockedTier = override?.lockedTier ?? null
|
||||
|
||||
return {
|
||||
tier: lockedTier ?? algorithmTier,
|
||||
algorithmTier,
|
||||
lockedTier,
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,42 @@ export type {
|
||||
MemberMonthlyTrend,
|
||||
} from './basic-queries'
|
||||
|
||||
export {
|
||||
getGroupContactFacts,
|
||||
getNonSystemMembersForContacts,
|
||||
getPrivateContactFacts,
|
||||
isValidContactPlatformId,
|
||||
resolveOwnerMember,
|
||||
} from './contact-queries'
|
||||
export type { ContactMemberRef, GroupContactFacts, PrivateContactFacts } from './contact-queries'
|
||||
|
||||
export {
|
||||
MIN_NON_FRIEND_INTERACTIONS_FOR_HIGH,
|
||||
MIN_NON_FRIENDS_FOR_TIERS,
|
||||
MIN_PRIVATE_MESSAGES_FOR_CORE,
|
||||
MIN_PRIVATE_SESSIONS_FOR_CONTACTS,
|
||||
applyContactOverride,
|
||||
assignFriendTiers,
|
||||
assignNonFriendTiers,
|
||||
computeFriendScore,
|
||||
computeFriendScores,
|
||||
computeNonFriendScore,
|
||||
computeNonFriendScores,
|
||||
computePrivateRegularity,
|
||||
rankPercentiles,
|
||||
} from './contact-scoring'
|
||||
export type {
|
||||
AppliedContactOverride,
|
||||
ContactScoringResult,
|
||||
ContactTierAssignment,
|
||||
FriendScoreComponents,
|
||||
FriendScoreInput,
|
||||
FriendTierInput,
|
||||
NonFriendScoreComponents,
|
||||
NonFriendScoreInput,
|
||||
NonFriendTierInput,
|
||||
} from './contact-scoring'
|
||||
|
||||
export {
|
||||
queryMessages,
|
||||
searchMessagesLike,
|
||||
|
||||
@@ -226,6 +226,108 @@ export interface SetOwnerAndApplyProfileResult {
|
||||
updatedSessionOwnerIds: Record<string, string>
|
||||
}
|
||||
|
||||
// ==================== Contacts (cross-session relationship view) ====================
|
||||
|
||||
export type ContactTier =
|
||||
| 'core'
|
||||
| 'friend'
|
||||
| 'acquaintance'
|
||||
| 'high_interaction'
|
||||
| 'medium_interaction'
|
||||
| 'low_interaction'
|
||||
|
||||
export type ContactPool = 'friend' | 'non_friend'
|
||||
|
||||
export type ContactsCacheStatus = 'fresh' | 'stale' | 'missing'
|
||||
|
||||
export interface ContactOverride {
|
||||
lockedTier?: ContactTier | null
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
export interface ContactOverridePatch {
|
||||
lockedTier?: ContactTier | null
|
||||
}
|
||||
|
||||
export interface ContactScoreBreakdown {
|
||||
privateMessageScore?: number
|
||||
privateRegularityScore?: number
|
||||
commonGroupScore?: number
|
||||
coOccurrenceScore?: number
|
||||
replyInteractionScore?: number
|
||||
privateMessageCount?: number
|
||||
activePrivateMonths?: number
|
||||
commonGroupCount?: number
|
||||
coOccurrenceCount?: number
|
||||
coOccurrenceRawScore?: number
|
||||
replyInteractionCount?: number
|
||||
repliesFromOwnerToContact?: number
|
||||
repliesFromContactToOwner?: number
|
||||
}
|
||||
|
||||
export interface ContactSourceSession {
|
||||
id: string
|
||||
name: string
|
||||
platform: ChatPlatform
|
||||
type: ChatType
|
||||
messageCount?: number
|
||||
privateMessageCount?: number
|
||||
coOccurrenceCount?: number
|
||||
coOccurrenceRawScore?: number
|
||||
replyInteractionCount?: number
|
||||
repliesFromOwnerToContact?: number
|
||||
repliesFromContactToOwner?: number
|
||||
lastMessageTs?: number | null
|
||||
lastInteractionTs?: number | null
|
||||
}
|
||||
|
||||
export interface ContactItem {
|
||||
key: string
|
||||
platform: ChatPlatform
|
||||
platformId: string
|
||||
sessionScoped: boolean
|
||||
sessionId?: string
|
||||
displayName: string
|
||||
aliases: string[]
|
||||
avatar: string | null
|
||||
isFriend: boolean
|
||||
pool: ContactPool
|
||||
tier: ContactTier
|
||||
algorithmTier: ContactTier
|
||||
lockedTier: ContactTier | null
|
||||
score: number
|
||||
scoreBreakdown: ContactScoreBreakdown
|
||||
sourceSessions: ContactSourceSession[]
|
||||
searchText: string
|
||||
lastInteractionTs: number | null
|
||||
}
|
||||
|
||||
export interface ContactsDiagnostics {
|
||||
privateSessionCount: number
|
||||
contactsEnabled: boolean
|
||||
skippedMissingOwnerSessions: number
|
||||
skippedUnresolvedOwnerSessions: number
|
||||
skippedAmbiguousPrivateSessions: number
|
||||
skippedInvalidPlatformIdMembers: number
|
||||
skippedFailedSessions: number
|
||||
hiddenLowInteractionNonFriends: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface ContactsCacheState {
|
||||
status: ContactsCacheStatus
|
||||
computedAt: number | null
|
||||
signature?: string
|
||||
staleReason?: string
|
||||
}
|
||||
|
||||
export interface ContactsResponse {
|
||||
contacts: ContactItem[]
|
||||
diagnostics: ContactsDiagnostics
|
||||
cache: ContactsCacheState
|
||||
algorithmVersion: string
|
||||
}
|
||||
|
||||
export interface Preferences {
|
||||
pinnedSessionIds: string[]
|
||||
aiPreprocessConfig: AIPreprocessConfig
|
||||
|
||||
Reference in new issue
Block a user