feat(contacts): add core analysis primitives

This commit is contained in:
digua committed 2026-06-27 15:54:08 +08:00
1 parent ef71543237
commit 39a97cae95
8 files changed
+1156 -29

No files matched your search

+30
View File
@@ -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,
})
})
})
+60 -29
View File
@@ -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,
})
}
+219
View File
@@ -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,
}
})
}
+303
View File
@@ -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,
}
}
+36
View File
@@ -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,
+102
View File
@@ -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