fix:更新算法

This commit is contained in:
吕新雨
2026-02-02 11:22:35 +08:00
parent 814b96edb6
commit 58d17fc39f
27 changed files with 3286 additions and 62 deletions

View File

@@ -6,7 +6,8 @@
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web"
"web": "expo start --web",
"test": "vitest run"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
@@ -41,7 +42,8 @@
"@babel/plugin-transform-react-jsx": "^7.28.6",
"@types/react": "~19.1.0",
"react-test-renderer": "19.1.0",
"typescript": "~5.9.2"
"typescript": "~5.9.2",
"vitest": "^4.0.18"
},
"private": true
}

833
client/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,8 +26,8 @@ export const API_BASE_URL = getRequiredEnv('EXPO_PUBLIC_API_BASE_URL');
/**
* 默认语言策略:
* - auto优先设备语言支持列表内时否则回退 zh-CN
* - zh-CN/en/es/pt/zh-TW固定默认语言仍允许用户在设置中手动切换并持久化
* - auto优先设备语言支持列表内时否则回退 en
* - en/zh-TW固定默认语言仍允许用户在设置中手动切换并持久化
*/
export const DEFAULT_LANGUAGE = getOptionalEnv('EXPO_PUBLIC_DEFAULT_LANGUAGE', 'auto');

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import {
buildUserProfileFromQuestionnaire,
computeProfileConfidence,
computeTimeConfidence,
normalizeAnswers,
} from '../index';
describe('userProfileScoring V1.2', () => {
it('normalizeAnswers: 非法值按跳过处理', () => {
// @ts-expect-error: 模拟非法输入
const out = normalizeAnswers({ mom_stage: 'xxx', emotion: 'yyy', context: 'zzz', need: 'ooo' });
expect(out).toEqual({ mom_stage: undefined, emotion: undefined, context: undefined, need: undefined });
});
it('computeTimeConfidence: 分段衰减', () => {
const gen = new Date('2026-01-01T00:00:00Z');
// 07 天1.0
expect(computeTimeConfidence(gen, new Date('2026-01-05T00:00:00Z'))).toBe(1.0);
// 30 天以上0.5
expect(computeTimeConfidence(gen, new Date('2026-02-15T00:00:00Z'))).toBe(0.5);
});
it('computeProfileConfidence: 完整度因子 + clamp', () => {
const confTime = 1.0;
// 全部跳过completion=0 → completionFactor=0.5 → 0.5
expect(
computeProfileConfidence(confTime, { stage: false, emotion: false, context: false, need: false })
).toBe(0.5);
// 全部作答completion=1 → completionFactor=1 → 1
expect(computeProfileConfidence(confTime, { stage: true, emotion: true, context: true, need: true })).toBe(1.0);
});
it('buildUserProfileFromQuestionnaire: 全部跳过输出最小可计算画像', () => {
const p = buildUserProfileFromQuestionnaire({}, { generatedAt: '2026-01-30T00:00:00Z', now: '2026-01-30T00:00:00Z' });
expect(p.profile_version).toBe('v1.2');
expect(p.profile_source).toBe('questionnaire');
expect(p.profile_answered).toEqual({ stage: false, emotion: false, context: false, need: false });
expect(p.stage).toEqual({ unknown: 1 });
expect(p.emotion_score).toBeNull();
expect(p.context).toEqual({});
expect(p.need).toEqual({});
// conf_time=1completionFactor=0.5
expect(p.profile_confidence).toBe(0.5);
// unknown 会命中 unsafe_for_stage_unknown并带跨维度谓词
expect(p.hard_rules.forbidden_risk_flags).toContain('unsafe_for_stage_unknown');
expect(p.hard_rules.forbidden_content_predicates.some((x) => x.id === 'unknown_block_parenting_pressure_personalized')).toBe(
true
);
});
it('buildUserProfileFromQuestionnaire: emotion<=0.2 命中 unsafe_for_emotion_low', () => {
const p = buildUserProfileFromQuestionnaire(
{ mom_stage: 'expecting', emotion: 'overwhelmed', context: 'health', need: 'anxiety_relief' },
{ generatedAt: '2026-01-30T00:00:00Z', now: '2026-01-30T00:00:00Z' }
);
expect(p.emotion_score).toBe(0.2);
expect(p.hard_rules.forbidden_risk_flags).toContain('unsafe_for_emotion_low');
});
});

View File

@@ -0,0 +1,15 @@
export type {
BuildUserProfileOptions,
QuestionnaireAnswersV1_2,
UserProfileV1_2,
UserProfileV1_2_Extended,
} from './types';
export {
buildUserProfileFromQuestionnaire,
computeProfileAnswered,
computeProfileConfidence,
computeTimeConfidence,
normalizeAnswers,
} from './scoring';

View File

@@ -0,0 +1,233 @@
/**
* 用户画像打分User Profile ScoringV1.2
*
* 规则来源:
* - `spec_kit/User Profile Scoring/spec.md`
* - `设计说明文档/客戶端問卷打分規則.md`V1.2
*/
import type {
BuildUserProfileOptions,
ContextAnswer,
EmotionAnswer,
HardRules,
MomStageAnswer,
NeedAnswer,
ProfileAnswered,
QuestionnaireAnswersV1_2,
SparseOneHot,
UserProfileV1_2_Extended,
UserStageOneHot,
} from './types';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}
function toDate(value: Date | string | undefined): Date | null {
if (!value) return null;
if (value instanceof Date) return Number.isFinite(value.getTime()) ? value : null;
const d = new Date(value);
return Number.isFinite(d.getTime()) ? d : null;
}
function isMomStageAnswer(v: unknown): v is MomStageAnswer {
return v === 'expecting' || v === 'parenting' || v === 'unknown';
}
function isEmotionAnswer(v: unknown): v is EmotionAnswer {
return (
v === 'low' ||
v === 'overwhelmed' ||
v === 'tired' ||
v === 'neutral' ||
v === 'calm' ||
v === 'joyful'
);
}
function isContextAnswer(v: unknown): v is ContextAnswer {
return v === 'family' || v === 'work' || v === 'relationship' || v === 'friends' || v === 'health';
}
function isNeedAnswer(v: unknown): v is NeedAnswer {
return (
v === 'emotional_support' ||
v === 'parenting_pressure' ||
v === 'self_worth' ||
v === 'anxiety_relief' ||
v === 'rest_balance'
);
}
/**
* 归一化答案:非法值按“跳过”处理(归一化为 undefined
* - `null` 保留,表示显式跳过/无值
*/
export function normalizeAnswers(raw: QuestionnaireAnswersV1_2): QuestionnaireAnswersV1_2 {
const mom_stage =
raw.mom_stage === null ? null : isMomStageAnswer(raw.mom_stage) ? raw.mom_stage : undefined;
const emotion = raw.emotion === null ? null : isEmotionAnswer(raw.emotion) ? raw.emotion : undefined;
const context = raw.context === null ? null : isContextAnswer(raw.context) ? raw.context : undefined;
const need = raw.need === null ? null : isNeedAnswer(raw.need) ? raw.need : undefined;
return { mom_stage, emotion, context, need };
}
export function computeProfileAnswered(normalized: QuestionnaireAnswersV1_2): ProfileAnswered {
return {
stage: normalized.mom_stage !== undefined && normalized.mom_stage !== null,
emotion: normalized.emotion !== undefined && normalized.emotion !== null,
context: normalized.context !== undefined && normalized.context !== null,
need: normalized.need !== undefined && normalized.need !== null,
};
}
/**
* 时间衰减置信度conf_time
* - 07 天1.0
* - 730 天:线性衰减到 0.7(含第 30 天)
* - 30 天以上0.5
*/
export function computeTimeConfidence(generatedAt: Date, now: Date): number {
const deltaMs = now.getTime() - generatedAt.getTime();
if (!Number.isFinite(deltaMs) || deltaMs <= 0) return 1.0;
const days = deltaMs / MS_PER_DAY;
if (days <= 7) return 1.0;
if (days <= 30) {
const t = (days - 7) / (30 - 7); // 0..1
return 1.0 - 0.3 * t; // 1 -> 0.7
}
return 0.5;
}
/**
* V1.2profile_confidenceconf_U
* conf = clamp(conf_time * (0.5 + 0.5 * completion), 0.2, 1.0)
*/
export function computeProfileConfidence(confTime: number, answered: ProfileAnswered): number {
const answeredCount =
(answered.stage ? 1 : 0) + (answered.emotion ? 1 : 0) + (answered.context ? 1 : 0) + (answered.need ? 1 : 0);
const completion = answeredCount / 4;
const completionFactor = 0.5 + 0.5 * completion;
return clamp(confTime * completionFactor, 0.2, 1.0);
}
function buildStageOneHot(momStage: MomStageAnswer | null | undefined): UserStageOneHot {
// V1.2mom_stage 跳过按安全策略输出 unknown=1
if (momStage === null || momStage === undefined) {
return { unknown: 1 };
}
return {
expecting: momStage === 'expecting' ? 1 : 0,
parenting: momStage === 'parenting' ? 1 : 0,
unknown: momStage === 'unknown' ? 1 : 0,
};
}
function mapEmotionScore(emotion: EmotionAnswer | null | undefined): number | null {
if (emotion === null || emotion === undefined) return null;
switch (emotion) {
case 'low':
return 0.0;
case 'overwhelmed':
return 0.2;
case 'tired':
return 0.4;
case 'neutral':
return 0.6;
case 'calm':
return 0.8;
case 'joyful':
return 1.0;
}
}
function buildSparseOneHot(value: string | null | undefined): SparseOneHot {
if (value === null || value === undefined) return {};
return { [value]: 1 };
}
function computeRuleHitsAndHardRules(profile: {
stage: UserStageOneHot;
emotion_score: number | null;
}): { rule_hits: string[]; hard_rules: HardRules } {
const rule_hits: string[] = [];
const forbidden_risk_flags: string[] = [];
const stageUnknown = profile.stage.unknown === 1;
const stageParenting = profile.stage.parenting === 1;
if (stageUnknown) {
rule_hits.push('unsafe_for_stage_unknown');
forbidden_risk_flags.push('unsafe_for_stage_unknown');
}
if (stageParenting) {
rule_hits.push('unsafe_for_stage_parenting');
forbidden_risk_flags.push('unsafe_for_stage_parenting');
}
if (profile.emotion_score !== null && profile.emotion_score <= 0.2) {
rule_hits.push('unsafe_for_emotion_low');
forbidden_risk_flags.push('unsafe_for_emotion_low');
}
const forbidden_content_predicates = [];
if (stageUnknown) {
forbidden_content_predicates.push({
id: 'unknown_block_parenting_pressure_personalized',
when_user: { stage_unknown: true },
forbid_content: { need: 'parenting_pressure', personalization_power: 1 },
});
}
return {
rule_hits,
hard_rules: {
forbidden_risk_flags,
forbidden_content_predicates,
},
};
}
export function buildUserProfileFromQuestionnaire(
rawAnswers: QuestionnaireAnswersV1_2,
options: BuildUserProfileOptions = {}
): UserProfileV1_2_Extended {
const normalized = normalizeAnswers(rawAnswers);
const profile_answered = computeProfileAnswered(normalized);
const now = toDate(options.now) ?? new Date();
const generatedAt = toDate(options.generatedAt) ?? now;
const confTime = computeTimeConfidence(generatedAt, now);
const profile_confidence = computeProfileConfidence(confTime, profile_answered);
const stage = buildStageOneHot(normalized.mom_stage);
const emotion_score = mapEmotionScore(normalized.emotion);
const context = buildSparseOneHot(normalized.context);
const need = buildSparseOneHot(normalized.need);
const { rule_hits, hard_rules } = computeRuleHitsAndHardRules({ stage, emotion_score });
return {
profile_version: 'v1.2',
profile_source: 'questionnaire',
profile_generated_at: generatedAt.toISOString(),
profile_confidence,
profile_answered,
stage,
emotion_score,
context,
need,
rule_hits,
hard_rules,
};
}

View File

@@ -0,0 +1,95 @@
/**
* 用户画像打分User Profile ScoringV1.2 类型定义
*
* 说明:
* - 本模块用于:问卷答案(可跳过)→ 用户画像(可计算、可观测、可版本化)
* - 字段与规则以 `spec_kit/User Profile Scoring/spec.md`V1.2)为准
*/
export type MomStageAnswer = 'expecting' | 'parenting' | 'unknown';
export type EmotionAnswer = 'low' | 'overwhelmed' | 'tired' | 'neutral' | 'calm' | 'joyful';
export type ContextAnswer = 'family' | 'work' | 'relationship' | 'friends' | 'health';
export type NeedAnswer =
| 'emotional_support'
| 'parenting_pressure'
| 'self_worth'
| 'anxiety_relief'
| 'rest_balance';
/**
* V1.2:每题可跳过
* - `undefined`:字段缺失(可能是“没传”)
* - `null`:显式跳过/无值(例如 UI 明确传 null
*/
export type QuestionnaireAnswersV1_2 = {
mom_stage?: MomStageAnswer | null;
emotion?: EmotionAnswer | null;
context?: ContextAnswer | null;
need?: NeedAnswer | null;
};
export type ProfileAnswered = {
stage: boolean;
emotion: boolean;
context: boolean;
need: boolean;
};
export type UserStageOneHot = {
expecting?: 0 | 1;
parenting?: 0 | 1;
unknown: 0 | 1;
};
export type SparseOneHot = Record<string, 1>;
export type UserProfileV1_2 = {
profile_version: 'v1.2';
profile_source: 'questionnaire';
profile_generated_at: string; // ISO8601
profile_confidence: number; // 01
profile_answered: ProfileAnswered;
stage: UserStageOneHot;
emotion_score: number | null;
context: SparseOneHot;
need: SparseOneHot;
};
export type ForbiddenContentPredicate = {
/**
* 谓词 ID用于可观测与回归测试
*/
id: string;
/**
* 触发条件(用户侧)
* 说明:这里刻意保持为 object便于未来接入规则引擎时做 schema 对齐。
*/
when_user: Record<string, unknown>;
/**
* 禁推条件(内容侧)
* 说明:本模块不判断内容的 `personalization_power`,只输出可执行条件。
*/
forbid_content: Record<string, unknown>;
};
export type HardRules = {
forbidden_risk_flags: string[];
forbidden_content_predicates: ForbiddenContentPredicate[];
};
export type UserProfileV1_2_Extended = UserProfileV1_2 & {
rule_hits: string[];
hard_rules: HardRules;
};
export type BuildUserProfileOptions = {
/**
* 画像生成时间;不传则使用当前时间
*/
generatedAt?: Date | string;
/**
* 当前时间(用于计算 time decay不传则使用当前时间
*/
now?: Date | string;
};

View File

@@ -4,30 +4,21 @@ import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './locales/en.json';
import es from './locales/es.json';
import pt from './locales/pt.json';
import zhCN from './locales/zh-CN.json';
import zhTW from './locales/zh-TW.json';
/**
* 语言码约定:
* - 简体中文zh-CN
* - 繁体中文zh-TW
* - 英语en
* - 西班牙语es
* - 葡萄牙语pt
*/
export type AppLanguage = 'zh-CN' | 'zh-TW' | 'en' | 'es' | 'pt';
export type AppLanguage = 'zh-TW' | 'en';
export const SUPPORTED_LANGUAGES: readonly AppLanguage[] = [
'zh-CN',
'zh-TW',
'en',
'es',
'pt',
] as const;
const DEFAULT_FALLBACK_LANGUAGE: AppLanguage = 'zh-CN';
const DEFAULT_FALLBACK_LANGUAGE: AppLanguage = 'en';
const STORAGE_KEY_LANGUAGE = 'settings.language';
function isSupportedLanguage(lang: string): lang is AppLanguage {
@@ -37,19 +28,13 @@ function isSupportedLanguage(lang: string): lang is AppLanguage {
function normalizeDeviceLanguageTagToAppLanguage(languageTag: string): AppLanguage {
const tag = languageTag.toLowerCase();
// 中文:优先区分繁简
// 中文:当前仅支持繁体中文zh-TW
if (tag.startsWith('zh')) {
// 常见繁体标记zh-TW / zh-HK / zh-Hant
if (tag.includes('tw') || tag.includes('hk') || tag.includes('hant')) {
return 'zh-TW';
}
return 'zh-CN';
return 'zh-TW';
}
// 其他语言:按前缀匹配
// 其他语言:按前缀匹配(当前仅支持英文)
if (tag.startsWith('en')) return 'en';
if (tag.startsWith('es')) return 'es';
if (tag.startsWith('pt')) return 'pt';
return DEFAULT_FALLBACK_LANGUAGE;
}
@@ -87,7 +72,7 @@ export async function clearLanguagePreference(): Promise<void> {
* 语言选择优先级:
* 1) 用户设置(若存在)
* 2) 设备语言(在支持列表内时生效;否则会被 normalize 到默认回退)
* 3) 默认回退(zh-CN
* 3) 默认回退(en
*/
export async function initI18n(): Promise<void> {
if (i18n.isInitialized) return;
@@ -98,11 +83,8 @@ export async function initI18n(): Promise<void> {
await i18n.use(initReactI18next).init({
resources: {
'zh-CN': { translation: zhCN },
'zh-TW': { translation: zhTW },
en: { translation: en },
es: { translation: es },
pt: { translation: pt },
},
lng: initialLang,
fallbackLng: DEFAULT_FALLBACK_LANGUAGE,