Merge branch 'damer'

This commit is contained in:
吕新雨
2026-02-02 11:23:53 +08:00
27 changed files with 3286 additions and 62 deletions

View File

@@ -9,6 +9,6 @@
- 输入/输出定义
- 验收标准(可验证)
3. 拆分后输出一个 `modules/` 目录结构列表,并为每个模块生成对应 spec 内容。
4. 保留大 spec.md 的高层背景/总览到 overview 部分。
4. 保留大 spec.md 的高层背景/总览到 overview 部分,并标明各个模块的实现顺序
5. 子模块之间按逻辑关系关联。
6. 不生成 plan.md 或 tasks.md仅拆出子模块 spec。
6. 不生成 plan.md 或 tasks.md仅拆出子模块 spec。

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,

View File

@@ -0,0 +1,102 @@
# 子模块Content Repository候选查询与数据访问层Spec
## 1. 目标描述
提供推荐算法可注入的、与 ORM/SQL 解耦的数据访问接口:
- 按场景与用户画像拉取候选内容画像Cᵢ
-`content_id` 批量获取内容画像(去重/重排/补字段)。
- 对 risk_flags、suitability JSON 等“存储形态”做统一解析与兼容(对上提供稳定结构)。
> 规则口径risk_flags 命名与语义严格按 `句子文案打分规则`;若历史数据存在旧 flag需在读取层做一次映射避免语义漂移
>
> 存储形态对齐 `modules/db-design/plan.md`:读取层需要将 `contents` + `content_profiles` + `content_risk_flags` 组装为上层稳定的 `ContentProfile` 结构。
---
## 2. 输入 / 输出定义
### 2.1 输入
- `scene`: `feed | push | widget`
- `user_profile`: 客户端问卷画像V1.2;字段允许缺失)
- `fallback_level`: `0|1|2|3`
- `limit`: 候选条数上限(由引擎配置)
- (可选)排除集合:`exclude_content_ids`(用于 DB 层先排一部分,减少传输;类型为 `List[int]`,与 MySQL 自增 `content_id` 对齐)
- 数据库会话/连接(实现层使用 `AsyncSession` 注入)
### 2.2 输出
- `List[ContentProfile]`(稳定字段契约):
- `content_id``int`MySQL 自增主键)
- `text`:文案文本
- `stage``general | expecting | parenting | unknown`
- `emotion_score``float | None`(约定:`None` 表示 general
- `context_suitability``Dict[str, float]`(见 4.1 的 key 集合;值为 `0/0.5/1`
- `need_suitability``Dict[str, float]`(见 4.1 的 key 集合;值为 `0/0.5/1`
- `personalization_power``float`(对上稳定口径为 `0/0.5/1`;若 DB 存 `0/5/10`,读取层需映射)
- `risk_flags``List[str]`(已做旧→新映射、去重;命名只允许 `unsafe_for_* / block_* / soft_*`
- 可选:`author_id``template_id``review_confidence`
### 2.3 数据存储形态(对齐 DB 设计)
> 对齐:`spec_kit/Personalized Reco/modules/db-design/plan.md`
- `contents`:提供 `content_id``text`、(可选)`author_id``template_id`
- `content_profiles`:提供 `stage``emotion_score``context_suitability_json``need_suitability_json``personalization_power`(推荐存 `0/5/10`)、(可选)`review_confidence`
- `content_risk_flags`:通过关联表提供风险标记集合(同一 content 下按 `uniq_content_flag(content_id, flag)` 去重)
---
## 3. 接口(建议)
推荐模块对该子模块只依赖抽象接口Python Protocol/ABC 均可):
- `fetch_candidates(scene, user_profile, fallback_level, limit, exclude_content_ids=None) -> List[ContentProfile]`
- `fetch_contents_by_ids(content_ids: List[int]) -> List[ContentProfile]`
---
## 4. 关键规则与实现约束
### 4.1 字段缺失与默认值
-`review_confidence` 缺失:输出时默认按 `0.7`(对齐 `句子文案打分规则` V1.2 约定)。
- `context_suitability/need_suitability` 若缺失:**读取层必须补齐为“全 0.5 的通用可推”结构**(稳定输出,避免上层分支判断)。
- `context_suitability` 必须包含 5 个 key`family/work/relationship/friends/health`
- `need_suitability` 必须包含 5 个 key`emotional_support/parenting_pressure/self_worth/anxiety_relief/rest_balance`
- 补齐时上述 key 的默认值均为 `0.5`
- `personalization_power`:若 DB 采用 `0/5/10` 存储,读取层必须映射为 `0.0/0.5/1.0` 对上输出。
### 4.2 risk_flags 兼容映射(若存在历史旧数据)
对齐 `句子文案打分规则` 的旧→新映射:
- `block_stage_unknown``unsafe_for_stage_unknown`
- `block_stage_parenting``unsafe_for_stage_parenting`
- `block_emotion_low``unsafe_for_emotion_low`
- `block_health_sensitive``block_health_medical`(读取层默认采用更保守的硬拦截映射;除非未来引入可判定的细分字段再放宽为 `soft_health_sensitive`
输出约束:
- 输出的 flags 必须已去重,且不得包含任何旧命名。
### 4.3 性能约束
- 不得产生 N+1 查询:候选与字段必须一次或少量批量查询获取。
- `fetch_candidates` 必须支持 limit并在 DB 层尽量过滤(减少应用层扫描)。
- `fetch_contents_by_ids` / `fetch_candidates` 推荐查询形态:
- `contents` JOIN `content_profiles`,再 LEFT JOIN `content_risk_flags`(或先批量取 profiles再批量取 flags 并在应用层聚合),避免按 `content_id` 循环查 flags。
---
## 5. 验收标准(可验证)
- `fetch_contents_by_ids`
- 输入任意 `content_id` 列表,返回包含完整字段的 `ContentProfile` 列表(无重复、可缺省字段按约定兜底)。
- `fetch_candidates`
- 在不同 `scene``fallback_level` 下能返回候选(即便画像缺失也不报错)。
- risk_flags 映射正确:输出的 flag 名称集合只包含新命名(`unsafe_for_* / block_* / soft_*`)。
- 性能:
- 单次调用不出现按 content_id 循环查库的行为(可通过日志/测试断言查询次数)。

View File

@@ -0,0 +1,217 @@
# DB Design & Migrations数据库设计与迁移Plan
> 对应规范:`spec_kit/Personalized Reco/modules/db-design/spec.md`
>
> 前置确认(已对齐):
>
> - `content_id`MySQL **自增主键**;文案微调时 **content_id 不变**(更新同一条记录)。
> - `need_suitability/context_suitability`**JSON** 存储。
> - `risk_flags`:选择更强扩展性的方案(本计划采用 **关联表**,利于索引与过滤)。
> - 安全池L3**方式 A**`is_safe_pool`)。
> - 迁移:使用 **Alembic**,目录放 `server/alembic/`dev/pro 两套库均可运行同一套迁移。
> - 字符集:统一 **utf8mb4**。
---
## 1. 目标与交付物
### 1.1 目标
- 在“库为空”的前提下,落地推荐系统最小可用的数据模型。
- 保证后续推荐查询可实现:按画像条件召回、按 ID 批量查、按风险标记过滤、支持安全池兜底。
### 1.2 交付物
- `server/alembic/`Alembic 初始化目录、`alembic.ini`(或等价配置)、迁移脚本。
- SQLAlchemy ORM 模型(建议放 `server/app/db/models/`)。
- 初始迁移:创建 `contents``content_profiles``content_risk_flags`(以及必要索引)。
---
## 2. 技术决策V1
### 2.1 表设计原则
- **分离主体与画像**`contents` 存文本与来源字段;`content_profiles` 存画像字段(便于未来画像重算/回填)。
- **JSON 存 suitability**`context_suitability``need_suitability` 用 JSON保持结构与规则文档一致。
- **risk_flags 关联表**:用 `content_risk_flags(content_id, flag)`,便于:
- 快速 Hard Filter`block_health_medical` 等)
- 索引与统计(按 flag 计数)
- 兼容旧 flag 映射(在写入/读取层)
- **emotion_score 的 general 表示**:用 `NULL` 表示 general与规则文档“可为 general”语义等价
- **personalization_power**:存为 `TINYINT`0/5/10`DECIMAL(2,1)`0/0.5/1。本计划推荐 `TINYINT`(更易索引/更省空间),应用层做映射:
- 0 → 0.0
- 5 → 0.5
- 10 → 1.0
### 2.2 字符集与排序规则
- 数据库与表:`utf8mb4`
- collation建议 `utf8mb4_0900_ai_ci`MySQL 8 默认更常见;若环境不同以实际为准,但必须 utf8mb4
---
## 3. 表结构V1 方案)
> 以下为“建议 schema”。实际字段名可调整但语义必须严格对齐 `句子文案打分规则`。
### 3.1 `contents`(文案主体)
- `content_id` BIGINT UNSIGNED PK AUTO_INCREMENT
- `text` TEXT NOT NULL
- `author_id` VARCHAR(64) NULL
- `template_id` VARCHAR(64) NULL
- `created_at` DATETIME NOT NULL
- `updated_at` DATETIME NOT NULL
索引建议:
- `idx_contents_author_id(author_id)`
- `idx_contents_template_id(template_id)`
### 3.2 `content_profiles`(内容画像)
- `content_id` BIGINT UNSIGNED PKFK → contents.content_idON DELETE CASCADE
- `stage` ENUM('general','expecting','parenting','unknown') NOT NULL DEFAULT 'general'
- `emotion_score` DECIMAL(3,2) NULL
- 约定NULL 表示 general
- `context_suitability_json` JSON NOT NULL
- `need_suitability_json` JSON NOT NULL
- `personalization_power` TINYINT UNSIGNED NOT NULL DEFAULT 0
- 约定:只允许 0/5/10
- `review_confidence` DECIMAL(3,2) NULL
- 约定NULL 由推荐模块按 0.7 兜底(对齐规则文档)
- `is_safe_pool` BOOLEAN NOT NULL DEFAULT FALSE
- `updated_at` DATETIME NOT NULL
索引建议:
- `idx_profiles_stage(stage)`
- `idx_profiles_personalization_power(personalization_power)`
- `idx_profiles_is_safe_pool(is_safe_pool)`
> 说明suitability 放 JSON 后V1 可以先不做 JSON 路径索引;当候选量上来后再加“生成列/函数索引”做加速(见 6.2)。
### 3.3 `content_risk_flags`(风险标记,关联表)
- `id` BIGINT UNSIGNED PK AUTO_INCREMENT
- `content_id` BIGINT UNSIGNED NOT NULLFK → contents.content_idON DELETE CASCADE
- `flag` VARCHAR(64) NOT NULL
- `created_at` DATETIME NOT NULL
约束与索引:
- UNIQUE`uniq_content_flag(content_id, flag)`(同一 content 不重复插同 flag
- 索引:`idx_flag(flag)`(用于 Hard Filter 与统计)
- 索引:`idx_content_id(content_id)`(用于按内容批量取 flags
命名约束应用层强制DB 可选):
- flag 必须以 `unsafe_for_` / `block_` / `soft_` 开头(严格对齐规则文档)
---
## 4. Alembic 迁移落地步骤
### 4.1 依赖与目录
- 后端依赖:`alembic`(加入 `server/requirements.txt`,版本随项目统一管理)
- 目录:`server/alembic/`(包含 `env.py``versions/`
- 连接串:复用现有 `DATABASE_URL``mysql+aiomysql://...`
### 4.2 初始化与生成迁移(一次性)
- `alembic init alembic`(在 `server/` 下)
- 配置 `env.py`
-`app/core/config.py` 读取 `DATABASE_URL`
- 引入 ORM Base 与 models启用 autogenerate
- 创建初始迁移:
- `alembic revision --autogenerate -m "init content tables"`
- `alembic upgrade head`
### 4.3 dev/pro 一致性
- 迁移脚本保持同一套;通过不同环境的 `DATABASE_URL` 指向 `mindfulness_dev``mindfulness`
---
## 5. 入库流程(写入契约)
### 5.1 一条文案最小入库数据
必须字段V1 最小可用):
- `text`
- `content_profiles.stage`
- `content_profiles.emotion_score`(可为 NULL 表示 general
- `content_profiles.context_suitability_json`(必须包含 5 个 keyfamily/work/relationship/friends/health值为 0/0.5/1
- `content_profiles.need_suitability_json`(必须包含 5 个 keyemotional_support/parenting_pressure/self_worth/anxiety_relief/rest_balance值为 0/0.5/1
- `content_profiles.personalization_power`0/5/10
- `content_risk_flags`(可为空集合,但若存在必须按命名规范)
强烈建议字段:
- `author_id``template_id`
- `review_confidence`
- `is_safe_pool`(若要参与 L3 安全池)
### 5.2 写入策略
- 创建文案时:
- 先写 `contents` 得到 `content_id`(自增)
- 再写 `content_profiles`(同 content_id
- 再批量写 `content_risk_flags`
- 文案微调时content_id 不变):
- 更新 `contents.text``updated_at`
- 同步更新 `content_profiles`(若画像变更)
- risk_flags 做“全量覆盖”或“差量更新”plan 实现阶段定)
---
## 6. 查询与性能规划
### 6.1 V1 查询策略(先可用)
- 候选召回:
- 先按 `stage``personalization_power``is_safe_pool` 等可索引字段进行粗过滤
- 再在应用层结合 suitability JSON 与 risk_flags 做精过滤/打分
- Hard Filter
- 通过 `content_risk_flags` join 或子查询排除指定 flags`block_health_medical`
- 批量查:
- `content_id IN (...)` join `content_profiles` + left join `content_risk_flags`
### 6.2 V1.1 性能增强(候选量上来后再做)
当候选池变大、应用层过滤成本上升时,优先做两类增强:
- **生成列/函数索引**:为常用召回维度(例如 need/context 的某些 key创建 generated columns从 JSON_EXTRACT 取值并映射到 TINYINT再加索引。
- **风险 flag 位图/派生列**:对 `block_health_medical` 等强规则增加派生布尔列(或维护冗余表),降低 join 成本。
---
## 7. 测试与验收DB 子模块)
### 7.1 迁移验收
- 在全新库执行 `alembic upgrade head` 成功。
- 执行 `downgrade`(若实现)可回滚(至少在开发环境可用)。
### 7.2 数据契约验收
插入一条最小文案记录后,能够查询并组装出推荐模块所需的 `ContentProfile` 字段集合:
- `content_id/text/stage/emotion_score/context_suitability/need_suitability/personalization_power/risk_flags`
### 7.3 规则口径验收(写入侧)
- 写入 `risk_flags` 时,若出现旧 flag`block_stage_unknown`
- 写入层需在入库前映射为新命名(或拒绝写入并提示)
- 推荐侧读取层不得再出现旧 flag 名称
---
## 8. 与其他子模块的接口约定
- `Content Repository` 只依赖本模块提供的表与字段语义,不依赖具体迁移实现细节。
- 推荐引擎/打分模块对 `review_confidence` 的缺省值假设0.7)在 DB 缺失时依然成立。

View File

@@ -0,0 +1,91 @@
# 子模块DB Design & Migrations数据库设计与迁移Spec
## 1. 目标描述
在当前“数据库尚未设计且为空”的前提下,为个性化推荐提供最小可用的数据存储与索引能力:
- 存储文案及其内容画像Content ProfileCᵢ
- 能按画像条件进行候选召回need/context/stage/general、personalization_power、risk_flags 等)。
- 能按 `content_id` 批量查询(补全候选、去重/重排时取字段)。
- 为后续标注/审核/置信度补齐留出扩展空间。
> 规则口径:字段语义必须严格对齐 `设计说明文档/句子文案打分規則.md`。
---
## 2. 输入 / 输出定义
### 2.1 输入
- 内容侧提供的文案与画像数据可由运营导入、AI Reviewer 产出、人审修正等方式写入)。
- 推荐模块对数据访问的需求(召回过滤字段、排序字段、频控字段)。
### 2.2 输出
- 一套 MySQL 表结构(或视图)满足 `ContentProfile` 字段契约:
- `content_id`(稳定主键)
- `text`
- `stage``general/expecting/parenting/unknown`
- `emotion_score`0~1 或 `general` 的等价表示)
- `context_suitability`(每个 context 的 {0,0.5,1}
- `need_suitability`(每个 need 的 {0,0.5,1}
- `personalization_power`0/0.5/1
- `risk_flags`(命名以 `unsafe_for_* / block_* / soft_*` 为准)
- 可选:`author_id``template_id``review_confidence`
- Alembic 迁移脚本:`alembic revision --autogenerate` / `alembic upgrade head` 可创建上述表。
- 推荐查询需要的索引(至少支持按场景召回与按 ID 批量查)。
---
## 3. 建议表结构V1允许后续调整
> 说明:本子模块不强制具体表名;但需保证字段语义与索引可用。以下给出一个推荐落地方案,便于后续 plan 直接实现。
### 3.1 `contents`(文案主体)
- `content_id`PK
- `text`
- `author_id`(可空)
- `template_id`(可空)
- `created_at` / `updated_at`
### 3.2 `content_profiles`(内容画像)
- `content_id`PK/FK → contents
- `stage`枚举general/expecting/parenting/unknown
- `emotion_score`(可为 NULL 表示 general或用额外字段 `emotion_is_general` 表示)
- `context_suitability_json`JSON每个 context -> 0/0.5/1
- `need_suitability_json`JSON每个 need -> 0/0.5/1
- `personalization_power`DECIMAL(2,1) 或 TINYINT 映射到 0/0.5/1
- `risk_flags_json`JSON 数组:字符串集合)
- `review_confidence`DECIMAL(3,2),缺省可按 0.7 处理,由推荐模块兜底)
- `updated_at`
### 3.3 “通用安全池”支持L3 兜底)
至少提供一种方式能拉到安全池内容:
- 方式 A`content_profiles` 增加 `is_safe_pool`boolean
- 方式 B单独 `safe_pool_contents`content_id 列表)
---
## 4. 索引与查询能力V1 必需)
- **按 ID 批量查**`content_id in (...)`
- **按 stage / personalization_power 过滤**:支持候选召回与回退梯度
- **按 risk_flags 过滤**
- 推荐做法:将 `risk_flags_json` 冗余为可索引的派生列/位图/多表行plan 阶段定实现)
- V1 最小可用:允许先在应用层过滤(但需控制候选量,避免全表扫)
---
## 5. 验收标准(可验证)
- **迁移可运行**:全新 MySQL 库上执行 `alembic upgrade head` 可成功创建表结构。
- **字段契约可满足**:能从 DB 读出 `ContentProfile` 需要的字段(至少 content_id/text/stage/emotion_score/context_suitability/need_suitability/personalization_power/risk_flags
- **基本查询可用**
- 能按 `content_id` 批量拉取内容画像
- 能按 `stage/general``personalization_power` 条件召回候选(用于 L0~L3
- **安全池可用**:能稳定拉取 L3 兜底候选集(不依赖用户画像)。

View File

@@ -0,0 +1,99 @@
# DB Design & Migrations数据库设计与迁移Tasks
> 对应计划:`spec_kit/Personalized Reco/modules/db-design/plan.md`
>
> 目标:在“库为空”的前提下,落地推荐系统最小可用数据模型 + Alembic 迁移,并通过最小查询/契约验收。
---
## 0. 任务状态约定
- `[ ]`:未开始
- `[~]`:进行中
- `[x]`:已完成
- `[-]`:已取消/不做(需写明原因)
---
## 1. 环境与依赖准备
- [ ] **确认 MySQL 版本与字符集支持**
- 验收MySQL 版本为 8.x库/表可用 `utf8mb4`(建议 `utf8mb4_0900_ai_ci`)。
- [x] **后端依赖补齐 Alembic**
- 说明:`server/requirements.txt` 已包含 `alembic>=1.13`
- 验收:在 `server/.venv` 中可成功 `import alembic`
---
## 2. ORM 模型落地(推荐最小集合)
- [x] **创建 ORM 模型目录**
- 目标路径:`server/app/db/models/`
- 验收:目录存在,且可被 Python 正常 import。
- [x] **实现 `contents` 模型**
- 字段:`content_id(PK, 自增)``text_en?``text_tc?``author_id?``template_id?``created_at``updated_at`
- 索引:`author_id``template_id`
- 验收Alembic autogenerate 能识别表结构。
- [x] **实现 `content_profiles` 模型**
- 字段:`content_id(PK/FK)``stage(enum)``emotion_score(NULL=general)``context_suitability_json(JSON)``need_suitability_json(JSON)``personalization_power(0/5/10)``review_confidence?``is_safe_pool``updated_at`
- 索引:`stage``personalization_power``is_safe_pool`
- 验收Alembic autogenerate 能识别表结构;`content_id` 具备外键与级联删除。
- [x] **实现 `content_risk_flags` 模型(关联表)**
- 字段:`id(PK)``content_id(FK)``flag``created_at`
- 约束:`UNIQUE(content_id, flag)`
- 索引:`flag``content_id`
- 验收Alembic autogenerate 能识别唯一约束与索引。
---
## 3. Alembic 初始化与迁移生成
- [x] **初始化 Alembic 目录**
- 目标位置:`server/alembic/`(含 `versions/``env.py`
- 验收:在 `server/` 下可运行 `alembic -h` 且能读取配置。
- [x] **配置 Alembic 连接串来源**
- 要求:复用现有 `DATABASE_URL`(对齐 `server/app/core/config.py`
- 验收:`alembic` 命令可加载 `env.py` 并读取 `DATABASE_URL`(未连 DB 验收留到第 4 章)。
- [x] **配置 `env.py` 支持 autogenerate**
- 要求:引入 ORM `Base` 与 models确保 metadata 完整)
- 验收Alembic 能识别 `target_metadata`;且已提供初始迁移版本文件。
- [ ] **生成并执行初始迁移**
- 命令:`alembic upgrade head`
- 验收:数据库中出现 `contents``content_profiles``content_risk_flags` 与 Alembic 版本表。
---
## 4. 数据契约验收(最小入库与查询)
- [ ] **准备一条最小文案数据(人工插入或脚本)**
- 必须字段(对齐 plan`text``stage``emotion_score(可 NULL)``context_suitability_json(5 keys)``need_suitability_json(5 keys)``personalization_power(0/5/10)``risk_flags(可空)`
- 验收:可插入成功,不违反约束。
- [ ] **验证按 `content_id` 批量查询可用**
- 目标:能 join 组装出 `ContentProfile` 所需字段集合(含 risk_flags 列表)
- 验收:至少验证字段:`content_id/text/stage/emotion_score/context_suitability/need_suitability/personalization_power/risk_flags`
- [ ] **验证 Hard Filter 关键 flag 可过滤**
- 插入:至少一条带 `block_health_medical` 的记录
- 验收:通过 `content_risk_flags` 可在 SQL 层排除该内容(后续供推荐候选召回使用)。
- [ ] **验证安全池L3可用**
- 插入:至少一条 `is_safe_pool=true`
- 验收:可单独查询出安全池候选集(不依赖画像条件)。
---
## 5. 规则口径验收risk_flags 严格对齐)
- [ ] **建立 risk_flags 白名单/校验策略(写入侧或读取侧)**
- 要求flag 命名必须以 `unsafe_for_` / `block_` / `soft_` 开头(对齐 `句子文案打分规则`
- 验收:插入非法前缀时被拒绝或被修正(选择其一,并写清策略)。
- [ ] **旧 flag 兼容映射验收(若存在历史数据导入)**
- 覆盖:`block_stage_unknown``unsafe_for_stage_unknown` 等(详见 plan
- 验收:系统对外(读出/下游)不再出现旧 flag 名称。
---
## 6. 文档与交接
- [ ] **补齐本子模块 README/说明(可选,但建议)**
- 内容:如何初始化 DB、如何跑迁移、如何插入一条最小文案数据、如何验证查询。
- 验收:新同学按文档能在 30 分钟内跑通迁移 + 插入 + 查询。

View File

@@ -0,0 +1,59 @@
# 子模块IntegrationFastAPI API + Celery WorkerSpec
## 1. 目标描述
将推荐引擎对外暴露为两种调用方式满足“API 与任务两者都需要”的集成要求:
- **FastAPI**:同步请求/响应式获取推荐结果(客户端直接请求)。
- **Celery**:后台任务式生成推荐(用于 Push/Widget 的定时或批处理)。
两种方式都必须调用同一套 `Reco Engine`,确保行为一致、便于回归测试。
---
## 2. 输入 / 输出定义
### 2.1 API 输入HTTP Request
- `scene`: `feed | push | widget`
- `k`(可选;默认按场景)
- `user_profile`客户端问卷画像V1.2
- `already_recommended_ids`(数组)
- `touched_or_viewed_ids`(数组)
- (可选)`now`(用于可测试性;生产可不传,由服务端生成)
### 2.2 API 输出HTTP Response
- `items`: 推荐句子列表
- `meta`: RecoMeta候选规模、回退层级、empty_reason 等)
### 2.3 Celery 任务输入/输出
任务输入与 API 输入等价,但建议仅传必要字段并保持 payload 小(避免 Redis 膨胀):
- 允许只传 `user_profile` 与历史集合;若历史集合很大,建议传引用 ID后续演进
任务输出:
- 可选择“不存结果”(默认忽略结果)并在任务内部将推荐结果写入下游(如消息队列/推送系统/DB
- 或返回 `items/meta`(仅用于开发调试)。
---
## 3. 关键约束
- API 与 Celery **不得复制推荐逻辑**:只能调用 `Reco Engine`
- 输入合法性:
- 不因字段缺失而报错(画像允许缺失/跳过)。
- `already_recommended_ids/touched_or_viewed_ids` 允许为空数组。
- 可测试性:
- 允许注入 `now`(或在 header/参数中提供),便于确定性测试。
---
## 4. 验收标准(可验证)
- **API 可用**:给定合法请求体可返回推荐结果与 meta。
- **任务可用**Celery worker 能消费任务并成功调用推荐引擎(至少跑通 ping → reco 的调用链)。
- **一致性**相同输入下API 与任务调用返回结果一致(忽略时间戳字段差异时)。

View File

@@ -0,0 +1,45 @@
# 子模块Observability可观测性与打点载荷Spec
## 1. 目标描述
定义推荐模块的统一可观测载荷meta/event用于
- 线上监控覆盖率、回退率、候选规模分布、过滤原因分布。
- 支撑调参(权重/回退阈值/频控窗口与风险排查Hard Filter 命中情况)。
---
## 2. 输入 / 输出定义
### 2.1 输入
- 推荐引擎内部各阶段统计信息:
- 候选生成数量、过滤后数量、去重后数量、频控后数量
- 回退层级与触发原因
- served_k
- 缺失字段情况、conf_U
### 2.2 输出
统一的 `RecoMeta`(返回给调用方;调用方负责上报/落库/打点):
- `scene`
- `candidate_pool_size_raw`
- `candidate_pool_size_after_hard_filter`
- `candidate_pool_size_after_dedup`
- `candidate_pool_size_after_freqcap`
- `fallback_level_final`
- `served_k`
- `empty_reason`served_k=0 必填;枚举建议:`hard_filter_all | freqcap_all | pool_empty | unknown`
- `conf_U`
- `missing_fields``{ need: boolean; context: boolean; emotion: boolean }`
- (可选)`risk_filtered_count_by_flag``{ flag: count }`
---
## 3. 验收标准(可验证)
- 每次推荐调用都能产出 `RecoMeta`,并随响应/任务结果返回(或被上层打点系统消费)。
- `served_k=0``empty_reason` 必不为空,且与实际清空阶段一致。
- `fallback_level_final` 能真实反映最终回退层级(用于回退率报表)。

View File

@@ -0,0 +1,81 @@
# 子模块Reco Engine推荐引擎编排Spec
## 1. 目标描述
实现推荐的主编排器Orchestrator
- 输入用户画像 + 历史集合 + 场景参数。
- 调用 `Content Repository` 拉取候选。
- 执行统一 PipelineCandidate → Hard Filter → Soft Scoring → Rerank/Freqcap → Fallback Ladder。
- 输出推荐句子与 `meta`(可观测字段),供 API 或 Celery 上层直接下发。
---
## 2. 输入 / 输出定义
### 2.1 输入
- `scene`: `feed | push | widget`
- `user_profile`: 客户端问卷画像V1.2
- `already_recommended_ids`: `List[str|int]`
- `touched_or_viewed_ids`: `List[str|int]`
- `k`: intfeed 默认 30push/widget 默认 1
- `now`: 时间戳
- (可选)`constraints`:黑名单 author/template、最大候选量上限等
### 2.2 输出
- `items: List[RecommendedItem]`(长度 ≤ k
- `content_id``text``final_score`
- `fallback_level_final`
- `explanations`(可选,便于调参/排查)
- `meta: RecoMeta`
- `candidate_pool_size_raw`
- `candidate_pool_size_after_hard_filter`
- `candidate_pool_size_after_dedup`
- `candidate_pool_size_after_freqcap`
- `fallback_level_final`
- `served_k`
- `empty_reason`served_k=0 必填)
- `missing_fields`need/context/emotion 的缺失情况)
- `scene``conf_U`
---
## 3. 缺失字段判定(必须与客户端契约一致)
- `U.need` 为空对象 `{}` 或不存在 → 视为缺失
- `U.context` 为空对象 `{}` 或不存在 → 视为缺失
- `U.emotion_score``null`/不存在 → 视为缺失
当缺失明显或 `conf_U` 偏低时:
- Push必须启用不确定性惩罚 `P_uncertainty`,并自动降个性化(限制 `personalization_power` 上限)。
- 任意场景:候选生成至少按 **L1** 处理(降个性化,增加通用安全占比)。
---
## 4. Fallback Ladder回退梯度必须实现
- L0正常召回配比
- L1放宽匹配 + `personalization_power ≤ 0.5`
- L2回退通用池 + `personalization_power = 0`
- L3仅安全池白名单/安全池)
触发条件(任一满足即可回退):
- 候选池为空 / Hard Filter 清空 / 去重清空 / 频控清空
- served_k < kfeed 可允许部分不足,但需记录并可继续回退补齐,具体由实现配置)
每次回退必须更新 meta 中的候选规模与触发原因。
---
## 5. 验收标准(可验证)
- **稳定性**:任意输入(包括画像字段缺失、历史集合为空/很大)不报错,返回结构稳定。
- **回退可观测**:当候选不足时能逐级回退,且 `fallback_level_final``empty_reason` 正确。
- **去重生效**:输出不包含 `already_recommended_ids``touched_or_viewed_ids` 中的 content_id。
- **风险优先**Hard Filter 始终优先执行(尤其 `block_health_medical` 必挡)。
- **跨调用复用**:同一引擎既可被 FastAPI API 调用,也可被 Celery 任务调用(无框架耦合)。

View File

@@ -0,0 +1,87 @@
# 子模块Rerank & Freqcap重排 / 去重 / 频控Spec
## 1. 目标描述
对 Soft Scoring 后的候选集做最终的“可下发排序”,解决:
- **去重**:避免同一句在短期内反复出现。
- **多样性**Feed 场景需要序列多样性(作者/模板/标签)。
- **频控与冷却**Push/Widget 必做,按句子/作者/模板维度做窗口期不重复。
> 当前确认:历史集合由客户端在请求时传入(已推荐 ID、已触达/浏览 ID
---
## 2. 输入 / 输出定义
### 2.1 输入
- `scene`: `feed | push | widget`
- `scored_candidates`: `List[{ content_id, score, content_profile... }]`
- `already_recommended_ids`: `List[str|int]`
- `touched_or_viewed_ids`: `List[str|int]`
- (可选)`recent_author_ids` / `recent_template_ids`(若客户端暂不传,可由后续服务端侧补齐)
- `k`: 目标条数
- `config`
- Feed`mmr_lambda`(默认 0.7
- 冷却窗口:`cooldown_sentence_days / cooldown_author_days / cooldown_template_days`
### 2.2 输出
- `ranked_items`: 排好序的候选(长度 ≤ k
- `meta`
- `candidate_pool_size_after_dedup`
- `candidate_pool_size_after_freqcap`
- `freqcap_filtered_counts`(可选,按维度统计)
---
## 3. 规则与算法V1 最小集合)
### 3.1 去重(必做)
- 过滤 `content_id``already_recommended_ids touched_or_viewed_ids` 的候选。
- 去重键定义(对齐算法规则的工程约定):
- `sentence_key = content_id`
- `author_key = author_id`(可空)
- `template_key = template_id`(可空)
### 3.2 FeedMMR 序列重排(建议)
- Top1最高分确保第一眼命中。
- 后续:使用 MMR 选择序列,最大化与已选内容的差异(作者/模板/need/context/stage
MMR 定义:
\[
MMR(c)=\lambda\cdot Rel(c) - (1-\lambda)\cdot \max_{s\in S} Sim(c,s)
\]
其中:
- `Rel(c)`:可直接使用 `final_score`
- `Sim(c,s)`离散特征版V1 推荐):
- content_id 相同1
- template_id 相同:+0.6
- author_id 相同:+0.3
- 标签重合Jaccard+0.1 * Jaccard(tags_c, tags_s)
- clamp 到 [0,1]
### 3.3 Push/Widget频控与冷却必做
最小要求:
- 同一句在窗口 X 天内不重复(句子冷却)。
- 同作者/同模板在窗口期内尽量不重复可作为硬频控或强降权plan 阶段定)。
> 当前 V1 输入侧只确认有 content_id 历史;若 author/template 历史暂不具备,可先对 content_id 强硬去重,并将 author/template 频控作为“可选增强”meta 仍需统计缺失原因)。
---
## 4. 验收标准(可验证)
- **去重正确**:输出不包含 `already_recommended_ids``touched_or_viewed_ids`
- **Feed 序列多样**:在候选足够时,序列中不会出现大量同作者/同模板连续重复(可用统计阈值验收)。
- **Push/Widget 频控生效**:在冷却窗口内同一句不会再次被推荐(基于传入历史集合验证)。
- **可观测**:输出 meta 中包含 `after_dedup/after_freqcap` 的候选规模,便于排查 served_k 不足原因。

View File

@@ -0,0 +1,97 @@
# 子模块Scoring软打分与惩罚项Spec
## 1. 目标描述
实现推荐排序的打分函数与分解项,严格对齐 `設計說明文檔/個性化推薦算法規則.md` 的公式与 V1.2 缺失字段约定并与内容画像Cᵢ结构对齐 `句子文案打分规则`
---
## 2. 输入 / 输出定义
### 2.1 输入
- `scene`: `feed | push | widget`
- `user_profile`U
- `content_profile`Cᵢ
- `now`(用于 freshness/时间衰减,若 V1 暂不实现可先占位)
- `config`(权重与开关)
- `w_need/w_emotion/w_stage/w_context`
- `alpha`(个性化加成系数)
- `beta`(不确定性惩罚系数)
- `enable_uncertainty_penalty`push 默认 true
- `widget_emotion_soft_range`0.4~0.8,软降权)
### 2.2 输出
- `final_score: float`
- `breakdown`(用于调参/可观测V1 可选但建议保留):
- `S_need/S_context/S_stage/S_emotion`
- `S_core/S_personal/S_fresh`
- `P_fatigue/P_repeat/P_risk/P_uncertainty`
- `missing_fields`need/context/emotion
---
## 3. 计算规则(必须实现的最小集合)
### 3.1 核心结构(线性加权 + 惩罚)
\[
final\_score(U,C_i)=\mathbb{I}[pass]\times\Big(S_{core}+S_{personal}+S_{fresh}-P_{fatigue}-P_{repeat}-P_{risk}-P_{uncertainty}\Big)
\]
\[
S_{core}=w_{need}S_{need}+w_{emotion}S_{emotion}+w_{stage}S_{stage}+w_{context}S_{context}
\]
### 3.2 V1.2 缺失字段的保守计算(必须对齐)
-`U.need` 缺失 → `S_need = 0.5`
-`U.context` 缺失 → `S_context = 0.5`
-`U.emotion_score` 缺失 → `S_emotion = 0.8`
- 文案为 general 时仍按 0.8(对齐算法规则)
### 3.3 个性化加成(受回退梯度/降个性化约束)
\[
S_{personal}=\alpha \cdot C_i.personalization\_power \cdot \max(S_{need}, S_{context})
\]
当触发降个性化(缺失明显 / 低置信度 / fallback_level≥1
- L1限制 `personalization_power ≤ 0.5`
- L2/L3限制 `personalization_power = 0`
### 3.4 不确定性惩罚Push 默认启用)
\[
P_{uncertainty}=\beta \cdot (1-conf_U)\cdot(1-conf_{C_i})\cdot C_i.personalization\_power
\]
其中:
- `conf_U` 来自 `user_profile.profile_confidence`
- `conf_{C_i}` 来自 `content_profile.review_confidence`(缺省 0.7,对齐 `句子文案打分规则`
### 3.5 Widget 情绪区间(软降权)
-`scene=widget``C_i.emotion_score` 可计算(非 general
-`emotion_score` 不在 0.4~0.8:不硬过滤,但需要产生一个降权项(可并入 `P_risk` 或单独 `P_widget_emotion_out_of_range`)。
---
## 4. 场景默认权重V1 建议,来源算法规则)
- Feed`w_need=0.35, w_emotion=0.20, w_stage=0.15, w_context=0.30`
- Push`w_need=0.45, w_emotion=0.35, w_stage=0.15, w_context=0.05`
- Widget`w_need=0.25, w_emotion=0.25, w_stage=0.30, w_context=0.20`
---
## 5. 验收标准(可验证)
- **缺失字段一致性**:当 U 的 need/context/emotion 缺失时S_need/S_context/S_emotion 按 0.5/0.5/0.8 计算,且不会报错。
- **不确定性惩罚生效**Push 默认启用,低 `conf_U` 或低 `conf_C` 时,高 `personalization_power` 内容分数降低。
- **Widget 软降权生效**0.4~0.8 区间外不被硬挡,但分数能明显被压低(可通过断言对比分数验证)。
- **可调参**:权重与系数可配置,调整不会破坏输出结构。

View File

@@ -0,0 +1,107 @@
# Personalized RecoOverview大规范总览
> 目的:保留大规范的背景/总览,并作为各子模块规范的索引入口。
>
> 依据:
>
> - `设计说明文档/個性化推薦算法規則.md`
> - `设计说明文档/句子文案打分規則.md`risk_flags 命名与语义的唯一准绳,必须严格对齐)
> - `spec_kit/User Profile Scoring/spec.md`客户端问卷画像输出契约V1.2
---
## 1. 背景
需要在后端实现一套可复用、可调参、可观测、可回退的推荐流程,覆盖 Feed / Push / Widget 三种分发场景。推荐模块必须与 API/任务解耦,避免逻辑散落;并在 Push 等高风险场景自动“降个性化/降风险”。
当前约束与确认点(来自需求澄清):
- **数据库现状**:数据库尚未设计,当前为空(需要新增 DB 设计与迁移子模块)。
- **规则口径**risk_flags 等内容画像语义必须严格对齐 `句子文案打分规则`
- **调用方式**:推荐请求时由客户端传入 `user_profile` 与历史集合(已推荐 ID、已触达/浏览 ID
- **Widget 情绪区间**0.4~0.8 采用**软降权**(非硬过滤)。
- **频控**Push/Widget 需要冷却与频控(确认要做)。
- **集成形态**:既需要 FastAPI API 接口,也需要 Celery 任务调用(两者都要)。
---
## 2. 大需求输入/输出(对外契约摘要)
- **输入**
- `scene`: `feed | push | widget`
- `user_profile`: 客户端问卷画像V1.2,支持字段缺失/跳过)
- `already_recommended_ids`: 已推荐过的文案 ID本次请求需去重
- `touched_or_viewed_ids`: 已触达/浏览的文案 ID本次请求需去重/疲劳)
- `k`返回条数feed 默认 30push/widget 默认 1
- **输出**
- 推荐句子列表(`content_id` + `text` + `final_score` 等)
- `meta`:候选规模、过滤/频控后规模、回退层级、empty_reason 等(用于打点)
---
## 3. 子模块拆分modules/
> 原则:每个子模块都可独立实现、测试、验收;子模块之间通过明确接口关联。
目录结构:
```text
spec_kit/Personalized Reco/
├ overview.md
├ spec.md
└ modules/
├ db-design/
│ └ spec.md
├ content-repository/
│ └ spec.md
├ reco-engine/
│ └ spec.md
├ scoring/
│ └ spec.md
├ rerank-freqcap/
│ └ spec.md
├ observability/
│ └ spec.md
└ integration-api-worker/
└ spec.md
```
模块关系(逻辑依赖):
- `integration-api-worker` → 调用 `reco-engine`
- `reco-engine` → 依赖 `content-repository` 拉取候选
- `reco-engine` → 依赖 `hard filter / scoring / rerank-freqcap`(此处拆为独立模块:`scoring``rerank-freqcap`
- `content-repository` → 依赖 `db-design` 提供表结构与字段契约
- `observability` → 被 `reco-engine``integration-api-worker` 共同使用
---
## 4. 主规范与子规范的职责划分
- `spec.md`:大需求的高层契约与总体约束(面向集成与验收)。
- `modules/*/spec.md`:每块可落地实现的子模块规范(面向工程实现与单测)。
---
## 5. 模块实现顺序(建议)
> 原则先把“数据可查”打通再实现“可排序”最后做“可对外提供API/任务)”与“可观测”。
1. **`modules/db-design/`(数据库设计与迁移)**
- 交付:表结构 + Alembic 迁移可跑通;能插入/读取最小 `ContentProfile` 字段。
2. **`modules/content-repository/`(数据访问层)**
- 交付:按场景/回退层级拉候选、按 ID`content_id` 自增 `int`批量查risk_flags 旧→新映射与默认值兜底。
- 口径suitability 读取层统一输出稳定结构;缺失时补齐“全 0.5”key 集合固定为:
- context`family/work/relationship/friends/health`
- need`emotional_support/parenting_pressure/self_worth/anxiety_relief/rest_balance`
3. **`modules/scoring/`(打分)**
- 交付:`final_score` 与缺失字段保守策略V1.2实现Push 的 `P_uncertainty`Widget 情绪区间软降权。
4. **`modules/rerank-freqcap/`(去重/重排/频控)**
- 交付基于传入历史集合的去重Feed 的 MMRPush/Widget 冷却窗口规则(先保证“同句不重复”)。
5. **`modules/observability/`(可观测)**
- 交付:统一 `meta` 结构与字段;能准确定位候选在何阶段被清空/回退。
6. **`modules/reco-engine/`(引擎编排)**
- 交付:串起候选→过滤→打分→重排→回退;输出 items+meta在画像缺失/候选不足时仍稳定返回。
7. **`modules/integration-api-worker/`API + Celery 集成)**
- 交付FastAPI 路由 + Celery 任务都能调用同一引擎;相同输入下结果一致(忽略时间戳差异)。

View File

@@ -0,0 +1,287 @@
# Personalized Reco后端个性化推荐算法模块解耦版Spec索引版
> 阶段高层规范spec
>
> 目标:把「个性化推荐算法」做成后端**独立模块**,输入用户画像与曝光历史,模块内部从数据库拉取文案候选并排序,输出推荐句子(支持 Feed / Push / Widget 三种场景的可调参策略)。
>
> 依据:
>
> - `设计说明文档/個性化推薦算法規則.md`
> - `设计说明文档/句子文案打分規則.md`risk_flags 命名与语义必须严格对齐)
> - `spec_kit/User Profile Scoring/spec.md`用户画像输入契约V1.2
---
## 1. 背景与动机(摘要)
需要在后端实现一套跨场景一致、可调参、可观测、可回退的推荐流程并将其做成独立模块以便复用API / Celery 均可调用),降低 Push 等高风险场景的翻车概率。
已确认约束:
- 数据库当前未设计且为空:本需求内新增 DB 设计与迁移子模块。
- risk_flags 等内容画像语义:严格按 `句子文案打分规则` 执行。
- 推荐请求:由客户端在请求时传入 `user_profile` 与历史集合。
- Widget 情绪区间0.4~0.8):采用软降权。
- Push/Widget需要频控与冷却且 API 与 Celery 两种调用方式都需要。
---
## 2. 目标Goals
- **模块解耦**:推荐逻辑以独立包/子模块方式存在,暴露稳定接口,不与 FastAPI 路由/ Celery 任务强绑定。
- **统一 Pipeline**Hard Filter → Soft Scoring → Rerank多样性/新鲜度/疲劳并支持候选不足的回退梯度L0~L3
- **输入明确**:输入用户画像(允许字段缺失)、已推荐的文案 ID、已触达/浏览的文案 ID用于去重/疲劳/频控)。
- **从 DB 取候选**:模块内部负责根据画像与场景召回候选,并从数据库读取文案画像/风险标记/元信息。
- **输出可直接下发**:输出推荐句子(含 content_id、文本、排序分与必要的解释字段用于打点/调参)。
- **可观测**:对候选规模、过滤原因、回退层级、 served_k 等关键指标提供统一事件结构,便于落点与报表。
---
## 3. 非目标Non-goals
- **不在本阶段定义**内容生产生成句子、人工审核工作流、embedding/向量检索V2 扩展)。
- **不绑定具体表结构**:仅规定“必须能读到的字段契约”,具体表名/索引/迁移由后续 plan 细化。
- **不负责推送/Widget 排程**:模块只负责“给定场景与约束,返回推荐结果”,调度由上层系统决定。
---
## 4. 术语与数据对象Definitions摘要
### 4.1 场景Scene
- **feed**探索优先Exploration-first返回序列 TopK例如 30 条)。
- **push**精确与安全优先Precision-first通常返回 Top1或 TopN 供上层挑选)。
- **widget**稳定与品牌一致性优先Consistency-first返回 Top1每日一句
### 4.2 用户画像 UUserProfile
用户画像结构以客户端问卷输出为准V1.2),详见:
- `spec_kit/User Profile Scoring/spec.md`(字段与缺失约定)
- `modules/reco-engine/spec.md`(本模块如何消费画像与缺失判定)
### 4.3 文案画像 CContentProfile
模块需能从 DB 读到(或通过视图/派生字段得到):
- **content_id**(稳定主键)
- **text**(句子文本)
- **stage**(或可推导的 stage 标签)
- **emotion_score**0~1 或标记为 general
- **need_suitability / context_suitability**(离散适配表/JSON
- **personalization_power**0 / 0.5 / 1
- **risk_flags**(集合/数组/位图均可,语义一致即可)
- (可选)**author_id / template_id / review_confidence**
---
## 5. 模块边界与接口API Contract
### 5.1 入口函数(推荐引擎接口)
推荐模块对外暴露一个主入口,建议形式(不限定语言结构,但需表达同等信息):
- **输入**
- `scene`: `"feed" | "push" | "widget"`
- `user_profile`: `UserProfile`
- `already_recommended_ids`: `list[int|str]`
- `touched_or_viewed_ids`: `list[int|str]`(触达/浏览/点击等均归一为“已曝光”集合)
- `k`: intfeed 默认 30push/widget 默认 1
- `now`: 时间戳(用于时间衰减/冷却窗口)
- `constraints`(可选):如黑名单 author/template、最大风险等级、频控窗口天数等
- **输出**
- `items`: 推荐结果数组(长度 ≤ k
- `meta`: 本次推荐的统计与解释字段(用于打点/调参)
### 5.2 输出结果项RecommendedItem
每条推荐至少包含:
- `content_id`
- `text`
- `final_score`
- `fallback_level_final`
- `explanations`(轻量可选):如命中 need/context/stage、是否 general、被降个性化的原因缺失/低置信度/回退)
### 5.3 数据访问抽象Repository / Gateway
为实现解耦,推荐模块**不得**直接依赖 FastAPI 的 `Depends`;应通过注入的 `ContentRepository` 读取数据:
- `fetch_candidates(scene, user_profile, limit, fallback_level) -> list[ContentProfile]`
- `fetch_contents_by_ids(ids) -> list[ContentProfile]`(用于补全曝光历史的画像信息时可用)
实现层可使用 `AsyncSession`SQLAlchemy完成具体查询但算法层不感知 ORM/SQL。
---
## 6. 推荐流程(统一 Pipeline
本节是工程必须遵守的“骨架”,参数可按场景配置。
### 6.1 Candidate Generation候选集生成
核心要求:
- 依据 `U` 召回一个候选池:匹配 need/context/stage 的内容 + 一部分通用内容general
- 若问卷允许跳过导致字段缺失need/context/emotion_score 缺失),候选池需自动提高通用安全内容占比,并视为至少进入 **L1**(限制 `personalization_power ≤ 0.5`)。
### 6.2 Hard Filter硬性过滤
基于 `risk_flags` 与产品规则做剔除(说明文档 3.x
- `block_health_medical`:全场景硬过滤。
- `U.stage.unknown=1`:过滤 `unsafe_for_stage_unknown`
- `U.stage.parenting=1`:过滤 `unsafe_for_stage_parenting`
- `U.emotion_score≤0.2`:过滤 `unsafe_for_emotion_low`
- 跨维度规则:`U.stage.unknown=1``need_suitability[parenting_pressure]=1``personalization_power=1` → 禁推。
Hard Filter 后需输出 `candidate_pool_size_after_hard_filter` 以及若清空的 `empty_reason`
### 6.3 Soft Scoring软性打分
采用说明文档的线性加权结构:
最终分:
\[
final\_score(U,C_i)=\mathbb{I}[pass]\times\Big(S_{core}+S_{personal}+S_{fresh}-P_{fatigue}-P_{repeat}-P_{risk}-P_{uncertainty}\Big)
\]
其中
\[
S_{core}=w_{need}S_{need}+w_{emotion}S_{emotion}+w_{stage}S_{stage}+w_{context}S_{context}
\]
缺失字段的保守计算(说明文档 V1.2,必须实现):
- `U.need` 缺失 → `S_need = 0.5`
- `U.context` 缺失 → `S_context = 0.5`
- `U.emotion_score` 缺失 → `S_emotion = 0.8`general 仍为 0.8
个性化加成(必须受回退梯度约束):
\[
S_{personal}=\alpha \cdot personalization\_power \cdot \max(S_{need}, S_{context})
\]
不确定性惩罚Push 建议默认开启,模块需支持开关):
\[
P_{uncertainty}=\beta \cdot (1-conf_U)\cdot(1-conf_{C_i})\cdot personalization\_power
\]
### 6.4 Rerank重排多样性/新鲜度/疲劳)
最小要求:
- **去重**:排除 `already_recommended_ids``touched_or_viewed_ids`(同一句不重复)。
- **多样性**:作者/模板/标签多样性Feed 场景建议使用 MMR说明文档 4.5)。
- **频控与冷却**Push/Widget 必做):同句/同作者/同模板在窗口 X 天内不重复(具体 X 由 plan 定参数)。
---
## 7. 回退策略Fallback Ladder
候选不足或过滤/频控导致 served_k 过少时,必须按梯度回退,并输出最终 `fallback_level_final`
- **L0正常**:按场景默认召回配比。
- **L1放宽匹配**need/context 命中阈值放宽;`personalization_power ≤ 0.5`
- **L2回退通用池**:增加 general 低风险句;`personalization_power = 0`
- **L3兜底安全池**:仅从通用安全句库/白名单池抽取。
规则:
- 回退时必须“降个性化与降风险”,尤其 Push。
- 每次回退都要重新统计候选规模,并记录触发原因(如 `hard_filter_all` / `freqcap_all` / `pool_empty`)。
---
## 8. 场景默认参数V1 建议)
### 8.1 FeedExploration-first
- 候选配比:匹配 60% + 通用 30% + 探索 10%
- 权重建议:`w_need=0.35, w_emotion=0.20, w_stage=0.15, w_context=0.30`
- 序列Top1 最高分,后续使用 MMRλ=0.7
### 8.2 PushPrecision & Safety-first
- 候选配比need 强命中 70% + emotion 命中 20% + 通用安全 10%
- 权重建议:`w_need=0.45, w_emotion=0.35, w_stage=0.15, w_context=0.05`
- 默认启用 `P_uncertainty`;当 `fallback_level_final>0` 或画像缺失/低置信度时自动降个性化
### 8.3 WidgetConsistency & Brand-first
- 候选池:以 general + `personalization_power≤0.5` 为主
- 权重建议:`w_need=0.25, w_emotion=0.25, w_stage=0.30, w_context=0.20`
- 情绪区间约束:建议 `emotion_score` 限制在 0.4~0.8(过低/过高降权或过滤,具体由 plan 定)
---
## 9. 数据库与存储契约DB Contract
模块需要 DB 提供以下能力(不限定表名实现方式):
- **按标签召回**:能按 need/context/stage/general、personalization_power、risk_flags 等条件筛选候选。
- **按 ID 批量拉取**:用于补全曝光历史或在 rerank 时补字段。
- **安全池支持**L3 兜底必须能拉到一批“通用安全句”(白名单/低风险集合)。
性能要求V1
- 单次推荐k<=30应避免 N+1 查询;候选批量拉取 + 内存打分。
- 查询需可加索引:`stage``general``personalization_power`、(以及用于召回的标签字段)。
---
## 10. 可观测性(事件与指标)
每次推荐Feed session / 每次 Push / 每日 Widget必须能产出以下字段由上层统一打点即可
- `candidate_pool_size_raw`
- `candidate_pool_size_after_hard_filter`
- `candidate_pool_size_after_dedup`
- `candidate_pool_size_after_freqcap`
- `fallback_level_final`
- `served_k`
- `empty_reason`served_k=0 时必填)
建议额外输出(用于调参/排查):
- `scene`
- `conf_U`
- `missing_fields`need/context/emotion 的缺失情况)
- `risk_filtered_count_by_flag`(可选聚合)
---
## 11. 安全与合规
- 严禁返回 Hard Filter 禁推内容。
- 对 Push 场景,默认倾向“低风险 + 低个性化强度”;当画像不确定或字段缺失时必须进一步降个性化。
- 不在仓库写入真实数据库账号密码;连接串统一从 `DATABASE_URL` 环境变量读取(已由 `server/app/core/config.py` 支持)。
---
## 12. 验收标准Acceptance Criteria
- 能以统一接口在三种场景运行:输入画像 + 曝光集合 → 输出 TopK 句子。
- 画像字段缺失时仍能产出排序,并触发降个性化策略(至少 L1不会报错。
- Hard Filter 规则生效:命中 `block_health_medical` 等风险标记的内容绝不出现在输出中。
- 候选不足时会逐级回退并输出 `fallback_level_final`,不会出现无输出且无原因字段的情况。
- 输出包含用于上层打点的 meta 统计字段,便于观测候选规模与回退率。
---
## 13. 子模块规范索引modules/
> 子模块规范用于实现拆分;本文件保留大需求高层约束与对外契约摘要。
- `modules/db-design/spec.md`:数据库设计与迁移(当前库为空,本需求内补齐)
- `modules/content-repository/spec.md`:数据访问层(按画像与场景拉取候选、按 ID 批量查)
- `modules/reco-engine/spec.md`:推荐引擎编排(候选→过滤→打分→重排→回退)
- `modules/scoring/spec.md`:打分与惩罚项(严格对齐规则文档;含缺失字段保守策略)
- `modules/rerank-freqcap/spec.md`:重排/去重/频控Feed 的 MMRPush/Widget 冷却)
- `modules/observability/spec.md`可观测事件结构与指标candidate_pool_size_* 等)
- `modules/integration-api-worker/spec.md`API 与 Celery 集成(请求入参、响应、任务封装)

View File

@@ -0,0 +1,184 @@
# User Profile Scoring技术计划 · V1.2
## 1. 计划目标
在**客户端TypeScript**实现一个可复用的“用户画像计算”模块:
- **输入**Onboarding 问卷答案(每题**可跳过**;字段可缺失/为 `null`
- **输出**:严格符合 `spec_kit/User Profile Scoring/spec.md`V1.2)的用户画像对象(含 `profile_answered`、四维度字段、`profile_confidence`
- **硬规则**:输出一份“下游可直接执行/可观测”的硬规则结果,用于推荐/Push/Widget 统一复用(对齐 V1.2 规则语义)
- **约束**:不抛异常、不报错;非法值按**跳过**处理
## 2. 默认技术决策(本计划采用)
- **实现位置**`client/src/features/userProfileScoring/`
- **语言**TypeScript纯函数为主便于测试与跨端复用
- **时间与可测性**:允许调用方注入 `generatedAt/now`,默认使用 `new Date()`,保证可回归测试
- **规则对齐来源**
- 画像与置信度:`设计说明文档/客戶端問卷打分規則.md`V1.2
- 下游过滤语义:`设计说明文档/個性化推薦算法規則.md`Hard Filter / `risk_flags` 语义)
## 3. 输入与输出契约V1.2,含跳过)
### 3.1 输入:问卷答案(允许部分缺失/跳过)
定义输入类型 `QuestionnaireAnswersV1_2`(所有字段可选;也允许显式 `null`
- `mom_stage?: "expecting" | "parenting" | "unknown" | null`
- `emotion?: "low" | "overwhelmed" | "tired" | "neutral" | "calm" | "joyful" | null`
- `context?: "family" | "work" | "relationship" | "friends" | "health" | null`
- `need?: "emotional_support" | "parenting_pressure" | "self_worth" | "anxiety_relief" | "rest_balance" | null`
非法值处理策略:
- 字段存在但值不在枚举内:按**跳过**处理(归一化为 `undefined`),不抛错
### 3.2 输出:用户画像(严格对齐 spec.md V1.2
输出 `UserProfileV1_2`,满足 `spec.md` 的字段与取值:
- `profile_version: "v1.2"`
- `profile_source: "questionnaire"`
- `profile_generated_at: string`ISO8601
- `profile_confidence: number`01
- `profile_answered: { stage: boolean; emotion: boolean; context: boolean; need: boolean }`
- `stage: { expecting?: 0|1; parenting?: 0|1; unknown: 0|1 }`
- 注:题目跳过时按安全策略输出 `unknown=1`
- `emotion_score: number | null`
- `context: Record<string, 1>`(稀疏 one-hot题目跳过时为 `{}`
- `need: Record<string, 1>`(稀疏 one-hot题目跳过时为 `{}`
### 3.3 额外输出:硬规则(供下游直接执行/打点)
`spec.md` 要求“向下游暴露可直接执行的硬规则结果”,但不强制字段结构。本计划采用以下扩展字段(不破坏 `spec.md` 核心结构):
- `rule_hits: string[]`
- 仅用于可观测/回归测试(例如 `["unsafe_for_emotion_low"]`
- `hard_rules: { ... }`
- `forbidden_risk_flags: string[]`(对齐内容侧/推荐侧 `risk_flags` 名称)
- `forbidden_content_predicates: Array<{ id: string; when_user: object; forbid_content: object }>`
- 用于表达“需要同时看用户与内容字段才能执行”的规则(例如 unknown + 育儿压力 + 强个性化)
## 4. 跳过题目的处理策略V1.2:安全优先 + 可观测)
> 目标:题目跳过时不报错;画像仍可计算;并通过 `conf_U` 与下游 `P_uncertainty` 思路自动降个性化/降风险。
- **mom_stage 跳过**
- 输出:`stage.unknown = 1`
- `profile_answered.stage = false`
- **emotion 跳过**
- 输出:`emotion_score = null`(不强行填 0.6,避免制造“伪精确画像”)
- `profile_answered.emotion = false`
- **context 跳过**
- 输出:`context = {}`
- `profile_answered.context = false`
- **need 跳过**
- 输出:`need = {}`
- `profile_answered.need = false`
## 5. 画像计算规则(映射 + 置信度 + 硬规则)
### 5.1 映射规则(对齐 `客戶端問卷打分規則.md` V1.2
- `mom_stage``stage.*`one-hot若跳过则 `unknown=1`
- `emotion``emotion_score`
- `low=0.0``overwhelmed=0.2``tired=0.4``neutral=0.6``calm=0.8``joyful=1.0`
- 若跳过:`emotion_score = null`
- `context` → 稀疏 one-hot例如 `work``{ work: 1 }`;若跳过:`{}`
- `need` → 稀疏 one-hot例如 `rest_balance``{ rest_balance: 1 }`;若跳过:`{}`
### 5.2 置信度(`profile_confidence / conf_U`计算V1.2
时间衰减(与 `spec.md` 一致):
- 07 天:`conf_time = 1.0`
- 730 天:线性衰减到 `0.7`
- 30 天以上:`conf_time = 0.5`
作答完整度因子(对齐 `客戶端問卷打分規則.md` V1.2
- `answered_count = Σ I[profile_answered.* = true]``total_questions = 4`
- `completion = answered_count / total_questions`
- `completion_factor = 0.5 + 0.5 * completion`
- `profile_confidence = clamp(conf_time * completion_factor, 0.2, 1.0)`
### 5.3 硬规则输出(对齐 V1.2
本模块输出 `rule_hits``hard_rules`,用于下游先过滤、后打分:
1) **按用户状态映射到内容 `risk_flags` 的禁推**
-`stage.unknown = 1`
- `rule_hits += ["unsafe_for_stage_unknown"]`
- `hard_rules.forbidden_risk_flags += ["unsafe_for_stage_unknown"]`
-`stage.parenting = 1`
- `rule_hits += ["unsafe_for_stage_parenting"]`
- `hard_rules.forbidden_risk_flags += ["unsafe_for_stage_parenting"]`
-`emotion_score !== null``emotion_score <= 0.2`
- `rule_hits += ["unsafe_for_emotion_low"]`
- `hard_rules.forbidden_risk_flags += ["unsafe_for_emotion_low"]`
2) **跨维度产品规则(需要同时看用户与内容字段)**
-`stage.unknown = 1`
- 增加一个可执行谓词,表达“禁推 育儿压力且高个性化内容”:
- `when_user`: `{ stage_unknown: true }`
- `forbid_content`: `{ need: "parenting_pressure", personalization_power: 1 }`
- 该规则由下游在内容侧字段存在时执行(对齐 `spec.md` 7 与 `客戶端問卷打分規則.md` 6 的 V1.2 口径)
> 注:本模块不判断内容的 `personalization_power`(属于内容侧),只输出“可执行条件”。
## 6. 文件与目录规划(客户端)
`client/src/` 新增/调整:
- `client/src/features/userProfileScoring/types.ts`
- `QuestionnaireAnswersV1_2` / `UserProfileV1_2` / 枚举联合类型
- `client/src/features/userProfileScoring/scoring.ts`
- `buildUserProfileFromQuestionnaire(answers, options): UserProfileV1_2 & { rule_hits; hard_rules }`
- `computeTimeConfidence(generatedAt, now): number`
- `normalizeAnswers(raw): QuestionnaireAnswersV1_2`(非法值 → 跳过)
- `computeProfileAnswered(normalized): profile_answered`
- `computeProfileConfidence(conf_time, profile_answered): number`
- `client/src/features/userProfileScoring/index.ts`
- 对外导出(供 Onboarding/推荐/Push 调用)
## 7. 测试计划(客户端可回归)
若工程未提供 `test` 脚本,建议补齐最小单元测试能力(只测纯函数):
- 新增 `vitest`(或 `jest`)为 devDependencies并在 `client/package.json` 增加 `test` 脚本
-`buildUserProfileFromQuestionnaire` 增加用例(与 `spec.md` 第 10 章一致):
- 映射正确性(完整作答)
- 置信度时间衰减07/730/30+
- 硬规则触发(`emotion_score<=0.2``stage.unknown``stage.parenting`
- 跳过场景V1.2
- 全部跳过(输入 `{}``profile_answered` 全 false`stage.unknown=1``emotion_score=null``context/need={}``profile_confidence=0.5`(当 conf_time=1 时)
- 仅回答 1 题:验证 `completion_factor` 与 clamp最小 0.2)逻辑正确
## 8. 实施步骤
1.`client/src/features/userProfileScoring/` 增加类型定义与常量映射表V1.2
2. 实现答案归一化(非法值按跳过;支持 `null/undefined`
3. 实现画像生成主函数(含 `profile_answered`、元信息生成、置信度计算)
4. 实现硬规则命中与结构化输出(`forbidden_risk_flags` + `forbidden_content_predicates`
5. 补齐最小测试框架与单测(仅纯函数)
6. Onboarding 流程完成处接入(后续任务):问卷提交后生成画像对象;持久化/上传由上层决定
## 9. 验收标准
- 任意问卷答案输入(含任意题跳过/非法值),都能生成符合 `spec.md`V1.2)结构的用户画像对象(不抛错)
- 输出包含:
- V1.2 元信息(`profile_version/source/generated_at/confidence/profile_answered`
- 四维度字段(`stage/emotion_score/context/need`;允许跳过后的 `null/{}`
- `rule_hits``hard_rules`(供下游执行 Hard Filter 与打点)
- 置信度:
- 时间衰减符合 `spec.md` 6.2
- 跳过越多 `profile_confidence` 越低(按 V1.2 完整度因子),并被 clamp 到 `[0.2, 1.0]`
## 10. 后续演进(不阻塞 V1.2
- 支持 `context/need` 多选(输出仍为 one-hot可多个 key=1
- 引入 `profile_source="mixed"`:行为信号修正 `emotion_score` 或补全缺失维度(保留 questionnaire 原始画像用于可观测)
- 将硬规则输出进一步与推荐系统规则引擎 schema 对齐(使 `forbidden_content_predicates` 可直接落入规则引擎)

View File

@@ -4,13 +4,76 @@
本模块用于将 Onboarding 问卷答案转换为标准化、可计算、可版本化的**用户画像User Profile**,供其他模块(个性化推荐 / Push 推送排序 / 内容过滤)统一调用。
### 1.1 问卷信息V1.2,供外部 UI 实现;支持可跳过)
本模块**不接入 UI**,仅定义“问卷答案 → 用户画像”的规则。外部 UI或服务端只需在用户完成 Onboarding 问卷后,按以下题目将答案编码为枚举值并调用本模块。
V1.2 题目集合(每题**可跳过**;作答时为单选;**中英对齐**
#### Q1 母职阶段(`mom_stage`
- 中文:你处于哪个母职阶段?
- English: Which stage of motherhood are you in?
| UI 展示(中文) | UI 展示English | 枚举值(提交给本模块) |
| --- | --- | --- |
| 备孕 / 怀孕 / 准备迎接宝宝 | Pregnant / Preparing | `expecting` |
| 已经在育儿阶段 | Parenting | `parenting` |
| 不想说 / 不希望被母职身份定义 | Prefer not to say | `unknown` |
#### Q2 当下情绪(`emotion`
- 中文:你现在感觉如何?
- English: How are you feeling right now?
| UI 展示(中文) | UI 展示English | 枚举值(提交给本模块) |
| --- | --- | --- |
| 低落 | Low | `low` |
| 过载 / 不堪重负 | Overwhelmed | `overwhelmed` |
| 疲惫 | Tired | `tired` |
| 还好 / 一般 | Okay | `neutral` |
| 平静 | Calm | `calm` |
| 愉悦 | Joyful | `joyful` |
#### Q3 主要触发场景(`context`
- 中文:这些感受主要受到什么影响?
- English: Whats been influencing how you feel?
| UI 展示(中文) | UI 展示English | 枚举值(提交给本模块) |
| --- | --- | --- |
| 家庭 | Family | `family` |
| 工作或学习 | Work or study | `work` |
| 亲密关系 | Relationship | `relationship` |
| 朋友 | Friends | `friends` |
| 健康 | Health | `health` |
#### Q4 最需要的支持(`need`
- 中文:你现在最需要哪一种支持?
- English: What kind of support do you need most right now?
| UI 展示(中文) | UI 展示English | 枚举值(提交给本模块) |
| --- | --- | --- |
| 情绪支持 | Emotional support | `emotional_support` |
| 育儿压力 | Parenting pressure | `parenting_pressure` |
| 自我价值 | Self-worth | `self_worth` |
| 缓解焦虑 | Anxiety relief | `anxiety_relief` |
| 休息与平衡 | Rest & balance | `rest_balance` |
说明:
- 外部 UI 可自由决定视觉样式,但建议文案**中英成对出现**并与上表一致;无论文案如何变化,**答案编码必须与上述枚举一致**,以保证跨端一致性与可回归测试。
- 每一题允许用户**跳过不答**;当题目被跳过时,外部调用可选择“不传该字段”或传 `null`(以具体接口约定为准),本模块将按第 6.1.1 节输出 `profile_answered` 并生成保守画像。
- 后续若扩展为多选(例如 `context/need` 多选),应遵循第 8 节兼容策略,保持 V1 输出结构可向后兼容。
该模块的设计原则:
- **不做心理诊断**:只描述「此刻允许被如何对待」
- 区分 **身份(离散)****状态(连续)**
- 允许「通用」与「高个性化」内容并存,并通过置信度控制“个性化力度”
来源规则文档:`设计说明文档/客戶端問卷打分規則.md`V1 / V1.1 工程补充
来源规则文档:`设计说明文档/客戶端問卷打分規則.md`V1 / V1.1 / V1.2
## 2. 模块定位与边界
@@ -37,38 +100,43 @@
### 4.1 输入(问卷答案)
V1 假设每题为**单选**
V1.2:每题**可跳过**;作答时为**单选**
- `mom_stage``expecting | parenting | unknown`
- `emotion``low | overwhelmed | tired | neutral | calm | joyful`
- `context``family | work | relationship | friends | health`
- `need``emotional_support | parenting_pressure | self_worth | anxiety_relief | rest_balance`
- `mom_stage?``expecting | parenting | unknown`
- `emotion?``low | overwhelmed | tired | neutral | calm | joyful`
- `context?``family | work | relationship | friends | health`
- `need?``emotional_support | parenting_pressure | self_worth | anxiety_relief | rest_balance`
> 说明:多选 `context/need` 可在后续版本扩展,但 V1 输出结构需保持可兼容升级。
> 说明:
>
> - 带 `?` 表示该字段允许缺失(题目跳过)。
> - 多选 `context/need` 可在后续版本扩展,但 V1 输出结构需保持可兼容升级。
### 4.2 输出(用户画像)
输出为一个 JSON 对象(或等价的 TypeScript 类型),包含:
- **元信息V1.1**
- `profile_version`: string`"v1.1"`
- **元信息V1.2**
- `profile_version`: string`"v1.2"`
- `profile_source`: `"questionnaire"`(后续可扩展 `"behavior"` / `"mixed"`
- `profile_generated_at`: stringISO8601 时间戳)
- `profile_confidence`: number01
- **四个维度V1**
- `stage`: one-hot
- `emotion_score`: number01
- `context`: one-hot
- `need`: one-hot
- `profile_answered`: object标记各题是否作答
- **四个维度V1V1.2 允许缺失/跳过)**
- `stage`: one-hot若题目跳过按安全策略输出 `unknown=1`
- `emotion_score`: number01`null`(题目跳过)
- `context`: one-hot 或空对象 `{}`(题目跳过)
- `need`: one-hot 或空对象 `{}`(题目跳过)
推荐的最小输出示例(结构示意):
```json
{
"profile_version": "v1.1",
"profile_version": "v1.2",
"profile_source": "questionnaire",
"profile_generated_at": "2026-01-30T00:00:00Z",
"profile_confidence": 1.0,
"profile_answered": { "stage": true, "emotion": true, "context": true, "need": true },
"stage": { "expecting": 0, "parenting": 1, "unknown": 0 },
"emotion_score": 0.4,
"context": { "work": 1 },
@@ -76,6 +144,22 @@ V1 假设每题为**单选**
}
```
题目跳过时的最小输出示例V1.2
```json
{
"profile_version": "v1.2",
"profile_source": "questionnaire",
"profile_generated_at": "2026-01-30T00:00:00Z",
"profile_confidence": 0.5,
"profile_answered": { "stage": false, "emotion": false, "context": false, "need": false },
"stage": { "unknown": 1 },
"emotion_score": null,
"context": {},
"need": {}
}
```
## 5. 画像字段定义V1
### 5.1 mom_stage母职阶段离散
@@ -89,6 +173,7 @@ V1 假设每题为**单选**
语义约束:
- `unknown` 的语义是:**不希望被母职身份定义**(不是“没有孩子”)
- 若题目被跳过:按安全策略输出 `stage.unknown = 1`,同时 `profile_answered.stage=false`
### 5.2 emotion当下情绪连续 01
@@ -105,7 +190,7 @@ V1 假设每题为**单选**
| Calm | emotion: calm | 0.8 |
| Joyful | emotion: joyful | 1.0 |
输出:`emotion_score ∈ [0,1]`
输出:`emotion_score ∈ [0,1]`;若题目被跳过则输出 `null`(或不输出该字段),并通过 `profile_answered.emotion=false` 表示“未作答”
### 5.3 context影响来源离散
@@ -117,6 +202,11 @@ V1 假设每题为**单选**
- Friends → `context.friends = 1`
- Health → `context.health = 1`
若题目被跳过:
- 输出 `context = {}`
- `profile_answered.context = false`
语义说明:
- `context` 不是情绪,而是情绪的“触发场景”
@@ -132,19 +222,38 @@ V1 假设每题为**单选**
- Anxiety relief → `need.anxiety_relief = 1`
- Rest & balance → `need.rest_balance = 1`
若题目被跳过:
- 输出 `need = {}`
- `profile_answered.need = false`
语义说明:
- `need` 是**推荐权重最高的维度**
- 表示“她现在最缺的是哪一种心理资源”
## 6. 元信息与置信度V1.1
## 6. 元信息与置信度V1.2
### 6.1 profile_version / source / generated_at
- `profile_version`:用于规则升级与兼容(建议从 `"v1.1"` 起步)
- `profile_version`:用于规则升级与兼容(建议从 `"v1.2"` 起步;本规范与规则文档已对齐 V1.2
- `profile_source`:固定为 `"questionnaire"`(后续可扩展)
- `profile_generated_at`:画像生成时间(用于推送降风险)
### 6.1.1 profile_answered支持题目可跳过V1.2
> 目的:区分“明确选择 unknown”与“题目被跳过导致 unknown/缺失”,并让下游在不确定时自动降个性化/降风险。
- `profile_answered`:记录每一题是否作答
- `stage/emotion/context/need``true/false`
输出约定与设计说明文档保持一致V1.2
- `mom_stage` 被跳过:按安全策略输出 `stage.unknown = 1`,同时 `profile_answered.stage=false`
- `emotion` 被跳过:`emotion_score` 输出 `null`(或不输出该字段),同时 `profile_answered.emotion=false`
- `context` 被跳过:`context` 输出为空对象 `{}`,同时 `profile_answered.context=false`
- `need` 被跳过:`need` 输出为空对象 `{}`,同时 `profile_answered.need=false`
### 6.2 profile_confidenceconf_U
默认规则(可配置参数):
@@ -155,6 +264,13 @@ V1 假设每题为**单选**
- 730 天:线性衰减到 `0.7`
- 30 天以上:`conf_U = 0.5`(除非用户重新做问卷或有行为信号更新)
V1.2 补充:叠加“作答完整度”(题目可跳过场景):
-`answered_count = Σ I[profile_answered.* = true]`,总题数 `total_questions = 4`
- `completion = answered_count / total_questions`
- `completion_factor = 0.5 + 0.5 * completion`
- `conf_U = clamp(conf_time * completion_factor, 0.2, 1.0)`(其中 `conf_time` 按时间衰减得到)
下游使用建议:
- Push 推送等高风险场景:当 `conf_U` 低时,应启用不确定性惩罚(例如 `P_uncertainty`)并限制个性化力度上限。
@@ -163,10 +279,11 @@ V1 假设每题为**单选**
本模块应向下游暴露“可直接执行”的硬规则结果(例如 `forbidden_tags``content_tone_blacklist`),以保证在任何排序之前先做安全过滤。
V1 核心禁推规则:
V1.2 核心禁推规则:
1. **`stage.unknown = 1`**
- 禁推:强指向育儿压力的内容(`need: parenting_pressure`
- 禁推:强指向育儿压力且高个性化的内容(用于避免“把她当妈妈/正在育儿”的对话
- 条件(由下游在内容侧判定):`need: parenting_pressure``personalization_power = 1`
2. **`stage.parenting = 1`**
- 禁推:明确怀孕 / 孕期 / 胎动等内容
3. **`emotion_score ≤ 0.2`low / overwhelmed**
@@ -188,3 +305,172 @@ V1 核心禁推规则:
- 输出包含元信息(版本、来源、时间、置信度)与四维度字段
- 硬规则输出可被推荐/推送模块直接用于过滤(先过滤、后打分)
## 10. 测试(输入题目答案 → 输出用户画像)
> 本模块作为**独立模块**暂不接入 UI外部 UI 只需按本规范提供枚举答案,本模块输出标准化用户画像对象。以下用例用于外部 UI / 服务端编写自动化测试,验证“输入答案 → 输出画像”稳定且可版本化。
### 10.1 测试约定(为可重复性而设)
- `profile_version`:固定为 `"v1.2"`
- `profile_source`:固定为 `"questionnaire"`
- `profile_generated_at`:测试用例中使用固定时间戳(避免随系统时间波动)
- `profile_confidence`
- 若“刚完成问卷”,测试时可令 `now == profile_generated_at`,期望 `profile_confidence = 1.0`
- 若测试时间衰减,则固定 `profile_generated_at``now`,按第 6.2 节规则计算期望值
### 10.2 字段映射用例(基础正确性)
#### 用例 A完整映射parenting + tired + work + rest_balance
输入(题目答案):
```json
{
"mom_stage": "parenting",
"emotion": "tired",
"context": "work",
"need": "rest_balance"
}
```
期望输出(用户画像;示例中 `profile_generated_at` 由测试固定注入):
```json
{
"profile_version": "v1.2",
"profile_source": "questionnaire",
"profile_generated_at": "2026-01-30T00:00:00Z",
"profile_confidence": 1.0,
"profile_answered": { "stage": true, "emotion": true, "context": true, "need": true },
"stage": { "expecting": 0, "parenting": 1, "unknown": 0 },
"emotion_score": 0.4,
"context": { "work": 1 },
"need": { "rest_balance": 1 }
}
```
#### 用例 Bunknown 语义(不希望被母职身份定义)
输入:
```json
{
"mom_stage": "unknown",
"emotion": "neutral",
"context": "relationship",
"need": "emotional_support"
}
```
期望输出(关键点:`stage.unknown = 1`
```json
{
"profile_version": "v1.2",
"profile_source": "questionnaire",
"profile_generated_at": "2026-01-30T00:00:00Z",
"profile_confidence": 1.0,
"profile_answered": { "stage": true, "emotion": true, "context": true, "need": true },
"stage": { "expecting": 0, "parenting": 0, "unknown": 1 },
"emotion_score": 0.6,
"context": { "relationship": 1 },
"need": { "emotional_support": 1 }
}
```
#### 用例 A2全部跳过V1.2 最小可计算画像)
输入(所有题都跳过;用“不传字段”的方式表示):
```json
{}
```
期望输出(关键点:`profile_answered` 全 false`stage.unknown=1``emotion_score=null``context/need={}`
```json
{
"profile_version": "v1.2",
"profile_source": "questionnaire",
"profile_generated_at": "2026-01-30T00:00:00Z",
"profile_confidence": 0.5,
"profile_answered": { "stage": false, "emotion": false, "context": false, "need": false },
"stage": { "unknown": 1 },
"emotion_score": null,
"context": {},
"need": {}
}
```
### 10.3 置信度衰减用例(时间维度)
> 本组用例只验证 `profile_confidence` 计算是否符合第 6.2 节;其余字段可沿用任意合法输入。
#### 用例 C07 天(不衰减)
- `profile_generated_at = 2026-01-01T00:00:00Z`
- `now = 2026-01-05T00:00:00Z`
期望:`profile_confidence = 1.0`
#### 用例 D730 天(线性衰减到 0.7
- `profile_generated_at = 2026-01-01T00:00:00Z`
- `now = 2026-01-11T00:00:00Z`(第 10 天)
期望:`profile_confidence ≈ 1.0 - 0.3 * (10 - 7) / (30 - 7) ≈ 0.9609`
> 测试建议:用浮点容差断言(例如 `abs(actual - expected) <= 1e-3`)。
#### 用例 E30 天以上(固定为 0.5
- `profile_generated_at = 2026-01-01T00:00:00Z`
- `now = 2026-02-15T00:00:00Z`
期望:`profile_confidence = 0.5`
### 10.4 硬规则触发用例(可选,但建议覆盖)
> 本节只验证“规则是否被触发”。由于第 7 节目前只规定了规则语义,未强约束硬规则输出字段结构,测试可用两种方式之一:
>
> - 方式 1若实现提供 `hard_rules`(或同义字段),则断言其包含对应禁推项
> - 方式 2实现不提供结构化 `hard_rules` 时,至少应提供可观测的 `rule_hits`(字符串数组)用于下游与回归测试
#### 用例 Femotion_score ≤ 0.2 禁推 joyful 调性
输入:
```json
{
"mom_stage": "expecting",
"emotion": "overwhelmed",
"context": "health",
"need": "anxiety_relief"
}
```
期望:
- `emotion_score = 0.2`
- 规则命中:`emotion_score ≤ 0.2` → 禁推高能量庆祝型joyful 调性)内容
#### 用例 Gstage.unknown 禁推“育儿压力 + 高个性化”内容
输入:
```json
{
"mom_stage": "unknown",
"emotion": "calm",
"context": "family",
"need": "parenting_pressure"
}
```
期望:
- `stage.unknown = 1`
- 规则命中:`stage.unknown = 1` → 下游禁推满足以下条件的内容:
- `need: parenting_pressure`
- `personalization_power = 1`

View File

@@ -0,0 +1,143 @@
# User Profile Scoring任务清单 · V1.2
> 依据:`spec_kit/User Profile Scoring/plan.md`V1.2
>
> 规则目标:在客户端实现“问卷答案(可跳过)→ 用户画像(含 profile_answered/confidence+ 硬规则输出”,供推荐/Push/Widget 复用。
## 任务状态说明
- `[ ]`:未开始
- `[~]`:进行中
- `[x]`:已完成
- `[-]`:已取消/不做
---
## A. 代码实现(客户端 TypeScript 模块)
### A1. 建立目录与导出入口
- [x]`client/src/features/` 下新增目录 `userProfileScoring/`
- **验收**:目录存在,且后续文件均放入该目录
- [x] 新建 `client/src/features/userProfileScoring/index.ts`
- **内容**:对外导出主函数与类型(避免上层直接深路径 import
- **验收**:上层可仅从 `.../userProfileScoring` 引入
### A2. 定义类型与枚举V1.2
- [x] 新建 `client/src/features/userProfileScoring/types.ts`
- **定义输入类型**`QuestionnaireAnswersV1_2`
- 字段允许 `undefined`/`null``mom_stage/emotion/context/need`
- **定义输出类型**`UserProfileV1_2`
- 固定 `profile_version: "v1.2"``profile_source: "questionnaire"`
- `profile_answered``{ stage; emotion; context; need }`
- `emotion_score: number | null`
- `context/need: Record<string, 1>`(稀疏 one-hot跳过为 `{}`
- **定义扩展输出类型**`UserProfileV1_2_Extended`
- `rule_hits: string[]`
- `hard_rules: { forbidden_risk_flags: string[]; forbidden_content_predicates: ... }`
- **验收**:类型字段与 `spec_kit/User Profile Scoring/spec.md`V1.2)一致
### A3. 实现答案归一化(非法值按跳过)
- [x] 新建 `client/src/features/userProfileScoring/scoring.ts`
- [x] 实现 `normalizeAnswers(raw): QuestionnaireAnswersV1_2`
- **规则**
- 值不在枚举内 → 归一化为 `undefined`
- `null` 保留为 `null`(表示显式跳过/无值)
- **验收**:任意输入不抛错;输出只包含合法枚举/`undefined`/`null`
### A4. 计算 profile_answeredV1.2
- [x] 实现 `computeProfileAnswered(normalized)`
- `stage = (mom_stage !== undefined && mom_stage !== null)`
- `emotion/context/need` 同理
- **验收**:与 `spec.md` 6.1.1 一致;可区分“缺失/跳过”与“明确作答”
### A5. 计算时间衰减 conf_time
- [x] 实现 `computeTimeConfidence(generatedAt, now): number`
- **规则**(与 `spec.md` 6.2 一致):
- 07 天1.0
- 730 天:线性衰减到 0.7
- 30 天以上0.5
- **验收**:给定固定时间输入,输出符合分段规则(允许浮点误差)
### A6. 计算 profile_confidenceV1.2 完整度因子 + clamp
- [x] 实现 `computeProfileConfidence(conf_time, profile_answered): number`
- **规则**(与 `设计说明文档/客戶端問卷打分規則.md` V1.2 一致):
- `answered_count = Σ I[profile_answered.* = true]`
- `completion = answered_count / 4`
- `completion_factor = 0.5 + 0.5 * completion`
- `conf = clamp(conf_time * completion_factor, 0.2, 1.0)`
- **验收**
- 全部跳过且 `conf_time=1``conf=0.5`
- 全部作答且 `conf_time=1``conf=1.0`
- 极端情况下不会低于 0.2 / 高于 1.0
### A7. 生成四维度画像字段(含跳过策略)
- [x] 实现 `buildUserProfileFromQuestionnaire(answers, options)`
- **元信息**
- `profile_version="v1.2"`
- `profile_source="questionnaire"`
- `profile_generated_at`:允许注入;默认 `new Date().toISOString()`
- **stage**
-`mom_stage` 缺失/为 `null`:按安全策略输出 `stage.unknown=1`
- 若为枚举:对应 one-hot
- **emotion_score**
- 若跳过:`null`
- 否则按映射表输出数值
- **context/need**
- 若跳过:输出 `{}`
- 否则输出稀疏 one-hot例如 `{ work: 1 }`
- **验收**:与 `spec.md` 4.2/5.x/6.x 一致
### A8. 硬规则与可观测输出rule_hits / hard_rules
- [x]`buildUserProfileFromQuestionnaire` 内生成 `rule_hits``hard_rules`
- **risk_flags 映射规则**(与 `spec.md` 7 一致):
- `stage.unknown=1``unsafe_for_stage_unknown`
- `stage.parenting=1``unsafe_for_stage_parenting`
- `emotion_score !== null && emotion_score<=0.2``unsafe_for_emotion_low`
- **跨维度规则谓词**(与 `spec.md` 7、客户端规则 V1.2 一致):
-`stage.unknown=1`:追加谓词
- `id`: 建议 `unknown_block_parenting_pressure_personalized`
- `when_user`: `{ stage_unknown: true }`
- `forbid_content`: `{ need: "parenting_pressure", personalization_power: 1 }`
- **验收**
- 输出结构稳定
- 不依赖内容侧字段(本模块只输出“可执行条件”)
---
## B. 单元测试(只测纯函数)
> 若当前工程暂不引入测试框架,可先把测试用例写成“可执行脚本/最小断言”;但建议最终落地 `vitest` 或 `jest`。
### B1. 建立测试框架(可选但建议)
- [x]`client/` 尚无测试:引入 `vitest`(或 `jest`)并添加 `test` 脚本
- **验收**:能在本机运行测试并输出结果
### B2. 覆盖 spec.md 的关键用例
- [x] 用例:完整作答映射正确(对应 `spec.md` 用例 A/B
- [x] 用例:时间衰减(对应 `spec.md` 用例 C/D/E
- [x] 用例:全部跳过 `{}`(对应 `spec.md` 用例 A2
- 断言:`profile_answered` 全 false`stage.unknown=1``emotion_score=null``context/need={}``profile_confidence=0.5`(当 conf_time=1
- [x] 用例:硬规则命中
- `emotion_score<=0.2` → 含 `unsafe_for_emotion_low`
- `stage.parenting=1` → 含 `unsafe_for_stage_parenting`
- `stage.unknown=1` → 含 `unsafe_for_stage_unknown` 且带跨维度谓词
---
## C. 接入与文档回写(后续任务)
- [ ] Onboarding 提交问卷处接入本模块(调用 `buildUserProfileFromQuestionnaire`
- **验收**:提交后能生成并持久化/上传画像对象(具体存储策略由上层决定)
- [ ] 在推荐/Push/Widget 侧消费 `hard_rules`
- **验收**Hard Filter 在打分前执行;且能使用跨维度谓词表达 unknown+育儿压力强个性化禁推

View File

@@ -82,7 +82,10 @@
- **核心范围**mom_stage离散、emotion_score01 连续、context离散、need离散四维度以及 `profile_version/source/generated_at/confidence`V1.1
- **主要约定**
- 输出结构稳定可版本化,便于规则演进
- 置信度随时间衰减,用于推送场景降个性化/降风险
- 硬规则优先于任何分数计算(例如 `unknown` 禁推育儿压力内容等)
- 支持题目可跳过:新增 `profile_answered` 标记各题是否作答;字段缺失时由推荐算法走“通用值 + 不确定性惩罚”
- mom_stage 跳过按安全策略视为 `unknown`用于避免冒犯emotion/context/need 跳过不强行填默认值
- 置信度 `conf_U` = 时间衰减 × 作答完整度(答得越少越不确定),用于推送场景降个性化/降风险
- 硬规则优先于任何分数计算:`unknown` 仅在“育儿压力且强个性化personalization_power=1”时禁推`block_health_medical` 全场景硬过滤
- **测试覆盖**:提供“输入题目答案 → 输出用户画像”的回归用例,覆盖字段映射、置信度衰减与硬规则触发(可选但建议)
- **阶段产物**
- `spec_kit/User Profile Scoring/spec.md`

View File

@@ -5,7 +5,7 @@
>
> **共用基礎**
>
> * 用戶畫像Umom_stage one-hotemotion_score、context one-hot、need one-hot
> * 用戶畫像Umom_stage one-hotemotion/context/need 允许缺失/跳过;含 `profile_confidence/profile_answered`
> * 文案畫像Cᵢstage、emotion_score、context/need suitability、personalization_power、risk_flags建議含 content_id/author_id/template_id/review_confidence
> * 推薦骨架Hard Filter → Soft Scoring → Rerank多樣性/新鮮度/疲勞)
@@ -24,6 +24,7 @@
1. **Candidate Generation候選集生成**
* 依 U 取一個「候選池」:匹配 need/context/stage 的內容 + 一部分通用內容
* V1.2:若問卷允許跳過導致 `U.need/U.context/U.emotion_score` 缺失,候選池需自動提高通用安全內容占比,并视为至少进入 L1限制 `personalization_power≤0.5`
2. **Hard Filter硬性過濾**
* 依 risk_flags 與產品規則剔除高風險內容
@@ -93,10 +94,37 @@ S_{core} + S_{personal} + S_{fresh} - P_{fatigue} - P_{repeat} - P_{risk} - P_{u
S_{core} = w_{need}S_{need} + w_{emotion}S_{emotion} + w_{stage}S_{stage} + w_{context}S_{context}
]
* `S_need = Cᵢ.need_suitability[U.need]`
* `S_context = Cᵢ.context_suitability[U.context]`
* `S_need = Cᵢ.need_suitability[U.need]`(若 U.need 缺失,见 V1.2
* `S_context = Cᵢ.context_suitability[U.context]`(若 U.context 缺失,见 V1.2
* `S_stage`general=1命中=1unknown對非unknown=0.7;其餘=0
* `S_emotion`:若文案 general→0.8;否則 `1-|U.emotion_score-Cᵢ.emotion_score|`
* `S_emotion`:若文案 general→0.8;否則 `1-|U.emotion_score-Cᵢ.emotion_score|`(若 U.emotion_score 缺失,见 V1.2
### V1.2:当问卷允许跳过题目时的打分约定(工程必读)
> 目标:用户画像字段不全时仍能产出排序;并在不确定时自动降个性化/降风险。
判定缺失:
* `U.need` 为空对象 `{}` 或不存在 → 视为缺失
* `U.context` 为空对象 `{}` 或不存在 → 视为缺失
* `U.emotion_score``null`/不存在 → 视为缺失
缺失时的保守计算:
* `S_need`
*`U.need` 缺失 → `S_need = 0.5`
* 否则 → `S_need = Cᵢ.need_suitability[U.need]`
* `S_context`
*`U.context` 缺失 → `S_context = 0.5`
* 否则 → `S_context = Cᵢ.context_suitability[U.context]`
* `S_emotion`
*`U.emotion_score` 缺失 → `S_emotion = 0.8`
* 否则按原定义计算(文案 general 仍为 0.8
额外约束与回退梯度一致6A
*`conf_U` 偏低或字段缺失明显:必须启用 `P_uncertainty`
* 若进入回退梯度(`fallback_level_final>0`):按 L1/L2/L3 限制 `personalization_power` 上限(见 3.1
個性化加成:
[
@@ -125,7 +153,7 @@ P_{uncertainty} = \beta \cdot (1-\text{conf}_U) \cdot (1-\text{conf}_{Cᵢ}) \cd
* `U.stage.unknown=1` → 禁推 `unsafe_for_stage_unknown`
* `U.stage.parenting=1` → 禁推 `unsafe_for_stage_parenting`
* `U.emotion_score≤0.2` → 禁推 `unsafe_for_emotion_low`
* `U.context.health=1` → 禁推 `block_health_medical`(僅擋「醫療/診斷/嚴重暗示」)
* `Cᵢ.risk_flags``block_health_medical`**全场景禁推Hard Filter**(仅挡「医疗/诊断/严重暗示」)
> `soft_health_sensitive` 不做 Hard Filter改由 Soft Penalty見 Push 的 P_risk
@@ -218,6 +246,11 @@ P_{uncertainty} = \beta \cdot (1-\text{conf}_U) \cdot (1-\text{conf}_{Cᵢ}) \cd
>
> * `personalization_power` 上限降為 `≤0.5`L1或 `0`L2/L3
> * 若候選不足,優先回退到「通用安全句庫」,而不是擴大高個性化內容
>
> V1.2(支持问卷跳过)补充:
>
> * 若 `profile_answered.need=false` 或 `U.need` 缺失不得按“need 强命中”召回,候选池应提高通用安全句占比,并至少按 L1 降个性化
> * 若 `profile_answered.emotion=false` 或 `U.emotion_score` 缺失Push 应偏向 `Cᵢ.emotion_score` 为 `general` 或 0.40.8 的稳定内容,并启用 `P_uncertainty`
## 5.3 Push 打分權重(建議)
@@ -313,7 +346,7 @@ V1.1 補充(工程預設):
* FeedMMR `λ=0.7`
* Push啟用 `P_uncertainty`(或併入 `P_risk``fallback_level>=1``personalization_power` 上限降為 `≤0.5`
* HealthHard Filter 僅擋 `block_health_medical``soft_health_sensitive` 走 Soft Penalty
* Health`block_health_medical` 全场景 Hard Filter`soft_health_sensitive` 走 Soft Penalty
> 一句話:
> **同一套 U×Cᵢ 打分,按場景調候選池、權重與重排約束。**

View File

@@ -214,7 +214,7 @@ C_i.review_confidence // 01AI/人工對標註結果的置信度(缺
* `block_health_medical`Hard Block
* 具「醫療/診斷/嚴重暗示」:疾病確診、用藥、治療方案、醫囑、手術、急重症、危險警示等
* 這類內容在 **Health 情境** 下特別容易翻車,建議 Hard Filter
* 这类内容属于高风险:**建议全场景Feed/Push/WidgetHard Filter**,不依赖用户是否选择 `context: health`
* `soft_health_sensitive`Soft Penalty
@@ -234,6 +234,10 @@ C_i.review_confidence // 01AI/人工對標註結果的置信度(缺
> AI Reviewer 若不確定,寧可標記 flag
### 置信度缺省值工程约定V1.2
* 若数据源未提供 `C_i.review_confidence`:推荐系统侧默认按 `0.7` 处理(用于 `P_uncertainty`),并建议后续补齐可观测与标注流程
---
## 九、AI Reviewer 打分流程(強烈建議)

View File

@@ -33,6 +33,8 @@
* `profile_source``questionnaire`(後續可擴展 `behavior` / `mixed`
* `profile_generated_at`ISO8601 時間戳
* `profile_confidence`= `conf_U``01`
* `profile_answered`:各題是否作答(用于区分“跳过” vs “明确选择”)
* `stage/emotion/context/need``true/false`
`profile_confidence` 建議規則V1.1 默認,可調參):
@@ -42,6 +44,36 @@
* 730 天:線性衰減到 `0.7`
* 30 天以上:`conf_U = 0.5`(除非用戶重新做問卷或有行為信號更新)
#### V1.2:支持“可跳过题目”的画像输出与置信度(工程必读)
> 前提:问卷允许用户跳过任意题目(包括 mom_stage / emotion / context / need
> 目标:保证任何情况下都能生成“可计算的 U”并在不确定时自动降个性化/降风险。
**V1.2 输出原则:**
* **mom_stage 跳过**:按安全策略处理为 `unknown`(即 `U.stage.unknown=1`),同时 `profile_answered.stage=false`
* **emotion 跳过**`U.emotion_score` 置为缺省(建议直接不输出该字段或输出 `null`),同时 `profile_answered.emotion=false`
* **context 跳过**`U.context` 输出为空对象 `{}`,同时 `profile_answered.context=false`
* **need 跳过**`U.need` 输出为空对象 `{}`,同时 `profile_answered.need=false`
> 说明:
>
> * 之所以“mom_stage 跳过也当作 unknown”是为了在 Push 等高风险场景优先保证不冒犯unknown 语义:不希望被母职身份定义)。
> * emotion/context/need 跳过不强行填默认值,避免制造“伪精确画像”;改由推荐算法在打分时走“通用值 + 不确定性惩罚”见「个性化推荐算法规则」V1.2 补充)。
**V1.2 `conf_U` 计算(在 V1.1 时间衰减基础上叠加“作答完整度”):**
定义:
* `conf_time`:按 V1.1 时间衰减得到01
* `answered_count = Σ I[profile_answered.* = true]`,总题数 `total_questions = 4`
* `completion = answered_count / total_questions`
* `completion_factor = 0.5 + 0.5 * completion`(答得越少越不确定,但仍保留基础信息)
则:
* `conf_U = clamp(conf_time * completion_factor, 0.2, 1.0)`
> Push 推送時若 `conf_U` 偏低,推薦系統需啟用 `P_uncertainty` 並限制 `personalization_power` 上限見「個性化推薦算法規則」V1.1)。
---
@@ -70,6 +102,7 @@
>
> * `mom_stage` 是「身份 / 階段」,不是強度,不做連續軸線
> * `unknown` 的語義是:**不希望被母職身份定義**(不是「沒有孩子」)
> * 若该题被跳过:按安全策略输出 `unknown`,同时 `profile_answered.stage=false`
### 推送原則(高層)
@@ -96,7 +129,7 @@
| Calm | emotion: calm | 0.8 |
| Joyful | emotion: joyful | 1.0 |
生成:`U.emotion_score ∈ [0,1]`
生成:`U.emotion_score ∈ [0,1]`;若该题跳过则输出 `null`(或不输出该字段),并通过 `profile_answered.emotion=false` 表示“未作答”
### 語義說明
@@ -126,6 +159,8 @@
| ----------------------------------------------------- | ----- |
| U.ctx.family / work / relationship / friends / health | 1 或 0 |
> 若该题被跳过:`U.context` 输出为空对象 `{}`,同时 `profile_answered.context=false`
### 語義說明
* context **不是情緒**,而是情緒的「觸發場景」
@@ -153,6 +188,8 @@
| ------------------------------------------------------------------------------------------ | ----- |
| U.need.emotional_support / parenting_pressure / self_worth / anxiety_relief / rest_balance | 1 或 0 |
> 若该题被跳过:`U.need` 输出为空对象 `{}`,同时 `profile_answered.need=false`
### 語義說明
* `need`**推薦權重最高的維度**
@@ -164,11 +201,12 @@
以下規則在推薦時 **優先於任何分數計算**
### 核心禁推規則V1
### 核心禁推規則V1.2
1. **mom_stage = unknown**
* ❌ 禁推:`need: parenting_pressure`(強指向
* ❌ 禁推:**高个性化**育儿压力内容(避免“把她当妈妈/正在育儿”对话
* 判定条件与推荐算法一致V1.2`Cᵢ.need_suitability[parenting_pressure]=1``Cᵢ.personalization_power=1`
2. **mom_stage = parenting**
@@ -192,6 +230,12 @@
"profile_source": "questionnaire",
"profile_generated_at": "2026-01-30T00:00:00Z",
"profile_confidence": 1.0,
"profile_answered": {
"stage": true,
"emotion": true,
"context": true,
"need": true
},
"stage": {
"expecting": 0,
"parenting": 1,
@@ -207,6 +251,31 @@
}
```
### 跳过题目时的输出示例V1.2
> 示例:用户跳过 emotion / context / needmom_stage 也跳过。
```json
{
"profile_version": "v1.2",
"profile_source": "questionnaire",
"profile_generated_at": "2026-01-30T00:00:00Z",
"profile_confidence": 0.5,
"profile_answered": {
"stage": false,
"emotion": false,
"context": false,
"need": false
},
"stage": {
"unknown": 1
},
"emotion_score": null,
"context": {},
"need": {}
}
```
---
## 八、設計總結給產品工程AI Reviewer