mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(contacts): simplify contacts experience
This commit is contained in:
1 parent
11152a7c4b
commit
d48665b2ed
24 files changed
+744
-1198
No files matched your search
@@ -365,7 +365,6 @@ export * as summaryService from './services/summary-service'
|
||||
export * as exportService from './services/export-service'
|
||||
export * as ownerProfileService from './services/owner-profile-service'
|
||||
export * as contactsService from './services/contacts-service'
|
||||
export * as contactsOverrides from './services/contacts-overrides'
|
||||
// Semantic index (Phase 1 vector search) — independent of legacy ai/rag
|
||||
export * as semanticIndex from './semantic-index'
|
||||
export {
|
||||
@@ -395,9 +394,7 @@ export type {
|
||||
|
||||
export {
|
||||
CONTACTS_ALGORITHM_VERSION,
|
||||
ContactsOverridesManager,
|
||||
MergeSessionCache,
|
||||
buildContactOverrideKey,
|
||||
createContactsService,
|
||||
createDatabaseManagerAdapter,
|
||||
pushImport,
|
||||
@@ -412,8 +409,6 @@ export type {
|
||||
ApplyOwnerProfileReason,
|
||||
ApplyOwnerProfileResult,
|
||||
SetOwnerAndApplyProfileResult,
|
||||
BuildContactOverrideKeyInput,
|
||||
ContactsOverrides,
|
||||
ContactsService,
|
||||
ContactsServiceDeps,
|
||||
ContactsServiceOptions,
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Tests for contacts override persistence.
|
||||
*
|
||||
* Run: pnpm test -- packages/node-runtime/src/services/contacts-overrides.test.ts
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { initAppLogger } from '../logging/app-logger'
|
||||
import { ContactsOverridesManager, buildContactOverrideKey } from './contacts-overrides'
|
||||
|
||||
function makeTempDir(): string {
|
||||
const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()
|
||||
return fs.mkdtempSync(path.join(baseDir, 'chatlab-contacts-overrides-'))
|
||||
}
|
||||
|
||||
function readJson(filePath: string): unknown {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
||||
}
|
||||
|
||||
test('loads empty overrides when contacts-overrides.json is missing', (t) => {
|
||||
const dir = makeTempDir()
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
|
||||
|
||||
const manager = new ContactsOverridesManager(dir)
|
||||
|
||||
assert.equal(manager.getFilePath(), path.join(dir, 'contacts-overrides.json'))
|
||||
assert.deepEqual(manager.load(), {})
|
||||
assert.equal(manager.getSignaturePart(), '-')
|
||||
})
|
||||
|
||||
test('saveOverride writes valid JSON and deleteOverride removes entries', (t) => {
|
||||
const dir = makeTempDir()
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
|
||||
const manager = new ContactsOverridesManager(dir)
|
||||
|
||||
const saved = manager.saveOverride('weixin:wxid_a', { lockedTier: 'core' })
|
||||
assert.equal(saved.lockedTier, 'core')
|
||||
assert.equal(typeof saved.updatedAt, 'number')
|
||||
|
||||
assert.deepEqual(readJson(manager.getFilePath()), {
|
||||
'weixin:wxid_a': saved,
|
||||
})
|
||||
|
||||
manager.deleteOverride('weixin:wxid_a')
|
||||
assert.deepEqual(readJson(manager.getFilePath()), {})
|
||||
})
|
||||
|
||||
test('buildContactOverrideKey uses stable ids and session scope for name-match platforms', () => {
|
||||
assert.equal(buildContactOverrideKey({ platform: 'weixin', platformId: 'wxid_a' }), 'weixin:wxid_a')
|
||||
assert.equal(
|
||||
buildContactOverrideKey({ platform: 'whatsapp', platformId: 'Alice', sessionId: 'session-1', matchMode: 'name' }),
|
||||
'whatsapp:session-1:Alice'
|
||||
)
|
||||
assert.equal(
|
||||
buildContactOverrideKey({ platform: 'line', platformId: 'Bob', sessionId: 'line-session' }),
|
||||
'line:line-session:Bob'
|
||||
)
|
||||
assert.throws(() => buildContactOverrideKey({ platform: 'instagram', platformId: 'Carol' }), /sessionId is required/)
|
||||
})
|
||||
|
||||
test('corrupt override file is backed up and loads empty overrides', (t) => {
|
||||
const dir = makeTempDir()
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
|
||||
initAppLogger(path.join(dir, 'logs'))
|
||||
const filePath = path.join(dir, 'contacts-overrides.json')
|
||||
fs.writeFileSync(filePath, '{ broken', 'utf-8')
|
||||
|
||||
const manager = new ContactsOverridesManager(dir)
|
||||
assert.deepEqual(manager.load(), {})
|
||||
|
||||
const backupsDir = path.join(dir, 'backups')
|
||||
const backups = fs.readdirSync(backupsDir).filter((name) => name.startsWith('contacts-overrides-corrupt-'))
|
||||
assert.equal(backups.length, 1)
|
||||
assert.equal(fs.readFileSync(path.join(backupsDir, backups[0]), 'utf-8'), '{ broken')
|
||||
|
||||
const log = fs.readFileSync(path.join(dir, 'logs', 'app.log'), 'utf-8')
|
||||
assert.match(log, /contacts overrides file is corrupt/)
|
||||
})
|
||||
|
||||
test('atomic write leaves complete JSON and no temp file behind', (t) => {
|
||||
const dir = makeTempDir()
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
|
||||
const manager = new ContactsOverridesManager(dir)
|
||||
|
||||
manager.saveOverride('qq:10086', { lockedTier: 'core' })
|
||||
manager.saveOverride('qq:10010', { lockedTier: 'friend' })
|
||||
|
||||
const raw = fs.readFileSync(manager.getFilePath(), 'utf-8')
|
||||
assert.doesNotThrow(() => JSON.parse(raw))
|
||||
assert.deepEqual(Object.keys(readJson(manager.getFilePath()) as Record<string, unknown>).sort(), [
|
||||
'qq:10010',
|
||||
'qq:10086',
|
||||
])
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(dir).filter((name) => name.includes('contacts-overrides') && name.endsWith('.tmp')),
|
||||
[]
|
||||
)
|
||||
assert.notEqual(manager.getSignaturePart(), '-')
|
||||
})
|
||||
@@ -1,158 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { isNameMatchPlatform } from '@openchatlab/core'
|
||||
import type {
|
||||
ChatPlatform,
|
||||
ContactOverride,
|
||||
ContactOverridePatch,
|
||||
ContactTier,
|
||||
OwnerMatchMode,
|
||||
} from '@openchatlab/shared-types'
|
||||
import { appLogger } from '../logging/app-logger'
|
||||
|
||||
export type ContactsOverrides = Record<string, ContactOverride>
|
||||
|
||||
export interface BuildContactOverrideKeyInput {
|
||||
platform: ChatPlatform
|
||||
platformId: string
|
||||
sessionId?: string
|
||||
matchMode?: OwnerMatchMode
|
||||
}
|
||||
|
||||
const OVERRIDES_FILE = 'contacts-overrides.json'
|
||||
const VALID_CONTACT_TIERS: ReadonlySet<ContactTier> = new Set([
|
||||
'core',
|
||||
'friend',
|
||||
'acquaintance',
|
||||
'high_interaction',
|
||||
'medium_interaction',
|
||||
'low_interaction',
|
||||
])
|
||||
|
||||
export function buildContactOverrideKey(input: BuildContactOverrideKeyInput): string {
|
||||
const platform = input.platform.trim()
|
||||
const platformId = input.platformId.trim()
|
||||
if (!platform) throw new Error('platform is required')
|
||||
if (!platformId) throw new Error('platformId is required')
|
||||
|
||||
const sessionScoped = input.matchMode === 'name' || isNameMatchPlatform(platform)
|
||||
if (!sessionScoped) return `${platform}:${platformId}`
|
||||
|
||||
const sessionId = input.sessionId?.trim()
|
||||
if (!sessionId) throw new Error('sessionId is required for session-scoped contact override keys')
|
||||
return `${platform}:${sessionId}:${platformId}`
|
||||
}
|
||||
|
||||
export class ContactsOverridesManager {
|
||||
private readonly filePath: string
|
||||
|
||||
constructor(systemDir: string) {
|
||||
this.filePath = path.join(systemDir, OVERRIDES_FILE)
|
||||
}
|
||||
|
||||
getFilePath(): string {
|
||||
return this.filePath
|
||||
}
|
||||
|
||||
load(): ContactsOverrides {
|
||||
if (!fs.existsSync(this.filePath)) return {}
|
||||
|
||||
let raw = ''
|
||||
try {
|
||||
raw = fs.readFileSync(this.filePath, 'utf-8')
|
||||
return normalizeOverrides(JSON.parse(raw))
|
||||
} catch (error) {
|
||||
this.backupCorruptFile(raw)
|
||||
appLogger.warn('contacts', 'contacts overrides file is corrupt', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
saveOverride(key: string, patch: ContactOverridePatch): ContactOverride {
|
||||
const overrides = this.load()
|
||||
const current = overrides[key] ?? {}
|
||||
const next = normalizeOverride({
|
||||
...current,
|
||||
...patch,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
overrides[key] = next
|
||||
this.writeOverrides(overrides)
|
||||
return next
|
||||
}
|
||||
|
||||
deleteOverride(key: string): void {
|
||||
const overrides = this.load()
|
||||
delete overrides[key]
|
||||
this.writeOverrides(overrides)
|
||||
}
|
||||
|
||||
getSignaturePart(): string {
|
||||
try {
|
||||
const stat = fs.statSync(this.filePath)
|
||||
return `${Math.floor(stat.mtimeMs)}:${stat.size}`
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
private writeOverrides(overrides: ContactsOverrides): void {
|
||||
const dir = path.dirname(this.filePath)
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
||||
|
||||
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`
|
||||
try {
|
||||
fs.writeFileSync(tempPath, `${JSON.stringify(overrides, null, 2)}\n`, 'utf-8')
|
||||
fs.renameSync(tempPath, this.filePath)
|
||||
} catch (error) {
|
||||
try {
|
||||
fs.rmSync(tempPath, { force: true })
|
||||
} catch {
|
||||
// ignore cleanup failures; the original write error is the actionable one
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private backupCorruptFile(raw: string): void {
|
||||
try {
|
||||
const backupDir = path.join(path.dirname(this.filePath), 'backups')
|
||||
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true })
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const backupPath = path.join(backupDir, `contacts-overrides-corrupt-${timestamp}.json`)
|
||||
fs.writeFileSync(backupPath, raw, 'utf-8')
|
||||
} catch (error) {
|
||||
appLogger.warn('contacts', 'failed to back up corrupt contacts overrides file', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOverrides(value: unknown): ContactsOverrides {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
|
||||
const result: ContactsOverrides = {}
|
||||
for (const [key, override] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (!key.trim()) continue
|
||||
if (!override || typeof override !== 'object' || Array.isArray(override)) continue
|
||||
result[key] = normalizeOverride(override as ContactOverride)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function normalizeOverride(override: ContactOverride): ContactOverride {
|
||||
const normalized: ContactOverride = {}
|
||||
|
||||
if (override.lockedTier === null || isValidContactTier(override.lockedTier)) {
|
||||
normalized.lockedTier = override.lockedTier
|
||||
}
|
||||
if (typeof override.updatedAt === 'number' && Number.isFinite(override.updatedAt)) {
|
||||
normalized.updatedAt = override.updatedAt
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function isValidContactTier(value: unknown): value is ContactTier {
|
||||
return typeof value === 'string' && VALID_CONTACT_TIERS.has(value as ContactTier)
|
||||
}
|
||||
@@ -192,7 +192,7 @@ test('aggregates stable-id contacts across private and group sessions', (t) => {
|
||||
],
|
||||
})
|
||||
|
||||
const result = createContactsService({ adapter: env.adapter, systemDir: env.dir }).getContacts()
|
||||
const result = createContactsService({ adapter: env.adapter }).getContacts()
|
||||
const byKey = new Map(result.contacts.map((contact) => [contact.key, contact]))
|
||||
const alice = byKey.get('weixin:alice')
|
||||
const bob = byKey.get('weixin:bob')
|
||||
@@ -247,7 +247,7 @@ test('records diagnostics for missing owner, unresolved owner, and ambiguous pri
|
||||
],
|
||||
})
|
||||
|
||||
const result = createContactsService({ adapter: env.adapter, systemDir: env.dir }).getContacts()
|
||||
const result = createContactsService({ adapter: env.adapter }).getContacts()
|
||||
|
||||
assert.equal(result.contacts.length, 0)
|
||||
assert.equal(result.diagnostics.privateSessionCount, 3)
|
||||
@@ -256,7 +256,7 @@ test('records diagnostics for missing owner, unresolved owner, and ambiguous pri
|
||||
assert.equal(result.diagnostics.skippedAmbiguousPrivateSessions, 1)
|
||||
})
|
||||
|
||||
test('keeps name-match platform contacts session-scoped and applies manual overrides only to existing contacts', (t) => {
|
||||
test('keeps name-match platform contacts session-scoped', (t) => {
|
||||
const env = new TestEnv()
|
||||
t.after(() => env.cleanup())
|
||||
|
||||
@@ -274,20 +274,53 @@ test('keeps name-match platform contacts session-scoped and applies manual overr
|
||||
})
|
||||
}
|
||||
|
||||
const service = createContactsService({ adapter: env.adapter, systemDir: env.dir })
|
||||
service.setContactOverride('whatsapp:whatsapp-a:Alice', { lockedTier: 'core' })
|
||||
service.setContactOverride('whatsapp:ghost:Alice', { lockedTier: 'core' })
|
||||
const service = createContactsService({ adapter: env.adapter })
|
||||
const result = service.getContacts()
|
||||
const keys = result.contacts.map((contact) => contact.key).sort()
|
||||
|
||||
assert.deepEqual(keys, ['whatsapp:whatsapp-a:Alice', 'whatsapp:whatsapp-b:Alice'])
|
||||
const locked = result.contacts.find((contact) => contact.key === 'whatsapp:whatsapp-a:Alice')
|
||||
const unlocked = result.contacts.find((contact) => contact.key === 'whatsapp:whatsapp-b:Alice')
|
||||
assert.ok(locked)
|
||||
assert.equal(locked.tier, 'core')
|
||||
assert.equal(locked.lockedTier, 'core')
|
||||
assert.ok(unlocked)
|
||||
assert.equal(unlocked.lockedTier, null)
|
||||
})
|
||||
|
||||
test('sorts contacts by score and marks low-signal non-friends', (t) => {
|
||||
const env = new TestEnv()
|
||||
t.after(() => env.cleanup())
|
||||
|
||||
env.seed({
|
||||
id: 'private-a',
|
||||
platform: 'weixin',
|
||||
type: 'private',
|
||||
ownerId: 'owner',
|
||||
members: [
|
||||
{ id: 1, platformId: 'owner' },
|
||||
{ id: 2, platformId: 'alice' },
|
||||
],
|
||||
messages: privateMessages(60, 1, 1704103200),
|
||||
})
|
||||
env.seed({
|
||||
id: 'group-a',
|
||||
platform: 'weixin',
|
||||
type: 'group',
|
||||
ownerId: 'owner',
|
||||
members: [
|
||||
{ id: 1, platformId: 'owner' },
|
||||
{ id: 2, platformId: 'alice' },
|
||||
{ id: 3, platformId: 'bob' },
|
||||
],
|
||||
messages: [
|
||||
{ id: 1, senderId: 1, ts: 1704103200, platformMessageId: 'owner-1' },
|
||||
{ id: 2, senderId: 3, ts: 1704103201, platformMessageId: 'bob-1' },
|
||||
],
|
||||
})
|
||||
|
||||
const service = createContactsService({ adapter: env.adapter })
|
||||
const result = service.getContacts()
|
||||
|
||||
assert.deepEqual(
|
||||
result.contacts.map((contact) => contact.key),
|
||||
['weixin:alice', 'weixin:bob']
|
||||
)
|
||||
assert.equal(result.contacts[1].isLowSignal, true)
|
||||
assert.equal(result.diagnostics.hiddenLowSignalNonFriends, 1)
|
||||
})
|
||||
|
||||
test('returns stale cached contacts when signature changes and acceptStale is true', (t) => {
|
||||
@@ -307,7 +340,7 @@ test('returns stale cached contacts when signature changes and acceptStale is tr
|
||||
messages: privateMessages(5, 1, 1704103200),
|
||||
})
|
||||
|
||||
const service = createContactsService({ adapter: env.adapter, systemDir: env.dir, now: () => now })
|
||||
const service = createContactsService({ adapter: env.adapter, now: () => now })
|
||||
const first = service.getContacts()
|
||||
assert.equal(first.cache.status, 'fresh')
|
||||
assert.equal(first.cache.computedAt, 1000)
|
||||
@@ -345,7 +378,7 @@ test('recomputes contacts after owner_id changes in a session database', (t) =>
|
||||
messages: privateMessages(5, 1, 1704103200),
|
||||
})
|
||||
|
||||
const service = createContactsService({ adapter: env.adapter, systemDir: env.dir, now: () => now })
|
||||
const service = createContactsService({ adapter: env.adapter, now: () => now })
|
||||
const first = service.getContacts()
|
||||
assert.deepEqual(
|
||||
first.contacts.map((contact) => contact.key),
|
||||
|
||||
@@ -2,24 +2,20 @@ import { ChatType } from '@openchatlab/shared-types'
|
||||
import type {
|
||||
ChatPlatform,
|
||||
ContactItem,
|
||||
ContactOverridePatch,
|
||||
ContactsCacheState,
|
||||
ContactsDiagnostics,
|
||||
ContactsResponse,
|
||||
ContactSourceSession,
|
||||
ContactTier,
|
||||
} from '@openchatlab/shared-types'
|
||||
import {
|
||||
MIN_PRIVATE_SESSIONS_FOR_CONTACTS,
|
||||
applyContactOverride,
|
||||
assignFriendTiers,
|
||||
assignNonFriendTiers,
|
||||
computeFriendScores,
|
||||
computeNonFriendScores,
|
||||
getGroupContactFacts,
|
||||
getPrivateContactFacts,
|
||||
getSessionMeta,
|
||||
isChatSessionDb,
|
||||
isLowSignalNonFriend,
|
||||
isNameMatchPlatform,
|
||||
resolveOwnerMember,
|
||||
} from '@openchatlab/core'
|
||||
@@ -27,7 +23,6 @@ import type { ContactMemberRef, SessionMeta } from '@openchatlab/core'
|
||||
import { getDbFileVersion } from '../cache/analytics-cache'
|
||||
import { appLogger } from '../logging/app-logger'
|
||||
import type { SessionRuntimeAdapter } from './adapters'
|
||||
import { ContactsOverridesManager, buildContactOverrideKey } from './contacts-overrides'
|
||||
|
||||
export const CONTACTS_ALGORITHM_VERSION = 'contacts-v1'
|
||||
|
||||
@@ -38,14 +33,11 @@ export interface ContactsServiceOptions {
|
||||
|
||||
export interface ContactsServiceDeps {
|
||||
adapter: SessionRuntimeAdapter
|
||||
systemDir: string
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export interface ContactsService {
|
||||
getContacts(options?: ContactsServiceOptions): ContactsResponse
|
||||
setContactOverride(key: string, patch: ContactOverridePatch): void
|
||||
deleteContactOverride(key: string): void
|
||||
invalidateContactsCache(): void
|
||||
}
|
||||
|
||||
@@ -89,12 +81,9 @@ export function createContactsService(deps: ContactsServiceDeps): ContactsServic
|
||||
}
|
||||
|
||||
class DefaultContactsService implements ContactsService {
|
||||
private readonly overrides: ContactsOverridesManager
|
||||
private cache: CachedContacts | null = null
|
||||
|
||||
constructor(private readonly deps: ContactsServiceDeps) {
|
||||
this.overrides = new ContactsOverridesManager(deps.systemDir)
|
||||
}
|
||||
constructor(private readonly deps: ContactsServiceDeps) {}
|
||||
|
||||
getContacts(options: ContactsServiceOptions = {}): ContactsResponse {
|
||||
const signature = this.buildSignature()
|
||||
@@ -119,18 +108,6 @@ class DefaultContactsService implements ContactsService {
|
||||
return this.toResponse(this.cache, 'fresh')
|
||||
}
|
||||
|
||||
setContactOverride(key: string, patch: ContactOverridePatch): void {
|
||||
this.overrides.saveOverride(key, patch)
|
||||
this.invalidateContactsCache()
|
||||
appLogger.info('contacts', 'contact override saved', { key })
|
||||
}
|
||||
|
||||
deleteContactOverride(key: string): void {
|
||||
this.overrides.deleteOverride(key)
|
||||
this.invalidateContactsCache()
|
||||
appLogger.info('contacts', 'contact override deleted', { key })
|
||||
}
|
||||
|
||||
invalidateContactsCache(): void {
|
||||
this.cache = null
|
||||
}
|
||||
@@ -239,7 +216,6 @@ class DefaultContactsService implements ContactsService {
|
||||
}
|
||||
|
||||
private buildContactItems(accumulators: ContactAccumulator[], diagnostics: ContactsDiagnostics): ContactItem[] {
|
||||
const overrides = this.overrides.load()
|
||||
const friendInputs = accumulators
|
||||
.filter((acc) => acc.isFriend)
|
||||
.map((acc) => ({
|
||||
@@ -259,38 +235,21 @@ class DefaultContactsService implements ContactsService {
|
||||
}))
|
||||
|
||||
const friendScores = computeFriendScores(friendInputs)
|
||||
const friendTierInputs = friendInputs.map((input) => ({
|
||||
input,
|
||||
score: friendScores.get(input)?.score ?? 0,
|
||||
privateMessageCount: input.privateMessageCount,
|
||||
}))
|
||||
const friendTiers = assignFriendTiers(friendTierInputs)
|
||||
|
||||
const nonFriendScores = computeNonFriendScores(nonFriendInputs)
|
||||
const nonFriendTierInputs = nonFriendInputs.map((input) => ({
|
||||
input,
|
||||
score: nonFriendScores.get(input)?.score ?? 0,
|
||||
coOccurrenceCount: input.coOccurrenceCount,
|
||||
replyInteractionCount: input.replyInteractionCount,
|
||||
}))
|
||||
const nonFriendTiers = assignNonFriendTiers(nonFriendTierInputs)
|
||||
|
||||
const contacts: ContactItem[] = []
|
||||
|
||||
// 评分和分层按好友/非好友分池计算;最终再应用手动锁定,保证用户选择拥有最高优先级。
|
||||
for (const tierInput of friendTierInputs) {
|
||||
const { input } = tierInput
|
||||
// 评分仍按好友/群聊联系人分池计算;分池后的 score 只用于排序,不再生成关系定性标签。
|
||||
for (const input of friendInputs) {
|
||||
const score = friendScores.get(input) ?? { score: 0, scoreBreakdown: {} }
|
||||
const algorithmTier = friendTiers.tiers.get(tierInput) ?? 'acquaintance'
|
||||
contacts.push(this.toContactItem(input.acc, 'friend', algorithmTier, score, overrides[input.acc.key]))
|
||||
contacts.push(this.toContactItem(input.acc, 'friend', false, score))
|
||||
}
|
||||
|
||||
for (const tierInput of nonFriendTierInputs) {
|
||||
const { input } = tierInput
|
||||
for (const input of nonFriendInputs) {
|
||||
const score = nonFriendScores.get(input) ?? { score: 0, scoreBreakdown: {} }
|
||||
const algorithmTier = nonFriendTiers.tiers.get(tierInput) ?? 'low_interaction'
|
||||
const item = this.toContactItem(input.acc, 'non_friend', algorithmTier, score, overrides[input.acc.key])
|
||||
if (item.tier === 'low_interaction') diagnostics.hiddenLowInteractionNonFriends++
|
||||
const isLowSignal = isLowSignalNonFriend(input)
|
||||
const item = this.toContactItem(input.acc, 'non_friend', isLowSignal, score)
|
||||
if (item.isLowSignal) diagnostics.hiddenLowSignalNonFriends++
|
||||
contacts.push(item)
|
||||
}
|
||||
|
||||
@@ -300,11 +259,9 @@ class DefaultContactsService implements ContactsService {
|
||||
private toContactItem(
|
||||
acc: ContactAccumulator,
|
||||
pool: 'friend' | 'non_friend',
|
||||
algorithmTier: ContactTier,
|
||||
scoring: { score: number; scoreBreakdown: ContactItem['scoreBreakdown'] },
|
||||
override: Parameters<typeof applyContactOverride>[1]
|
||||
isLowSignal: boolean,
|
||||
scoring: { score: number; scoreBreakdown: ContactItem['scoreBreakdown'] }
|
||||
): ContactItem {
|
||||
const applied = applyContactOverride(algorithmTier, override)
|
||||
const aliases = [...acc.aliases].filter((alias) => alias !== acc.displayName)
|
||||
const searchText = [acc.displayName, acc.platformId, ...aliases].join(' ').toLowerCase()
|
||||
|
||||
@@ -319,9 +276,7 @@ class DefaultContactsService implements ContactsService {
|
||||
avatar: acc.avatar,
|
||||
isFriend: acc.isFriend,
|
||||
pool,
|
||||
tier: applied.tier,
|
||||
algorithmTier: applied.algorithmTier,
|
||||
lockedTier: applied.lockedTier,
|
||||
isLowSignal,
|
||||
score: scoring.score,
|
||||
scoreBreakdown: {
|
||||
...scoring.scoreBreakdown,
|
||||
@@ -341,7 +296,7 @@ class DefaultContactsService implements ContactsService {
|
||||
}
|
||||
|
||||
private buildSignature(): string {
|
||||
const parts = [`algorithm:${CONTACTS_ALGORITHM_VERSION}`, `overrides:${this.overrides.getSignaturePart()}`]
|
||||
const parts = [`algorithm:${CONTACTS_ALGORITHM_VERSION}`]
|
||||
for (const sessionId of [...this.deps.adapter.listSessionIds()].sort()) {
|
||||
const dbPath = this.deps.adapter.getDbPath(sessionId)
|
||||
parts.push(`${sessionId}:${getDbFileVersion(dbPath)}`)
|
||||
@@ -381,7 +336,7 @@ function createEmptyDiagnostics(): ContactsDiagnostics {
|
||||
skippedAmbiguousPrivateSessions: 0,
|
||||
skippedInvalidPlatformIdMembers: 0,
|
||||
skippedFailedSessions: 0,
|
||||
hiddenLowInteractionNonFriends: 0,
|
||||
hiddenLowSignalNonFriends: 0,
|
||||
warnings: [],
|
||||
}
|
||||
}
|
||||
@@ -393,12 +348,7 @@ function getOrCreateAccumulator(
|
||||
contact: ContactMemberRef
|
||||
): ContactAccumulator {
|
||||
const sessionScoped = isNameMatchPlatform(meta.platform)
|
||||
const key = buildContactOverrideKey({
|
||||
platform: meta.platform,
|
||||
platformId: contact.platformId,
|
||||
sessionId,
|
||||
matchMode: sessionScoped ? 'name' : 'platform_id',
|
||||
})
|
||||
const key = buildContactKey(meta.platform, contact.platformId, sessionScoped ? sessionId : undefined)
|
||||
const existing = accumulators.get(key)
|
||||
if (existing) {
|
||||
mergeContactIdentity(existing, contact)
|
||||
@@ -430,6 +380,16 @@ function getOrCreateAccumulator(
|
||||
return created
|
||||
}
|
||||
|
||||
function buildContactKey(platform: ChatPlatform, platformId: string, sessionId?: string): string {
|
||||
const normalizedPlatform = platform.trim()
|
||||
const normalizedPlatformId = platformId.trim()
|
||||
if (!normalizedPlatform) throw new Error('platform is required')
|
||||
if (!normalizedPlatformId) throw new Error('platformId is required')
|
||||
return sessionId?.trim()
|
||||
? `${normalizedPlatform}:${sessionId.trim()}:${normalizedPlatformId}`
|
||||
: `${normalizedPlatform}:${normalizedPlatformId}`
|
||||
}
|
||||
|
||||
function mergeContactIdentity(acc: ContactAccumulator, contact: ContactMemberRef): void {
|
||||
if (contact.name) acc.aliases.add(contact.name)
|
||||
acc.aliases.add(contact.platformId)
|
||||
|
||||
@@ -55,10 +55,6 @@ export type { LlmConfig, SummaryServiceDeps } from './summary-service'
|
||||
// Export service
|
||||
export { exportMarkdown } from './export-service'
|
||||
|
||||
// Contacts overrides
|
||||
export { ContactsOverridesManager, buildContactOverrideKey } from './contacts-overrides'
|
||||
export type { BuildContactOverrideKeyInput, ContactsOverrides } from './contacts-overrides'
|
||||
|
||||
// Contacts service
|
||||
export { CONTACTS_ALGORITHM_VERSION, createContactsService } from './contacts-service'
|
||||
export type { ContactsService, ContactsServiceDeps, ContactsServiceOptions } from './contacts-service'
|
||||
|
||||
Reference in new issue
Block a user