新功能:个性化推荐算法
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { UserProfileV1_2_Extended } from '@/src/features/userProfileScoring';
|
||||
|
||||
/**
|
||||
* 本地存储 key 统一管理,避免 UI 里散落硬编码
|
||||
@@ -9,6 +10,9 @@ const KEY_CONTENT_REACTIONS = 'content.reactions';
|
||||
const KEY_FAVORITES_ITEMS = 'favorites.items';
|
||||
const KEY_CONSENT_ACCEPTED = 'consent.accepted';
|
||||
const KEY_USER_PROFILE = 'user.profile';
|
||||
const KEY_USER_PROFILE_SCORING = 'user.profileScoring';
|
||||
const KEY_RECO_FEED_CACHE = 'reco.feedCache';
|
||||
const KEY_RECO_FEED_HISTORY = 'reco.feedHistory';
|
||||
const KEY_UI_THEME_MODE = 'ui.theme.mode';
|
||||
const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
|
||||
|
||||
@@ -20,11 +24,42 @@ export type UserProfile = {
|
||||
name?: string;
|
||||
intents?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户画像(问卷打分输出)
|
||||
* 说明:用于推荐/Push/Widget 统一复用;结构以 `src/features/userProfileScoring` 输出为准。
|
||||
*/
|
||||
export type UserProfileScoring = UserProfileV1_2_Extended;
|
||||
export type DailyReminderSettings = {
|
||||
timesPerDay: number;
|
||||
pushEnabled: boolean;
|
||||
};
|
||||
|
||||
export type RecoFeedCacheItem = {
|
||||
content_id: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type RecoFeedCache = {
|
||||
saved_at: string; // ISO8601
|
||||
items: RecoFeedCacheItem[];
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Feed 链路可观测输入(用于下一次请求携带给后端)
|
||||
*
|
||||
* - already_recommended_ids:本设备已下发过的内容(避免重复下发)
|
||||
* - touched_or_viewed_ids:本设备用户已看过/划过的内容(用于频控/去重/降重复)
|
||||
*
|
||||
* 说明:后端不需要“实时知道”,只要在下一次拉取时带上即可。
|
||||
*/
|
||||
export type RecoFeedHistory = {
|
||||
updated_at: string; // ISO8601
|
||||
already_recommended_ids: number[];
|
||||
touched_or_viewed_ids: number[];
|
||||
};
|
||||
|
||||
|
||||
async function getJson<T>(key: string, fallback: T): Promise<T> {
|
||||
const raw = await AsyncStorage.getItem(key);
|
||||
@@ -122,6 +157,103 @@ export async function setUserProfile(profile: UserProfile): Promise<void> {
|
||||
await setJson(KEY_USER_PROFILE, { ...current, ...profile });
|
||||
}
|
||||
|
||||
export async function getUserProfileScoring(): Promise<UserProfileScoring | null> {
|
||||
const raw = await AsyncStorage.getItem(KEY_USER_PROFILE_SCORING);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as UserProfileScoring;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setUserProfileScoring(profile: UserProfileScoring): Promise<void> {
|
||||
await setJson(KEY_USER_PROFILE_SCORING, profile);
|
||||
}
|
||||
|
||||
export async function getRecoFeedCache(): Promise<RecoFeedCache | null> {
|
||||
const raw = await AsyncStorage.getItem(KEY_RECO_FEED_CACHE);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as RecoFeedCache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setRecoFeedCache(cache: RecoFeedCache): Promise<void> {
|
||||
await setJson(KEY_RECO_FEED_CACHE, cache);
|
||||
}
|
||||
|
||||
export async function getRecoFeedHistory(): Promise<RecoFeedHistory> {
|
||||
const raw = await AsyncStorage.getItem(KEY_RECO_FEED_HISTORY);
|
||||
if (!raw) {
|
||||
return {
|
||||
updated_at: new Date().toISOString(),
|
||||
already_recommended_ids: [],
|
||||
touched_or_viewed_ids: [],
|
||||
};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<RecoFeedHistory>;
|
||||
return {
|
||||
updated_at: typeof parsed.updated_at === 'string' ? parsed.updated_at : new Date().toISOString(),
|
||||
already_recommended_ids: Array.isArray(parsed.already_recommended_ids)
|
||||
? parsed.already_recommended_ids.filter((x) => Number.isFinite(x)).map((x) => Number(x))
|
||||
: [],
|
||||
touched_or_viewed_ids: Array.isArray(parsed.touched_or_viewed_ids)
|
||||
? parsed.touched_or_viewed_ids.filter((x) => Number.isFinite(x)).map((x) => Number(x))
|
||||
: [],
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
updated_at: new Date().toISOString(),
|
||||
already_recommended_ids: [],
|
||||
touched_or_viewed_ids: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function setRecoFeedHistory(history: RecoFeedHistory): Promise<void> {
|
||||
await setJson(KEY_RECO_FEED_HISTORY, history);
|
||||
}
|
||||
|
||||
function uniqKeepLatest(list: number[], max: number): number[] {
|
||||
const seen = new Set<number>();
|
||||
const out: number[] = [];
|
||||
for (let i = list.length - 1; i >= 0; i -= 1) {
|
||||
const v = list[i];
|
||||
if (!Number.isFinite(v)) continue;
|
||||
if (seen.has(v)) continue;
|
||||
seen.add(v);
|
||||
out.push(v);
|
||||
if (out.length >= max) break;
|
||||
}
|
||||
return out.reverse();
|
||||
}
|
||||
|
||||
export async function recordRecoFeedServed(contentIds: number[]): Promise<void> {
|
||||
if (!contentIds?.length) return;
|
||||
const h = await getRecoFeedHistory();
|
||||
const next = {
|
||||
...h,
|
||||
updated_at: new Date().toISOString(),
|
||||
already_recommended_ids: uniqKeepLatest([...h.already_recommended_ids, ...contentIds], 500),
|
||||
};
|
||||
await setRecoFeedHistory(next);
|
||||
}
|
||||
|
||||
export async function recordRecoFeedTouched(contentId: number): Promise<void> {
|
||||
if (!Number.isFinite(contentId)) return;
|
||||
const h = await getRecoFeedHistory();
|
||||
const next = {
|
||||
...h,
|
||||
updated_at: new Date().toISOString(),
|
||||
touched_or_viewed_ids: uniqKeepLatest([...h.touched_or_viewed_ids, contentId], 500),
|
||||
};
|
||||
await setRecoFeedHistory(next);
|
||||
}
|
||||
|
||||
export async function getDailyReminderSettings(): Promise<DailyReminderSettings> {
|
||||
const s = await getJson<DailyReminderSettings>(KEY_DAILY_REMINDER_SETTINGS, {
|
||||
timesPerDay: 3,
|
||||
|
||||
Reference in New Issue
Block a user