From 6dc4e2b943af597fa561e1c560725e8b7500d9ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=96=B0=E9=9B=A8?= Date: Mon, 2 Feb 2026 16:47:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=8A=9F=E8=83=BD=EF=BC=9A=E4=B8=AA?= =?UTF-8?q?=E6=80=A7=E5=8C=96=E6=8E=A8=E8=8D=90=E7=AE=97=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/commands/myspec.code.md | 3 +- .cursor/commands/myspec.task.md | 1 + .cursor/commands/myspec.test.md | 1 + .cursor/rules/rule.mdc | 1 + client/app/(app)/home.tsx | 71 ++- client/app/(onboarding)/onboarding.tsx | 63 ++- client/app/index.tsx | 5 +- .../components/onboarding/SelectionStep.tsx | 38 +- client/ios/Podfile.lock | 412 ++++-------------- client/ios/client.xcodeproj/project.pbxproj | 4 - client/src/constants/env.ts | 21 +- .../__tests__/onboarding-integration.test.ts | 51 +++ .../src/features/userProfileScoring/index.ts | 4 + .../userProfileScoring/onboardingMapping.ts | 61 +++ client/src/services/recoApi.ts | 63 +++ client/src/storage/appStorage.ts | 132 ++++++ server/{.env.devs => .env.dev} | 0 server/.test.db | Bin 0 -> 49152 bytes server/app/__pycache__/main.cpython-313.pyc | Bin 0 -> 1285 bytes .../api/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 275 bytes .../api/__pycache__/limits.cpython-313.pyc | Bin 0 -> 3356 bytes server/app/api/limits.py | 62 +++ .../v1/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 209 bytes .../api/v1/__pycache__/reco.cpython-313.pyc | Bin 0 -> 6571 bytes .../user_profile_scoring.cpython-313.pyc | Bin 0 -> 1164 bytes server/app/api/v1/reco.py | 156 +++++++ .../db/__pycache__/session.cpython-313.pyc | Bin 0 -> 1351 bytes .../content_profile.cpython-313.pyc | Bin 3047 -> 3047 bytes .../features/personalized_reco/__init__.py | 6 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 373 bytes .../content_repository/__init__.py | 8 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 467 bytes .../__pycache__/interface.cpython-313.pyc | Bin 0 -> 1670 bytes .../__pycache__/normalization.cpython-313.pyc | Bin 0 -> 4141 bytes .../sqlalchemy_repo.cpython-313.pyc | Bin 0 -> 11601 bytes .../__pycache__/types.cpython-313.pyc | Bin 0 -> 2557 bytes .../content_repository/interface.py | 36 ++ .../content_repository/normalization.py | 130 ++++++ .../content_repository/sqlalchemy_repo.py | 265 +++++++++++ .../content_repository/types.py | 64 +++ .../observability/__init__.py | 20 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 747 bytes .../__pycache__/builder.cpython-313.pyc | Bin 0 -> 7377 bytes .../__pycache__/types.cpython-313.pyc | Bin 0 -> 2113 bytes .../__pycache__/utils.cpython-313.pyc | Bin 0 -> 2148 bytes .../observability/builder.py | 136 ++++++ .../personalized_reco/observability/types.py | 51 +++ .../personalized_reco/observability/utils.py | 61 +++ .../personalized_reco/reco_engine/__init__.py | 11 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 563 bytes .../__pycache__/defaults.cpython-313.pyc | Bin 0 -> 748 bytes .../__pycache__/hard_filter.cpython-313.pyc | Bin 0 -> 5724 bytes .../__pycache__/orchestrator.cpython-313.pyc | Bin 0 -> 16371 bytes .../__pycache__/types.cpython-313.pyc | Bin 0 -> 4066 bytes .../__pycache__/utils.cpython-313.pyc | Bin 0 -> 3086 bytes .../personalized_reco/reco_engine/defaults.py | 14 + .../reco_engine/hard_filter.py | 128 ++++++ .../reco_engine/orchestrator.py | 396 +++++++++++++++++ .../personalized_reco/reco_engine/types.py | 101 +++++ .../personalized_reco/reco_engine/utils.py | 90 ++++ .../rerank_freqcap/__init__.py | 22 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 803 bytes .../__pycache__/defaults.cpython-313.pyc | Bin 0 -> 1118 bytes .../__pycache__/rerank.cpython-313.pyc | Bin 0 -> 8485 bytes .../__pycache__/types.cpython-313.pyc | Bin 0 -> 2686 bytes .../__pycache__/utils.cpython-313.pyc | Bin 0 -> 3998 bytes .../rerank_freqcap/defaults.py | 41 ++ .../rerank_freqcap/rerank.py | 208 +++++++++ .../personalized_reco/rerank_freqcap/types.py | 61 +++ .../personalized_reco/rerank_freqcap/utils.py | 107 +++++ .../personalized_reco/scoring/__init__.py | 22 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 797 bytes .../__pycache__/defaults.cpython-313.pyc | Bin 0 -> 1233 bytes .../scoring/__pycache__/score.cpython-313.pyc | Bin 0 -> 9081 bytes .../scoring/__pycache__/types.cpython-313.pyc | Bin 0 -> 2942 bytes .../scoring/__pycache__/utils.cpython-313.pyc | Bin 0 -> 2178 bytes .../personalized_reco/scoring/defaults.py | 44 ++ .../personalized_reco/scoring/score.py | 201 +++++++++ .../personalized_reco/scoring/types.py | 84 ++++ .../personalized_reco/scoring/utils.py | 59 +++ .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 525 bytes .../__pycache__/scoring.cpython-313.pyc | Bin 0 -> 6817 bytes .../__pycache__/types.cpython-313.pyc | Bin 0 -> 4497 bytes server/app/main.py | 2 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 211 bytes .../tasks/__pycache__/reco.cpython-313.pyc | Bin 0 -> 5711 bytes server/app/tasks/reco.py | 168 +++++++ server/requirements.txt | 3 + server/run.sh | 145 ++++++ .../conftest.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 10653 bytes ...nt_repository.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 18510 bytes ...on_api_worker.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 11351 bytes ...observability.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 18938 bytes ...t_reco_engine.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 16574 bytes ...erank_freqcap.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 11513 bytes .../test_scoring.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 20718 bytes server/tests/conftest.py | 223 ++++++++++ server/tests/test_content_repository.py | 178 ++++++++ server/tests/test_integration_api_worker.py | 165 +++++++ server/tests/test_observability.py | 129 ++++++ server/tests/test_reco_engine.py | 173 ++++++++ server/tests/test_rerank_freqcap.py | 130 ++++++ server/tests/test_scoring.py | 133 ++++++ spec_kit/Client User Identity/spec.md | 151 +++++++ .../modules/content-repository/plan.md | 275 ++++++++++++ .../modules/content-repository/spec.md | 5 +- .../modules/content-repository/tasks.md | 198 +++++++++ .../modules/integration-api-worker/plan.md | 205 +++++++++ .../modules/integration-api-worker/tasks.md | 188 ++++++++ .../modules/observability/plan.md | 190 ++++++++ .../modules/observability/tasks.md | 130 ++++++ .../modules/reco-engine/plan.md | 367 ++++++++++++++++ .../modules/reco-engine/tasks.md | 195 +++++++++ .../modules/rerank-freqcap/plan.md | 264 +++++++++++ .../modules/rerank-freqcap/tasks.md | 172 ++++++++ .../Personalized Reco/modules/scoring/plan.md | 271 ++++++++++++ .../modules/scoring/tasks.md | 167 +++++++ spec_kit/Personalized Reco/overview.md | 33 +- spec_kit/overview.md | 9 + 119 files changed, 7427 insertions(+), 357 deletions(-) create mode 100644 .cursor/commands/myspec.test.md create mode 100644 client/src/features/userProfileScoring/__tests__/onboarding-integration.test.ts create mode 100644 client/src/features/userProfileScoring/onboardingMapping.ts create mode 100644 client/src/services/recoApi.ts rename server/{.env.devs => .env.dev} (100%) create mode 100644 server/.test.db create mode 100644 server/app/__pycache__/main.cpython-313.pyc create mode 100644 server/app/api/__pycache__/__init__.cpython-313.pyc create mode 100644 server/app/api/__pycache__/limits.cpython-313.pyc create mode 100644 server/app/api/limits.py create mode 100644 server/app/api/v1/__pycache__/__init__.cpython-313.pyc create mode 100644 server/app/api/v1/__pycache__/reco.cpython-313.pyc create mode 100644 server/app/api/v1/__pycache__/user_profile_scoring.cpython-313.pyc create mode 100644 server/app/api/v1/reco.py create mode 100644 server/app/db/__pycache__/session.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/__init__.py create mode 100644 server/app/features/personalized_reco/__pycache__/__init__.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/content_repository/__init__.py create mode 100644 server/app/features/personalized_reco/content_repository/__pycache__/__init__.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/content_repository/__pycache__/interface.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/content_repository/__pycache__/normalization.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/content_repository/__pycache__/sqlalchemy_repo.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/content_repository/__pycache__/types.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/content_repository/interface.py create mode 100644 server/app/features/personalized_reco/content_repository/normalization.py create mode 100644 server/app/features/personalized_reco/content_repository/sqlalchemy_repo.py create mode 100644 server/app/features/personalized_reco/content_repository/types.py create mode 100644 server/app/features/personalized_reco/observability/__init__.py create mode 100644 server/app/features/personalized_reco/observability/__pycache__/__init__.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/observability/__pycache__/builder.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/observability/__pycache__/types.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/observability/__pycache__/utils.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/observability/builder.py create mode 100644 server/app/features/personalized_reco/observability/types.py create mode 100644 server/app/features/personalized_reco/observability/utils.py create mode 100644 server/app/features/personalized_reco/reco_engine/__init__.py create mode 100644 server/app/features/personalized_reco/reco_engine/__pycache__/__init__.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/reco_engine/__pycache__/defaults.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/reco_engine/__pycache__/hard_filter.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/reco_engine/__pycache__/orchestrator.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/reco_engine/__pycache__/types.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/reco_engine/__pycache__/utils.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/reco_engine/defaults.py create mode 100644 server/app/features/personalized_reco/reco_engine/hard_filter.py create mode 100644 server/app/features/personalized_reco/reco_engine/orchestrator.py create mode 100644 server/app/features/personalized_reco/reco_engine/types.py create mode 100644 server/app/features/personalized_reco/reco_engine/utils.py create mode 100644 server/app/features/personalized_reco/rerank_freqcap/__init__.py create mode 100644 server/app/features/personalized_reco/rerank_freqcap/__pycache__/__init__.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/rerank_freqcap/__pycache__/defaults.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/rerank_freqcap/__pycache__/rerank.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/rerank_freqcap/__pycache__/types.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/rerank_freqcap/__pycache__/utils.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/rerank_freqcap/defaults.py create mode 100644 server/app/features/personalized_reco/rerank_freqcap/rerank.py create mode 100644 server/app/features/personalized_reco/rerank_freqcap/types.py create mode 100644 server/app/features/personalized_reco/rerank_freqcap/utils.py create mode 100644 server/app/features/personalized_reco/scoring/__init__.py create mode 100644 server/app/features/personalized_reco/scoring/__pycache__/__init__.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/scoring/__pycache__/defaults.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/scoring/__pycache__/score.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/scoring/__pycache__/types.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/scoring/__pycache__/utils.cpython-313.pyc create mode 100644 server/app/features/personalized_reco/scoring/defaults.py create mode 100644 server/app/features/personalized_reco/scoring/score.py create mode 100644 server/app/features/personalized_reco/scoring/types.py create mode 100644 server/app/features/personalized_reco/scoring/utils.py create mode 100644 server/app/features/user_profile_scoring/__pycache__/__init__.cpython-313.pyc create mode 100644 server/app/features/user_profile_scoring/__pycache__/scoring.cpython-313.pyc create mode 100644 server/app/features/user_profile_scoring/__pycache__/types.cpython-313.pyc create mode 100644 server/app/tasks/__pycache__/__init__.cpython-313.pyc create mode 100644 server/app/tasks/__pycache__/reco.cpython-313.pyc create mode 100644 server/app/tasks/reco.py create mode 100755 server/run.sh create mode 100644 server/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc create mode 100644 server/tests/__pycache__/test_content_repository.cpython-313-pytest-9.0.2.pyc create mode 100644 server/tests/__pycache__/test_integration_api_worker.cpython-313-pytest-9.0.2.pyc create mode 100644 server/tests/__pycache__/test_observability.cpython-313-pytest-9.0.2.pyc create mode 100644 server/tests/__pycache__/test_reco_engine.cpython-313-pytest-9.0.2.pyc create mode 100644 server/tests/__pycache__/test_rerank_freqcap.cpython-313-pytest-9.0.2.pyc create mode 100644 server/tests/__pycache__/test_scoring.cpython-313-pytest-9.0.2.pyc create mode 100644 server/tests/conftest.py create mode 100644 server/tests/test_content_repository.py create mode 100644 server/tests/test_integration_api_worker.py create mode 100644 server/tests/test_observability.py create mode 100644 server/tests/test_reco_engine.py create mode 100644 server/tests/test_rerank_freqcap.py create mode 100644 server/tests/test_scoring.py create mode 100644 spec_kit/Client User Identity/spec.md create mode 100644 spec_kit/Personalized Reco/modules/content-repository/plan.md create mode 100644 spec_kit/Personalized Reco/modules/content-repository/tasks.md create mode 100644 spec_kit/Personalized Reco/modules/integration-api-worker/plan.md create mode 100644 spec_kit/Personalized Reco/modules/integration-api-worker/tasks.md create mode 100644 spec_kit/Personalized Reco/modules/observability/plan.md create mode 100644 spec_kit/Personalized Reco/modules/observability/tasks.md create mode 100644 spec_kit/Personalized Reco/modules/reco-engine/plan.md create mode 100644 spec_kit/Personalized Reco/modules/reco-engine/tasks.md create mode 100644 spec_kit/Personalized Reco/modules/rerank-freqcap/plan.md create mode 100644 spec_kit/Personalized Reco/modules/rerank-freqcap/tasks.md create mode 100644 spec_kit/Personalized Reco/modules/scoring/plan.md create mode 100644 spec_kit/Personalized Reco/modules/scoring/tasks.md diff --git a/.cursor/commands/myspec.code.md b/.cursor/commands/myspec.code.md index ac5ab8d..080d585 100644 --- a/.cursor/commands/myspec.code.md +++ b/.cursor/commands/myspec.code.md @@ -1,4 +1,5 @@ 请开始完成编码 客户端请按照标准的RN架构目录写代码 后端请按照标准的python FastAPI 架构目录写代码 -现在多语言仅支持 EN / TC \ No newline at end of file +现在多语言仅支持 EN / TC +整个task.md执行完毕后需要在对应的overview.md标记,并且说明变更的文件名 diff --git a/.cursor/commands/myspec.task.md b/.cursor/commands/myspec.task.md index a3722db..23c4aec 100644 --- a/.cursor/commands/myspec.task.md +++ b/.cursor/commands/myspec.task.md @@ -2,3 +2,4 @@ 根据对应的plan.md 生成task.md 任务清单详细可执行 执行完要标记 +整个task.md执行完毕后需要在对应的overview.md标记 diff --git a/.cursor/commands/myspec.test.md b/.cursor/commands/myspec.test.md new file mode 100644 index 0000000..10c54b2 --- /dev/null +++ b/.cursor/commands/myspec.test.md @@ -0,0 +1 @@ +使用测试工具完成集成测试,并给我一份简单的测试报告 \ No newline at end of file diff --git a/.cursor/rules/rule.mdc b/.cursor/rules/rule.mdc index 02f5745..f8ad39f 100644 --- a/.cursor/rules/rule.mdc +++ b/.cursor/rules/rule.mdc @@ -28,4 +28,5 @@ modules/ 可嵌套 modules/,每层都独立规范。 输出时根据这个结构生成内容时,请保持文件职责清晰。 简短记录项目的该层每个spec的内容 ,每次编码完成后更新overview.md 可以通过nvm 切换node版本 +在对数据库操作中,禁止执行破坏性操作,如果必须请让我同意,并回复:允许操作数据库 diff --git a/client/app/(app)/home.tsx b/client/app/(app)/home.tsx index c0fb964..10c1fb8 100644 --- a/client/app/(app)/home.tsx +++ b/client/app/(app)/home.tsx @@ -14,12 +14,20 @@ import Animated, { import { MOCK_CONTENT } from '@/src/constants/mockContent'; import { addFavorite, + getRecoFeedCache, + getRecoFeedHistory, getThemeMode, getUserProfile, + getUserProfileScoring, + recordRecoFeedServed, + recordRecoFeedTouched, + setRecoFeedCache, setReaction, setThemeMode, + type RecoFeedCacheItem, type ThemeMode, } from '@/src/storage/appStorage'; +import { fetchRecoFeed } from '@/src/services/recoApi'; import ProfileModal from '@/components/home/ProfileModal'; import ThemeModal from '@/components/home/ThemeModal'; @@ -41,8 +49,11 @@ export default function HomeScreen() { const [profileName, setProfileName] = useState(undefined); const [busy, setBusy] = useState(false); const [likeFilled, setLikeFilled] = useState(false); + const [feedItems, setFeedItems] = useState>([]); - const item = useMemo(() => MOCK_CONTENT[index % MOCK_CONTENT.length], [index]); + const currentList = feedItems.length > 0 ? feedItems : MOCK_CONTENT; + const item = useMemo(() => currentList[index % currentList.length], [currentList, index]); + const currentContentId = typeof (item as any)?.content_id === 'number' ? Number((item as any).content_id) : null; // 动画相关 Shared Values const translateY = useSharedValue(0); @@ -66,6 +77,55 @@ export default function HomeScreen() { }, []) ); + // 首次进入:先读缓存,再拉后端 feed(失败则保持 mock/缓存) + useEffect(() => { + let cancelled = false; + (async () => { + const cache = await getRecoFeedCache(); + if (!cancelled && cache?.items?.length) { + setFeedItems(cache.items.map((x: RecoFeedCacheItem) => ({ content_id: x.content_id, text: x.text }))); + } + + const scoring = await getUserProfileScoring(); + if (!scoring) return; + + try { + const hist = await getRecoFeedHistory(); + const out = await fetchRecoFeed({ + k: 30, + user_profile: { + profile_version: scoring.profile_version, + profile_source: scoring.profile_source, + profile_generated_at: scoring.profile_generated_at, + profile_confidence: scoring.profile_confidence, + profile_answered: scoring.profile_answered, + stage: scoring.stage, + emotion_score: scoring.emotion_score, + context: scoring.context, + need: scoring.need, + }, + already_recommended_ids: hist.already_recommended_ids, + touched_or_viewed_ids: hist.touched_or_viewed_ids, + }); + + if (!cancelled && out.items?.length) { + setFeedItems(out.items.map((x) => ({ content_id: x.content_id, text: x.text }))); + await setRecoFeedCache({ + saved_at: new Date().toISOString(), + items: out.items.map((x) => ({ content_id: x.content_id, text: x.text })), + meta: out.meta as Record, + }); + await recordRecoFeedServed(out.items.map((x) => x.content_id)); + } + } catch { + // 忽略:保持缓存/本地 mock + } + })(); + return () => { + cancelled = true; + }; + }, []); + const backgroundColor = themeMode === 'color' ? '#F3D0E1' : '#F4D6C2'; useLayoutEffect(() => { @@ -105,6 +165,11 @@ export default function HomeScreen() { if (busy) return; setBusy(true); + // 记录“看过/划过”的内容 id(用于下一次向后端请求时去重/频控) + if (typeof currentContentId === 'number') { + void recordRecoFeedTouched(currentContentId); + } + // 1. 当前文案向上移动并消失 translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) }); opacity.value = withTiming(0, { duration: 300 }, (finished) => { @@ -125,7 +190,7 @@ export default function HomeScreen() { }); } }); - }, [busy, index, translateY, opacity]); + }, [busy, currentContentId, index, translateY, opacity]); const lastTapRef = useRef(0); @@ -176,7 +241,7 @@ export default function HomeScreen() { // 2. 保存到收藏夹,包含当前背景信息 await addFavorite({ - id: item.id, + id: typeof currentContentId === 'number' ? String(currentContentId) : (item as any).id, date: dateStr, themeMode: themeMode, background: backgroundColor, // 目前存储的是颜色值 diff --git a/client/app/(onboarding)/onboarding.tsx b/client/app/(onboarding)/onboarding.tsx index df0ef33..5104f72 100644 --- a/client/app/(onboarding)/onboarding.tsx +++ b/client/app/(onboarding)/onboarding.tsx @@ -5,7 +5,16 @@ import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout'; import { NameInputStep } from '@/components/onboarding/NameInputStep'; import { SelectionStep } from '@/components/onboarding/SelectionStep'; import { ReminderStep } from '@/components/onboarding/ReminderStep'; -import { setOnboardingCompleted, setUserProfile, setDailyReminderSettings } from '@/src/storage/appStorage'; +import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnaireAnswers } from '@/src/features/userProfileScoring'; +import { fetchRecoFeed } from '@/src/services/recoApi'; +import { + recordRecoFeedServed, + setOnboardingCompleted, + setUserProfile, + setDailyReminderSettings, + setUserProfileScoring, + setRecoFeedCache, +} from '@/src/storage/appStorage'; const STEPS = [ { id: 'name', type: 'name', title: '我可以怎么称呼你?' }, @@ -71,6 +80,40 @@ export default function OnboardingScreen() { const { status } = await Notifications.requestPermissionsAsync(); const pushEnabled = status === 'granted'; + // 将 Onboarding 选择映射为标准问卷枚举(允许跳过) + const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections); + + // 生成用户画像(供推荐/Push/Widget 复用) + const scoringProfile = buildUserProfileFromQuestionnaire(answers); + await setUserProfileScoring(scoringProfile); + + // Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页) + try { + const { items, meta } = await fetchRecoFeed({ + k: 30, + user_profile: { + profile_version: scoringProfile.profile_version, + profile_source: scoringProfile.profile_source, + profile_generated_at: scoringProfile.profile_generated_at, + profile_confidence: scoringProfile.profile_confidence, + profile_answered: scoringProfile.profile_answered, + stage: scoringProfile.stage, + emotion_score: scoringProfile.emotion_score, + context: scoringProfile.context, + need: scoringProfile.need, + }, + }); + + await setRecoFeedCache({ + saved_at: new Date().toISOString(), + items: items.map((x) => ({ content_id: x.content_id, text: x.text })), + meta: meta as Record, + }); + await recordRecoFeedServed(items.map((x) => x.content_id)); + } catch { + // 网络失败时使用首页本地 mock 兜底 + } + await setUserProfile({ name, intents: Object.values(selections).flat() @@ -98,19 +141,30 @@ export default function OnboardingScreen() { }; const onSkip = () => { + // 跳过整个 Onboarding:仍生成一个“全跳过”的最小画像,保证下游可用 + const scoringProfile = buildUserProfileFromQuestionnaire({}); + void setUserProfileScoring(scoringProfile); + + // 标记已完成,避免下次启动再次进入 Onboarding + void setOnboardingCompleted(true); + router.replace('/(app)/home'); }; + // 题目为单选:再次点击可取消;选择其他选项会替换为唯一选项 const handleToggleSelection = (id: string) => { setSelections(prev => { const currentIds = prev[currentStep.id] || []; - const nextIds = currentIds.includes(id) - ? currentIds.filter(i => i !== id) - : [...currentIds, id]; + const nextIds = currentIds.includes(id) ? [] : [id]; return { ...prev, [currentStep.id]: nextIds }; }); }; + const handleSkipStep = () => { + setSelections((prev) => ({ ...prev, [currentStep.id]: [] })); + onNext(); + }; + return ( )} diff --git a/client/app/index.tsx b/client/app/index.tsx index e9a5ae3..45f2198 100644 --- a/client/app/index.tsx +++ b/client/app/index.tsx @@ -14,9 +14,8 @@ export default function Index() { useEffect(() => { let cancelled = false; (async () => { - // 【完全重置】:清除本地存储的所有数据(收藏、设置、引导状态等) - await AsyncStorage.clear(); - console.log('AsyncStorage has been cleared.'); + // 注意:不要在启动时无条件清空存储,否则 Onboarding/画像等数据无法持久化。 + // 如需调试重置,请在开发期手动清空或自行加调试开关。 // 1. 检查是否同意协议 const consentAccepted = await getConsentAccepted(); diff --git a/client/components/onboarding/SelectionStep.tsx b/client/components/onboarding/SelectionStep.tsx index 8268cca..b70ad59 100644 --- a/client/components/onboarding/SelectionStep.tsx +++ b/client/components/onboarding/SelectionStep.tsx @@ -18,9 +18,10 @@ interface SelectionStepProps { selectedIds: string[]; onToggle: (id: string) => void; onNext: () => void; + onSkip?: () => void; } -export function SelectionStep({ options, selectedIds, onToggle, onNext }: SelectionStepProps) { +export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) { const hasSelection = selectedIds.length > 0; return ( @@ -48,13 +49,17 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext }: Select {/* 底部按钮:距离底部 12% 高度 */} - - {hasSelection ? : } - + + {onSkip && ( + + 跳过 + + )} + + + {hasSelection ? : } + + ); @@ -99,5 +104,20 @@ const styles = StyleSheet.create({ left: 0, right: 0, alignItems: 'center', - } + }, + footerRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 16, + }, + skipBtn: { + paddingVertical: 10, + paddingHorizontal: 14, + borderRadius: 12, + backgroundColor: 'rgba(0,0,0,0.04)', + }, + skipText: { + fontSize: 16, + color: OnboardingColors.textMuted, + }, }); diff --git a/client/ios/Podfile.lock b/client/ios/Podfile.lock index 87fc2f5..23e87b7 100644 --- a/client/ios/Podfile.lock +++ b/client/ios/Podfile.lock @@ -3,9 +3,6 @@ PODS: - ExpoModulesCore - EXConstants (18.0.13): - ExpoModulesCore - - EXJSONUtils (0.15.0) - - EXManifests (1.0.10): - - ExpoModulesCore - EXNotifications (0.32.16): - ExpoModulesCore - Expo (54.0.32): @@ -33,177 +30,6 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - expo-dev-client (6.0.20): - - EXManifests - - expo-dev-launcher - - expo-dev-menu - - expo-dev-menu-interface - - EXUpdatesInterface - - expo-dev-launcher (6.0.20): - - EXManifests - - expo-dev-launcher/Main (= 6.0.20) - - expo-dev-menu - - expo-dev-menu-interface - - ExpoModulesCore - - EXUpdatesInterface - - hermes-engine - - RCTRequired - - RCTTypeSafety - - React-Core - - React-Core-prebuilt - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-jsi - - React-jsinspector - - React-NativeModulesApple - - React-RCTAppDelegate - - React-RCTFabric - - React-renderercss - - React-rendererdebug - - React-utils - - ReactAppDependencyProvider - - ReactCodegen - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - ReactNativeDependencies - - Yoga - - expo-dev-launcher/Main (6.0.20): - - EXManifests - - expo-dev-launcher/Unsafe - - expo-dev-menu - - expo-dev-menu-interface - - ExpoModulesCore - - EXUpdatesInterface - - hermes-engine - - RCTRequired - - RCTTypeSafety - - React-Core - - React-Core-prebuilt - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-jsi - - React-jsinspector - - React-NativeModulesApple - - React-RCTAppDelegate - - React-RCTFabric - - React-renderercss - - React-rendererdebug - - React-utils - - ReactAppDependencyProvider - - ReactCodegen - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - ReactNativeDependencies - - Yoga - - expo-dev-launcher/Unsafe (6.0.20): - - EXManifests - - expo-dev-menu - - expo-dev-menu-interface - - ExpoModulesCore - - EXUpdatesInterface - - hermes-engine - - RCTRequired - - RCTTypeSafety - - React-Core - - React-Core-prebuilt - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-jsi - - React-jsinspector - - React-NativeModulesApple - - React-RCTAppDelegate - - React-RCTFabric - - React-renderercss - - React-rendererdebug - - React-utils - - ReactAppDependencyProvider - - ReactCodegen - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - ReactNativeDependencies - - Yoga - - expo-dev-menu (7.0.18): - - expo-dev-menu/Main (= 7.0.18) - - expo-dev-menu/ReactNativeCompatibles (= 7.0.18) - - hermes-engine - - RCTRequired - - RCTTypeSafety - - React-Core - - React-Core-prebuilt - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-jsi - - React-NativeModulesApple - - React-RCTFabric - - React-renderercss - - React-rendererdebug - - React-utils - - ReactCodegen - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - ReactNativeDependencies - - Yoga - - expo-dev-menu-interface (2.0.0) - - expo-dev-menu/Main (7.0.18): - - EXManifests - - expo-dev-menu-interface - - ExpoModulesCore - - hermes-engine - - RCTRequired - - RCTTypeSafety - - React-Core - - React-Core-prebuilt - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-jsi - - React-jsinspector - - React-NativeModulesApple - - React-RCTFabric - - React-renderercss - - React-rendererdebug - - React-utils - - ReactCodegen - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - ReactNativeDependencies - - Yoga - - expo-dev-menu/ReactNativeCompatibles (7.0.18): - - hermes-engine - - RCTRequired - - RCTTypeSafety - - React-Core - - React-Core-prebuilt - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-jsi - - React-NativeModulesApple - - React-RCTFabric - - React-renderercss - - React-rendererdebug - - React-utils - - ReactCodegen - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - ReactNativeDependencies - - Yoga - ExpoAsset (12.0.12): - ExpoModulesCore - ExpoFileSystem (19.0.21): @@ -248,8 +74,6 @@ PODS: - ExpoModulesCore - ExpoWebBrowser (15.0.10): - ExpoModulesCore - - EXUpdatesInterface (2.0.0): - - ExpoModulesCore - FBLazyVector (0.81.5) - hermes-engine (0.81.5): - hermes-engine/Pre-built (= 0.81.5) @@ -1974,28 +1798,6 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - RNGestureHandler (2.30.0): - - hermes-engine - - RCTRequired - - RCTTypeSafety - - React-Core - - React-Core-prebuilt - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-jsi - - React-NativeModulesApple - - React-RCTFabric - - React-renderercss - - React-rendererdebug - - React-utils - - ReactCodegen - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - ReactNativeDependencies - - Yoga - RNReanimated (4.1.6): - hermes-engine - RCTRequired @@ -2238,26 +2040,19 @@ PODS: DEPENDENCIES: - "EXApplication (from `../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios`)" - "EXConstants (from `../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios`)" - - "EXJSONUtils (from `../node_modules/.pnpm/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios`)" - - "EXManifests (from `../node_modules/.pnpm/expo-manifests@1.0.10_expo@54.0.32/node_modules/expo-manifests/ios`)" - - "EXNotifications (from `../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+r_758952db70529f49bda448def1c13c49/node_modules/expo-notifications/ios`)" - - "Expo (from `../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-nati_18ad48ba284ee86e6eb1cb0f939697b0/node_modules/expo`)" - - "expo-dev-client (from `../node_modules/.pnpm/expo-dev-client@6.0.20_expo@54.0.32/node_modules/expo-dev-client/ios`)" - - "expo-dev-launcher (from `../node_modules/.pnpm/expo-dev-launcher@6.0.20_expo@54.0.32/node_modules/expo-dev-launcher`)" - - "expo-dev-menu (from `../node_modules/.pnpm/expo-dev-menu@7.0.18_expo@54.0.32/node_modules/expo-dev-menu`)" - - "expo-dev-menu-interface (from `../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios`)" + - "EXNotifications (from `../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@1_nvlvke5tn7wk5pigfsu7j4ieeq/node_modules/expo-notifications/ios`)" + - "Expo (from `../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-native@0.8_7rhpxisdkrzvrgzbu7ct455kta/node_modules/expo`)" - "ExpoAsset (from `../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios`)" - "ExpoFileSystem (from `../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios`)" - "ExpoFont (from `../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios`)" - - "ExpoHead (from `../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.1_bd9aa16746ed7110429f931eb008e6d2/node_modules/expo-router/ios`)" + - "ExpoHead (from `../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_rjurfbyy5kjn57nkkfxix5iqea/node_modules/expo-router/ios`)" - "ExpoKeepAwake (from `../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios`)" - - "ExpoLinearGradient (from `../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+_53aef72480df9baa4504f4743d9c64bb/node_modules/expo-linear-gradient/ios`)" + - "ExpoLinearGradient (from `../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@_e6k2hjkd5k4lph2ersbp3gfshy/node_modules/expo-linear-gradient/ios`)" - "ExpoLinking (from `../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios`)" - "ExpoLocalization (from `../node_modules/.pnpm/expo-localization@17.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-localization/ios`)" - "ExpoModulesCore (from `../node_modules/.pnpm/expo-modules-core@3.0.29_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-modules-core`)" - "ExpoSplashScreen (from `../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios`)" - "ExpoWebBrowser (from `../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios`)" - - "EXUpdatesInterface (from `../node_modules/.pnpm/expo-updates-interface@2.0.0_expo@54.0.32/node_modules/expo-updates-interface/ios`)" - "FBLazyVector (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector`)" - "hermes-engine (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)" - "RCTDeprecation (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)" @@ -2294,7 +2089,7 @@ DEPENDENCIES: - "React-logger (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/logger`)" - "React-Mapbuffer (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)" - "React-microtasksnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)" - - "react-native-safe-area-context (from `../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+reac_14122a3aa345cfabcc022a0f638ef16d/node_modules/react-native-safe-area-context`)" + - "react-native-safe-area-context (from `../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1_azuxgonsvxb2yngtegtuvyxcpi/node_modules/react-native-safe-area-context`)" - "React-NativeModulesApple (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)" - "React-oscompat (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/oscompat`)" - "React-perflogger (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/reactperflogger`)" @@ -2326,12 +2121,11 @@ DEPENDENCIES: - ReactCodegen (from `build/generated/ios`) - "ReactCommon/turbomodule/core (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)" - "ReactNativeDependencies (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)" - - "RNCAsyncStorage (from `../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6__ce3c4972004f3d6791573ec5b64bee38/node_modules/@react-native-async-storage/async-storage`)" - - "RNGestureHandler (from `../node_modules/.pnpm/react-native-gesture-handler@2.30.0_react-native@0.81.5_@babel+core@7.28.6_@types+react_39cf7da47c9c8531caaa923ee740e293/node_modules/react-native-gesture-handler`)" - - "RNReanimated (from `../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+cor_c7c888bd389fb93c9cfe2d3c1c8b0777/node_modules/react-native-reanimated`)" + - "RNCAsyncStorage (from `../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6_@types_fp4qq3a7mejmut52v6jrlvxlzi/node_modules/@react-native-async-storage/async-storage`)" + - "RNReanimated (from `../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/node_modules/react-native-reanimated`)" - "RNScreens (from `../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens`)" - "RNSVG (from `../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg`)" - - "RNWorklets (from `../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@_40f69326ce21d3f9f6d74d3965fd9adf/node_modules/react-native-worklets`)" + - "RNWorklets (from `../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@types+_5atwepuw3zy3crkgvetf35tkve/node_modules/react-native-worklets`)" - "Yoga (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/yoga`)" EXTERNAL SOURCES: @@ -2339,22 +2133,10 @@ EXTERNAL SOURCES: :path: "../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios" EXConstants: :path: "../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios" - EXJSONUtils: - :path: "../node_modules/.pnpm/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios" - EXManifests: - :path: "../node_modules/.pnpm/expo-manifests@1.0.10_expo@54.0.32/node_modules/expo-manifests/ios" EXNotifications: - :path: "../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+r_758952db70529f49bda448def1c13c49/node_modules/expo-notifications/ios" + :path: "../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@1_nvlvke5tn7wk5pigfsu7j4ieeq/node_modules/expo-notifications/ios" Expo: - :path: "../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-nati_18ad48ba284ee86e6eb1cb0f939697b0/node_modules/expo" - expo-dev-client: - :path: "../node_modules/.pnpm/expo-dev-client@6.0.20_expo@54.0.32/node_modules/expo-dev-client/ios" - expo-dev-launcher: - :path: "../node_modules/.pnpm/expo-dev-launcher@6.0.20_expo@54.0.32/node_modules/expo-dev-launcher" - expo-dev-menu: - :path: "../node_modules/.pnpm/expo-dev-menu@7.0.18_expo@54.0.32/node_modules/expo-dev-menu" - expo-dev-menu-interface: - :path: "../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios" + :path: "../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-native@0.8_7rhpxisdkrzvrgzbu7ct455kta/node_modules/expo" ExpoAsset: :path: "../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios" ExpoFileSystem: @@ -2362,11 +2144,11 @@ EXTERNAL SOURCES: ExpoFont: :path: "../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios" ExpoHead: - :path: "../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.1_bd9aa16746ed7110429f931eb008e6d2/node_modules/expo-router/ios" + :path: "../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_rjurfbyy5kjn57nkkfxix5iqea/node_modules/expo-router/ios" ExpoKeepAwake: :path: "../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios" ExpoLinearGradient: - :path: "../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+_53aef72480df9baa4504f4743d9c64bb/node_modules/expo-linear-gradient/ios" + :path: "../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@_e6k2hjkd5k4lph2ersbp3gfshy/node_modules/expo-linear-gradient/ios" ExpoLinking: :path: "../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios" ExpoLocalization: @@ -2377,8 +2159,6 @@ EXTERNAL SOURCES: :path: "../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios" ExpoWebBrowser: :path: "../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios" - EXUpdatesInterface: - :path: "../node_modules/.pnpm/expo-updates-interface@2.0.0_expo@54.0.32/node_modules/expo-updates-interface/ios" FBLazyVector: :path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector" hermes-engine: @@ -2451,7 +2231,7 @@ EXTERNAL SOURCES: React-microtasksnativemodule: :path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/microtasks" react-native-safe-area-context: - :path: "../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+reac_14122a3aa345cfabcc022a0f638ef16d/node_modules/react-native-safe-area-context" + :path: "../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1_azuxgonsvxb2yngtegtuvyxcpi/node_modules/react-native-safe-area-context" React-NativeModulesApple: :path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" React-oscompat: @@ -2515,43 +2295,34 @@ EXTERNAL SOURCES: ReactNativeDependencies: :podspec: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec" RNCAsyncStorage: - :path: "../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6__ce3c4972004f3d6791573ec5b64bee38/node_modules/@react-native-async-storage/async-storage" - RNGestureHandler: - :path: "../node_modules/.pnpm/react-native-gesture-handler@2.30.0_react-native@0.81.5_@babel+core@7.28.6_@types+react_39cf7da47c9c8531caaa923ee740e293/node_modules/react-native-gesture-handler" + :path: "../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6_@types_fp4qq3a7mejmut52v6jrlvxlzi/node_modules/@react-native-async-storage/async-storage" RNReanimated: - :path: "../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+cor_c7c888bd389fb93c9cfe2d3c1c8b0777/node_modules/react-native-reanimated" + :path: "../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/node_modules/react-native-reanimated" RNScreens: :path: "../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens" RNSVG: :path: "../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg" RNWorklets: - :path: "../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@_40f69326ce21d3f9f6d74d3965fd9adf/node_modules/react-native-worklets" + :path: "../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@types+_5atwepuw3zy3crkgvetf35tkve/node_modules/react-native-worklets" Yoga: :path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f - EXConstants: fce59a631a06c4151602843667f7cfe35f81e271 - EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd - EXManifests: a8d97683e5c7a3b026ffbd58559c64dc655b747b - EXNotifications: 9eec98712cc814ceff916d876cb53859003b0597 - Expo: 4e503a041c59c4e34c8be262a135848ad5cd3710 - expo-dev-client: 425ee077d6754a98cfe3a2e2410d29b440b24c9d - expo-dev-launcher: a4f4cdef064ab1fb8621e5b8c7c457cd6e9568c3 - expo-dev-menu: 05b18812110c175814c6af0d09dd658abcc5e00d - expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533 - ExpoAsset: f867e55ceb428aab99e1e8c082b5aee7c159ea18 - ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063 - ExpoFont: f543ce20a228dd702813668b1a07b46f51878d47 - ExpoHead: 4425246bc93411f0fe7f6945f95f698e91db8780 - ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296 - ExpoLinearGradient: 809102bdb979f590083af49f7fa4805cd931bd58 - ExpoLinking: 8f0aaf69aa56f832913030503b6263dc6f647f37 - ExpoLocalization: d9168d5300a5b03e5e78b986124d11fb6ec3ebbd - ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583 - ExpoSplashScreen: bc3cffefca2716e5f22350ca109badd7e50ec14d - ExpoWebBrowser: 17b064c621789e41d4816c95c93f429b84971f52 - EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734 + EXApplication: 13420f8139864183f8a04fd6099077bdf8cfb186 + EXConstants: 3feb66fd1d94202fc1f0946d74e029d8b224b60e + EXNotifications: 2a3feb7af6194828d9aafda72f63a9a03866230a + Expo: b8d64eb9a496ebe8c71e3dae7eeb7f394b146b80 + ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b + ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad + ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664 + ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29 + ExpoKeepAwake: 44bf6715bc1d2ddb17afe19d927cd039cda123f0 + ExpoLinearGradient: 814a21fc4056c3cf606e4f19e31e47074c5b5a86 + ExpoLinking: ebf543fd411d56375cb4eee07f6ab4e31c7ad959 + ExpoLocalization: 6ac6f326210f0a3141ef6f58ab8f8f4ed003b485 + ExpoModulesCore: 77496909fd3c800f97f7f2007dd26aeac4bb3798 + ExpoSplashScreen: 72fbc6dd9d6404dd9d0725a56c9ac1383bc0b14f + ExpoWebBrowser: 88b116cd378d9609c776c0903fe4070fca461588 FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990 @@ -2559,72 +2330,71 @@ SPEC CHECKSUMS: RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c React: 914f8695f9bf38e6418228c2ffb70021e559f92f React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71 - React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5 - React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599 - React-CoreModules: 1f6d1744b5f9f2ec684a4bb5ced25370f87e5382 - React-cxxreact: 3af79478e8187b63ffc22b794cd42d3fc1f1f2da + React-Core: 4ae98f9e8135b8ddbd7c98730afb6fdae883db90 + React-Core-prebuilt: 8f4cca589c14e8cf8fc6db4587ef1c2056b5c151 + React-CoreModules: e878a90bb19b8f3851818af997dbae3b3b0a27ac + React-cxxreact: 28af9844f6dc87be1385ab521fbfb3746f19563c React-debug: 6328c2228e268846161f10082e80dc69eac2e90a - React-defaultsnativemodule: d635ef36d755321e5d6fc065bd166b2c5a0e9833 - React-domnativemodule: dd28f6d96cd21236e020be2eff6fe0b7d4ec3b66 - React-Fabric: 2e32c3fdbb1fbcf5fde54607e3abe453c6652ce2 - React-FabricComponents: 5ed0cdb81f6b91656cb4d3be432feaa28a58071a - React-FabricImage: 2bc714f818cb24e454f5d3961864373271b2faf8 - React-featureflags: 847642f41fa71ad4eec5e0351badebcad4fe6171 - React-featureflagsnativemodule: c868a544b2c626fa337bcbd364b1befe749f0d3f - React-graphics: 192ec701def5b3f2a07db2814dfba5a44986cff6 - React-hermes: e875778b496c86d07ab2ccaa36a9505d248a254b - React-idlecallbacksnativemodule: 4d57965cdf82c14ee3b337189836cd8491632b76 - React-ImageManager: bd0b99e370b13de82c9cd15f0f08144ff3de079e - React-jserrorhandler: a2fdef4cbcfdcdf3fa9f5d1f7190f7fd4535248d - React-jsi: 89d43d1e7d4d0663f8ba67e0b39eb4e4672c27de - React-jsiexecutor: abe4874aaab90dfee5dec480680220b2f8af07e3 - React-jsinspector: a0b3e051aef842b0b2be2353790ae2b2a5a65a8f - React-jsinspectorcdp: 6346013b2247c6263fbf5199adf4a8751e53bd89 - React-jsinspectornetwork: 26281aa50d49fc1ec93abf981d934698fa95714f - React-jsinspectortracing: 55eedf6d57540507570259a778663b90060bbd6e - React-jsitooling: 0e001113fa56d8498aa8ac28437ac0d36348e51a - React-jsitracing: b713793eb8a5bbc4d86a84e9d9e5023c0f58cbaf - React-logger: 50fdb9a8236da90c0b1072da5c32ee03aeb5bf28 - React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e - React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b - react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2 - React-NativeModulesApple: 8969913947d5b576de4ed371a939455a8daf28aa + React-defaultsnativemodule: afc9d809ec75780f39464a6949c07987fbea488c + React-domnativemodule: 91a233260411d41f27f67aa1358b7f9f0bfd101d + React-Fabric: 21f349b5e93f305a3c38c885902683a9c79cf983 + React-FabricComponents: 47ac634cc9ecc64b30a9997192f510eebe4177e4 + React-FabricImage: 21873acd6d4a51a0b97c133141051c7acb11cc86 + React-featureflags: 653f469f0c3c9dc271d610373e3b6e66a9fd847d + React-featureflagsnativemodule: c91a8a3880e0f4838286402241ead47db43aed28 + React-graphics: b4bdb0f635b8048c652a5d2b73eb8b1ddd950f24 + React-hermes: fcfad3b917400f49026f3232561e039c9d1c34bf + React-idlecallbacksnativemodule: 8cb83207e39f8179ac1d344b6177c6ab3ccebcdc + React-ImageManager: 396128004783fc510e629124dce682d38d1088e7 + React-jserrorhandler: b58b788d788cdbf8bda7db74a88ebfcffc8a0795 + React-jsi: d2c3f8555175371c02da6dfe7ed1b64b55a9d6c0 + React-jsiexecutor: ba537434eb45ee018b590ed7d29ee233fddb8669 + React-jsinspector: f21b6654baf96cb9f71748844a32468a5f73ad51 + React-jsinspectorcdp: 3f8be4830694c3c1c39442e50f8db877966d43f0 + React-jsinspectornetwork: 70e41469565712ad60e11d9c8b8f999b9f7f61eb + React-jsinspectortracing: eccf9bfa4ec7f130d514f215cfb2222dc3c0e270 + React-jsitooling: b376a695f5a507627f7934748533b24eed1751ca + React-jsitracing: 5c8c3273dda2d95191cc0612fb5e71c4d9018d2a + React-logger: c3e2f8a2e284341205f61eef3d4677ab5a309dfd + React-Mapbuffer: 603c18db65844bb81dbe62fee8fcc976eaeb7108 + React-microtasksnativemodule: d77e0c426fce34c23227394c96ca1033b30c813c + react-native-safe-area-context: 53f796cb6c814661bbe99fbdfd0585d07b996cdd + React-NativeModulesApple: 1664340b8750d64e0ef3907c5e53d9481f74bcbd React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c - React-perflogger: 02b010e665772c7dcb859d85d44c1bfc5ac7c0e4 - React-performancetimeline: 130db956b5a83aa4fb41ddf5ae68da89f3fb1526 + React-perflogger: b1af3cfb3f095f819b2814910000392a8e17ba9f + React-performancetimeline: f9ec65b77bcadbc7bd8b47a6f4b4b697da7b1490 React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916 - React-RCTAnimation: a7b90fd2af7bb9c084428867445a1481a8cb112e - React-RCTAppDelegate: 3262bedd01263f140ec62b7989f4355f57cec016 - React-RCTBlob: c17531368702f1ebed5d0ada75a7cf5915072a53 - React-RCTFabric: 6409edd8cfdc3133b6cc75636d3b858fdb1d11ea - React-RCTFBReactNativeSpec: c004b27b4fa3bd85878ad2cf53de3bbec85da797 - React-RCTImage: c68078a120d0123f4f07a5ac77bea3bb10242f32 - React-RCTLinking: cf8f9391fe7fe471f96da3a5f0435235eca18c5b - React-RCTNetwork: ca31f7c879355760c2d9832a06ee35f517938a20 - React-RCTRuntime: a6cf4a1e42754fc87f493e538f2ac6b820e45418 - React-RCTSettings: e0e140b2ff4bf86d34e9637f6316848fc00be035 - React-RCTText: 75915bace6f7877c03a840cc7b6c622fb62bfa6b - React-RCTVibration: 25f26b85e5e432bb3c256f8b384f9269e9529f25 + React-RCTAnimation: 60f6eca214a62b9673f64db6df3830cee902b5af + React-RCTAppDelegate: 37734b39bac108af30a0fd9d3e1149ec68b82c28 + React-RCTBlob: 83fbcbd57755caf021787324aac2fe9b028cc264 + React-RCTFabric: a05cb1df484008db3753c8b4a71e4c6d9f1e43a6 + React-RCTFBReactNativeSpec: d58d7ae9447020bbbac651e3b0674422aba18266 + React-RCTImage: 47aba3be7c6c64f956b7918ab933769602406aac + React-RCTLinking: 2dbaa4df2e4523f68baa07936bd8efdfa34d5f31 + React-RCTNetwork: 1fca7455f9dedf7de2b95bec438da06680f3b000 + React-RCTRuntime: 17819dd1dfc8613efaf4cbb9d8686baae4a83e5b + React-RCTSettings: 01bf91c856862354d3d2f642ccb82f3697a4284a + React-RCTText: cb576a3797dcb64933613c522296a07eaafc0461 + React-RCTVibration: 560af8c086741f3525b8456a482cdbe27f9d098e React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d - React-renderercss: 477da167bb96b5ac86d30c5d295412fb853f5453 - React-rendererdebug: 2a1798c6f3ef5f22d466df24c33653edbabb5b89 - React-RuntimeApple: 28cf4d8eb18432f6a21abbed7d801ab7f6b6f0b4 - React-RuntimeCore: 41bf0fd56a00de5660f222415af49879fa49c4f0 - React-runtimeexecutor: 1afb774dde3011348e8334be69d2f57a359ea43e - React-RuntimeHermes: f3b158ea40e8212b1a723a68b4315e7a495c5fc6 - React-runtimescheduler: 3e1e2bec7300bae512533107d8e54c6e5c63fe0f - React-timing: 6fa9883de2e41791e5dc4ec404e5e37f3f50e801 - React-utils: 6e2035b53d087927768649a11a26c4e092448e34 - ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79 - ReactCodegen: fffa79906f5866f6a5ab5d98480b375191190271 - ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8 + React-renderercss: c5c6b7a15948dd28facca39a18ac269073718490 + React-rendererdebug: 3c9d5e1634273f5a24d84cc5669f290ce0bdc812 + React-RuntimeApple: 887637d1e12ea8262df7d32bc100467df2302613 + React-RuntimeCore: 91f779835dc4f8f84777fe5dd24f1a22f96454e4 + React-runtimeexecutor: 8bb6b738f37b0ada4a6269e6f8ab1133dea0285c + React-RuntimeHermes: 4cb93de9fa8b1cc753d200dbe61a01b9ec5f5562 + React-runtimescheduler: 83dc28f530bfbd2fce84ed13aa7feebdc24e5af7 + React-timing: 03c7217455d2bff459b27a3811be25796b600f47 + React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a + ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee + ReactCodegen: 88a1f4643f15841573f833b895bfa2a0c6cb4e7f + ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a - RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4 - RNGestureHandler: e0d0bce5599f6120b7adf90c38d2805e2935795f - RNReanimated: 9c6a550b41de91cf374e60afd79db93a362f1126 - RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845 - RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34 - RNWorklets: 1b50cb7595142f95e70518196ba247ad7f46a52e + RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4 + RNReanimated: 10415bc8396eaeac0d7b2c9a1538eae7e607ec9c + RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51 + RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528 + RNWorklets: 9ccdc8112b17af6eee2c85a233891cb80db150ad Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a PODFILE CHECKSUM: dfe3cc75dee014a0abd367bc9e1bdbab0ba64ee3 diff --git a/client/ios/client.xcodeproj/project.pbxproj b/client/ios/client.xcodeproj/project.pbxproj index 37c23a4..4a11c49 100644 --- a/client/ios/client.xcodeproj/project.pbxproj +++ b/client/ios/client.xcodeproj/project.pbxproj @@ -374,8 +374,6 @@ "${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle", ); name = "[CP] Copy Pods Resources"; outputPaths = ( @@ -389,8 +387,6 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; diff --git a/client/src/constants/env.ts b/client/src/constants/env.ts index 8dc0443..6e880bc 100644 --- a/client/src/constants/env.ts +++ b/client/src/constants/env.ts @@ -20,9 +20,26 @@ function getOptionalEnv(name: string, fallback: string): string { return process.env[name] ?? fallback; } -export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', 'dev') as AppEnv) ?? 'dev'; +export type AppRuntimeEnv = 'local' | 'dev' | 'prod'; -export const API_BASE_URL = getRequiredEnv('EXPO_PUBLIC_API_BASE_URL'); +export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', 'local') as AppRuntimeEnv) ?? 'local'; + +function getApiBaseUrl(env: AppRuntimeEnv): string { + // 向后兼容:若直接提供了 EXPO_PUBLIC_API_BASE_URL,则优先使用(不再强制要求 *_DEV/_PROD) + const direct = process.env.EXPO_PUBLIC_API_BASE_URL; + if (direct && String(direct).trim()) return String(direct).trim(); + + // 约定:local/dev/prod 三套域名分别配置,便于后续直接切环境而不改代码 + if (env === 'local') { + return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000'); + } + if (env === 'dev') { + return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_DEV', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000')); + } + return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_PROD', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000')); +} + +export const API_BASE_URL = getApiBaseUrl(APP_ENV); /** * 默认语言策略: diff --git a/client/src/features/userProfileScoring/__tests__/onboarding-integration.test.ts b/client/src/features/userProfileScoring/__tests__/onboarding-integration.test.ts new file mode 100644 index 0000000..ba2a6dd --- /dev/null +++ b/client/src/features/userProfileScoring/__tests__/onboarding-integration.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { buildUserProfileFromQuestionnaire } from '../index'; +import { mapOnboardingSelectionsToQuestionnaireAnswers } from '../onboardingMapping'; + +describe('Onboarding → UserProfileScoring 集成', () => { + it('完整作答:Onboarding 选择能正确映射并生成画像', () => { + const selections = { + status: ['pregnant'], + emotion: ['calm'], + influence: ['work'], + support: ['balance'], + }; + + const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections); + expect(answers).toEqual({ + mom_stage: 'expecting', + emotion: 'calm', + context: 'work', + need: 'rest_balance', + }); + + const p = buildUserProfileFromQuestionnaire(answers, { + generatedAt: '2026-01-30T00:00:00Z', + now: '2026-01-30T00:00:00Z', + }); + + expect(p.stage).toEqual({ expecting: 1, parenting: 0, unknown: 0 }); + expect(p.emotion_score).toBe(0.8); + expect(p.context).toEqual({ work: 1 }); + expect(p.need).toEqual({ rest_balance: 1 }); + expect(p.profile_answered).toEqual({ stage: true, emotion: true, context: true, need: true }); + }); + + it('全部跳过:仍能生成最小可计算画像(unknown=1)', () => { + const answers = mapOnboardingSelectionsToQuestionnaireAnswers({}); + expect(answers).toEqual({ mom_stage: null, emotion: null, context: null, need: null }); + + const p = buildUserProfileFromQuestionnaire(answers, { + generatedAt: '2026-01-30T00:00:00Z', + now: '2026-01-30T00:00:00Z', + }); + + expect(p.stage).toEqual({ unknown: 1 }); + expect(p.emotion_score).toBeNull(); + expect(p.context).toEqual({}); + expect(p.need).toEqual({}); + expect(p.profile_answered).toEqual({ stage: false, emotion: false, context: false, need: false }); + }); +}); + diff --git a/client/src/features/userProfileScoring/index.ts b/client/src/features/userProfileScoring/index.ts index dfa086c..2133e19 100644 --- a/client/src/features/userProfileScoring/index.ts +++ b/client/src/features/userProfileScoring/index.ts @@ -5,6 +5,8 @@ export type { UserProfileV1_2_Extended, } from './types'; +export type { OnboardingSelections } from './onboardingMapping'; + export { buildUserProfileFromQuestionnaire, computeProfileAnswered, @@ -13,3 +15,5 @@ export { normalizeAnswers, } from './scoring'; +export { mapOnboardingSelectionsToQuestionnaireAnswers } from './onboardingMapping'; + diff --git a/client/src/features/userProfileScoring/onboardingMapping.ts b/client/src/features/userProfileScoring/onboardingMapping.ts new file mode 100644 index 0000000..4e1072f --- /dev/null +++ b/client/src/features/userProfileScoring/onboardingMapping.ts @@ -0,0 +1,61 @@ +import type { QuestionnaireAnswersV1_2 } from './types'; + +/** + * Onboarding UI 的选项 ID → 标准问卷枚举(可跳过) + * + * 说明: + * - UI 侧每题目前是单选,但数据结构是 string[];这里取第 1 个作为答案 + * - 不存在错误处理:未知/非法值统一按“跳过”处理(返回 null) + */ +export type OnboardingSelections = Record; + +export function mapOnboardingSelectionsToQuestionnaireAnswers( + selections: OnboardingSelections +): QuestionnaireAnswersV1_2 { + return { + mom_stage: mapMomStage(selections.status?.[0]), + emotion: mapEmotion(selections.emotion?.[0]), + context: mapContext(selections.influence?.[0]), + need: mapNeed(selections.support?.[0]), + }; +} + +function mapMomStage(raw: string | undefined): QuestionnaireAnswersV1_2['mom_stage'] { + // 跳过:null(显式跳过) + if (!raw) return null; + // UI id → 标准枚举 + if (raw === 'pregnant') return 'expecting'; + if (raw === 'has_kids') return 'parenting'; + if (raw === 'no_fill') return 'unknown'; + // 其他非法值:按跳过处理 + return null; +} + +function mapEmotion(raw: string | undefined): QuestionnaireAnswersV1_2['emotion'] { + if (!raw) return null; + // UI 当前选项:happy/calm/stressed/low + if (raw === 'happy') return 'joyful'; + if (raw === 'calm') return 'calm'; + if (raw === 'stressed') return 'overwhelmed'; + if (raw === 'low') return 'low'; + return null; +} + +function mapContext(raw: string | undefined): QuestionnaireAnswersV1_2['context'] { + if (!raw) return null; + // UI id 已与标准枚举一致:family/work/relationship/friends/health + if (raw === 'family' || raw === 'work' || raw === 'relationship' || raw === 'friends' || raw === 'health') return raw; + return null; +} + +function mapNeed(raw: string | undefined): QuestionnaireAnswersV1_2['need'] { + if (!raw) return null; + // UI id → 标准枚举 + if (raw === 'emotional') return 'emotional_support'; + if (raw === 'parenting') return 'parenting_pressure'; + if (raw === 'self_worth') return 'self_worth'; + if (raw === 'anxiety') return 'anxiety_relief'; + if (raw === 'balance') return 'rest_balance'; + return null; +} + diff --git a/client/src/services/recoApi.ts b/client/src/services/recoApi.ts new file mode 100644 index 0000000..46cdb10 --- /dev/null +++ b/client/src/services/recoApi.ts @@ -0,0 +1,63 @@ +import i18n from 'i18next'; + +import { API_BASE_URL } from '@/src/constants/env'; +import type { UserProfileV1_2 } from '@/src/features/userProfileScoring'; + +export type RecommendedItem = { + content_id: number; + text: string; + final_score: number; + fallback_level_final: number; + explanations?: Record | null; +}; + +export type RecoMeta = Record; + +export type RecoEngineResult = { + items: RecommendedItem[]; + meta: RecoMeta; +}; + +export type RecoRequest = { + k?: number; + user_profile: UserProfileV1_2; + already_recommended_ids?: Array; + touched_or_viewed_ids?: Array; + now?: string; // ISO8601(可选) +}; + +function withTimeout(ms: number): AbortController { + const controller = new AbortController(); + setTimeout(() => controller.abort(), ms); + return controller; +} + +export async function fetchRecoFeed(req: RecoRequest): Promise { + const controller = withTimeout(12_000); + const url = `${API_BASE_URL}/v1/reco/feed`; + + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // 让后端做 locale 选择(目前后端只区分 en/tc) + 'Accept-Language': i18n.language || 'en', + }, + body: JSON.stringify({ + k: req.k, + user_profile: req.user_profile, + already_recommended_ids: req.already_recommended_ids ?? [], + touched_or_viewed_ids: req.touched_or_viewed_ids ?? [], + now: req.now, + }), + signal: controller.signal, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`推荐接口请求失败:${res.status} ${res.statusText} ${text}`.trim()); + } + + return (await res.json()) as RecoEngineResult; +} + diff --git a/client/src/storage/appStorage.ts b/client/src/storage/appStorage.ts index 455038a..c30c995 100644 --- a/client/src/storage/appStorage.ts +++ b/client/src/storage/appStorage.ts @@ -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; +}; + +/** + * 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(key: string, fallback: T): Promise { const raw = await AsyncStorage.getItem(key); @@ -122,6 +157,103 @@ export async function setUserProfile(profile: UserProfile): Promise { await setJson(KEY_USER_PROFILE, { ...current, ...profile }); } +export async function getUserProfileScoring(): Promise { + 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 { + await setJson(KEY_USER_PROFILE_SCORING, profile); +} + +export async function getRecoFeedCache(): Promise { + 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 { + await setJson(KEY_RECO_FEED_CACHE, cache); +} + +export async function getRecoFeedHistory(): Promise { + 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; + 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 { + await setJson(KEY_RECO_FEED_HISTORY, history); +} + +function uniqKeepLatest(list: number[], max: number): number[] { + const seen = new Set(); + 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 { + 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 { + 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 { const s = await getJson(KEY_DAILY_REMINDER_SETTINGS, { timesPerDay: 3, diff --git a/server/.env.devs b/server/.env.dev similarity index 100% rename from server/.env.devs rename to server/.env.dev diff --git a/server/.test.db b/server/.test.db new file mode 100644 index 0000000000000000000000000000000000000000..4c9f7b10834abea5efd202a8000b4923764bcfb5 GIT binary patch literal 49152 zcmeI)PjA~~90zbaPMSDv=T!(IRw}GYXbrTZNxGs=LXf52)~!vqG==VDxy_T-ZE9z> zvvy1f6&OO|!j&s;g13N#gqXN+;lKfjX-q;OhJ<$DXD4>u#-$P((z1Q6I<+4^e*S#? zJa(MK&(gJd-C=Uww6--zE^sF~p6A|{Wsc*9X&#{2pE9%%_gCmYA6hMG6x+VUQ8WKNCenr8UAOHafKmY3qDl78A*z#go?%qQl^TYI7b|u}H8OiZC zg1p>B{q6g?^mpar#ycu~cU$(uom4WD%kg(!bhOn5>rS&fMC{~JEuNoOB1@hY^%_;R zs$5hma(S^TmzU?~$K|x>Cw9bUue9$JE2T@tL;G1wb68EK_bju-Se;u?=BRFtg_RHnD zYs*Sle_ZyY+&wQWR+PDmWjD3(;gKuK1%(PxQkHxVJDft+Jp{ALyh1IYR9q?*XO(oe zB=%+A&GDRfOzj&DdS9k$EywiMgEH+OTs7zG?T4kQ1Et(fw0A0*%v_t`_ewz%X>7?ep-I4R9DmSw?ywBZd86uc`P!k>}DS|F)EwTT{hd% z)`Ldpr;|LoEMENRyuD4?4-(1DTa?$!-FfM@YHM{y^*0-btKWBYmf_u^a}LMIemoUV zW+o>1Plpe5D8FupGfqTyCO@`3c5I(%i*!28*i1I)7pK>m!7QzD`apkR+osnD>^0M3 z)Grs5%3NuzFfQl4uHhAL*HP`3PQ}-$QQlHFsrAU0m#7JcyRyNkKOP?~vPA^v2EPxV zo;+Aei`~@Oj@t?9dW{)t5hV?y%jXvt=ar({$J*zF!|An}wa0P(@DE<+Jn}ft1`Q#Z zd2@u{>kry(Co@}h*mko)#oxcM*zvCB8WlU#TIqBYN+w&6B{MUW+kTG>_NPWGvunWQgzSE-nz3E ztOq5KI~}7PDR2x2ul?t1%m34=LRY7?jV=E|;5GxQbJJory~mJCmz2^Kd5jKf8)9xL z=$v%1skld*HjqeoLpGfqq-#Z7k~!&X>2qm?7BE2o0uX=z1Rwwb2tWV=5P$##o zc#3JXupD1a0(HTB7&x4q5bk& z-pNNqiU^wKL;K~loRf=)6cOZpU>uiz=A^%+Kcru20TTou009U<00Izz00bZa0SG_< z0=+5FCk*l0RNVVl5WIi+SV9;QwH)sMd-IT@)DVCG1Rwwb2tWV=5P$##AOL|b0r!sI zY2W`l?fZYHegE&|yx;#1T;`7(K^MLr~ZodNT;DTRJmpcCfy| zw1%_MUVZ=eXlK9ztwDngG@GX71QDB>MUO)3#=7@ZwB52;u*qhPx=LC5=}&K?XW`M*M7gZ%gni%RgO}O-I%?_LSB(5mnnw-mh(n6QA;i z-2VRm0Vn+>{VqL_9v$1w5di`afB*y_009U<00Izz00bZafhSuaE~NOl`y#k6F+mW; z_Wl1|PWn^2C;ceheX=Wn(m?ePYb*rOK|*5A-38u*S6I5 zU>K^+3|n{fn=E4dg4g&24O>uX$G07AovAHj%P@D0h{*2u|K9!oFVZ(X&4j{300Izz z00bZa0SG_<0uX=z1fC6nm*V^kf5gWGu}oYnh!GzN1o!{nO81@(Ce#uF5P$##AOHaf zKmY;|fB*y_(CY%Hgc&~icLK2&gnz&PevCanlV8D g0uX=z1Rwwb2tWV=5P$##AkY&6?(_dKZ~Xs%05Z9vod5s; literal 0 HcmV?d00001 diff --git a/server/app/__pycache__/main.cpython-313.pyc b/server/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5d626d73cc67c60cb435ae7c9531757d2caef83b GIT binary patch literal 1285 zcmZ`(|7#RS6rb5Ick5|#k+d3PX_qQ(H?Ax1&0m|*{b{?O1s%0fW!_LBQ4k&|M7>zlpXN&?jRQUSpPm{rD%E<9>MR1gN|6#LYOwbqy=; zfE}ZD$t4sdaZA+FoBKl{0O#SY`@p{7uA(6v`hi8~6u2RV28V` z+ZMnak|N)cW@P#QkYNuQdJD28+W$(Xxd@Twhgu_lH#f+2W4YNYIe0v?Hgh|;d!zDX@|pKdBC$UIpn7d;WAUf8*}K8z?<)(Js?+7l zliBKZFSs`yOwX_XR$l-4O6`~1)f*2t7AJ;@X1c?&K1}q{jm66wi&uj3)Y`&a%_~>u zZUx`n3@+VT|8;(C=6>bTROQiJ?Z*d|g@^1YxEIkiT$5ao)JBvGGzKYdxgFN`AKKbu;trytd=4x8NuRSv70!=2uI82;pC{HB8xpNp%rsOf8W4wF zi|6;KgASpNI%1F5>c_+xb?u^Ro;$DR4VP$Y!LYKql4%jgQ2{qbs0y*vf@WB$;(0&b zEWzKeQ=+*f1B&qjj;Z z9V-%LDrM#S63f}r3dC1pzF#mJzq(rwP1P@<=FJukfRd}C= zK(vj;Wh3LT;IjMyO|nB0uhORbz`=&r$pU-np%<;F?TyK=z0WF%-Pfd*4rK|RM3?u` zW(4mzpq^y4}BhP9&6C_Kq}sN0kPvvNj0?1>+G0h1a#oMNR?5jNA;f%$K- z4b7!3t#JSb{{s3NgR=5y2e3Pcb}~I|fYej4vn7-CQW<<~B%F7EZSol7RkS}q@2#T4 btLVgLtQ{xIpT0mKp1+UcyDysKd>H3nR>WKl literal 0 HcmV?d00001 diff --git a/server/app/api/__pycache__/__init__.cpython-313.pyc b/server/app/api/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5248b766d98ff22456f07025f0dc408dd90ae110 GIT binary patch literal 275 zcmey&%ge<81btEsnMpwUF^B^Lj8MjB4j^MHLoh=TLpq}-Q)v{C%jFp0sqkX^`sY(N zK5bq4w0|)d*NgRAp3Ufczh~C7o=yd~#NrZ=lBYX%yjb4&boD`Ep03i4es$nzhV85tQrF|jbR6tMug09J}(zyJUM literal 0 HcmV?d00001 diff --git a/server/app/api/__pycache__/limits.cpython-313.pyc b/server/app/api/__pycache__/limits.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..524957c1753260d1fea8d8ce1fcdf9be27872642 GIT binary patch literal 3356 zcma(T{cjY|tjvvI#Kl2*rvst;WKtY93!zre0WyvRBv73wL}7|2INeEO8oo>? z6JjwNvSFJ^XFKg72X=&<*lEsfovx4@yF(uA;ZZNU0W~1OUW)`r3vKCdu|5cMy&UkI zf=h6>(YQ?D1WzqG#bR%Q4wNkusQ_hoB8sBw5nWaltt&tqu2@8mM3WIsGi-5LO2&Z3 z9+0EDVLP5qB_)`a9Xfve*sEuwQi`lH9Np6Cw4~|L!oKE*wIwbNeOZaAXSyT0bVTl#bqOyM@1*|kv!7e>*?>0c^E+aX3Hp*7Dmo_><{qpQOW@Gv-A^O^T{YOyl!^ zoP97oy)ZM&x9%h=3`SPKK}m8+!~3K#_}CYd;*oSx7vqtruHu0o*Aj>JnEve1biAF1 zfdxF=J!C+pig=iIvr0_X`7k*qgO z^j|>65g3DrJT<~p`w4_1V3GtnQJySu!<0ZZLCh*NtXpgNf<$+rrj!-{Qq2D<^g5%_ zL8Q@D=nz;@-6haOJWRJ&A`}B{N5Rfv<`{jn0wE9Lk-{oWm<8#2*pYe8j*hi^S$LQc zP_g>(=;*gBZ3)u@a%J94H@-5lw&=-8mWb;h( z=hb&ynYP0duVwg_A=g}W=pPleLmfG;e6(x0Yo4piaCMWXryBppZT&ZcDqs8tU`;2b zd0^kqxsE;TUtD&8QDO%&h*3&Ov<0YUgcP}^hy}uQF-c6%l#=VQNSG-~@TjL07h$SM z?J0TgvE*)AVb{=6xAU>SBGygL{?rJw0#)>QQNKVpfs|r9aqL-I!e*@x+n(5sdrB=# zi`v?+LOwv`_mKQIhrkG|U~8i#DuDv_F0(pel92KzU@wEiQ07H5uO;$mc{F&{(i&8= zm}J;vk{*$h0Vm!73veRG+iL2UV>o0yX$88#-?s2{`cEWqbJ73EgT>~nK{@6xkzq@ zs{t5xEa_>im_`8xPZ*>W<7UETyHrI2yKE^ezHnO$%{Bh z*fpX^ye&t?h^}M#WE!f2DE=0iS2e2xCP8ch(gA4u09-&{v38qtr5tg-H$u7U^;vfP z-?um4Y0PXtICyx@7s#>)b1(V_zc(giS^p!Jvb8<3J8j#(u6EiEQI={`D~aOemjMK5 zD}YGImNFc0MDFkgc_tqLvI+5!T*A6TpifzR1I2A68N?U#?odA{}=4uZ_MIxzN58cFIWb@0@^V#^_n#Dk>rV|Asr!kFsb%< zKLEp1YR@GNvxIEshg}~?$w~qf*I`oU4M!ZhHt2NBYG!FSgSCT5kc;8(5I|~yWdrBS zR<7>DA)t{LNF!n^3`J4@L=E?m=S#HhOH{XnURtpu+VeHNi8@5BAOcqzAgq{#uZ~b? Y-S*EK?sR8r4$Yy;|JYqro4L*Z08V))0ssI2 literal 0 HcmV?d00001 diff --git a/server/app/api/limits.py b/server/app/api/limits.py new file mode 100644 index 0000000..a3fce02 --- /dev/null +++ b/server/app/api/limits.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from typing import Dict, Tuple + +from fastapi import HTTPException, Request + + +@dataclass +class FixedWindowRateLimiter: + """ + 固定窗口限流(内存版)。 + + 约束: + - 适用于单进程/单实例;多进程/多实例下不共享计数(V1 可接受) + - 窗口粒度:按分钟 bucket(window_seconds 建议为 60) + """ + + limit: int + window_seconds: int + _counters: Dict[Tuple[str, int], int] = field(default_factory=dict) + _last_gc_bucket: int = 0 + + def _bucket(self, now_ts: float) -> int: + return int(now_ts // float(self.window_seconds)) + + def _gc(self, current_bucket: int) -> None: + # 每隔一段时间清理一次,避免 dict 无限增长(保留最近 3 个 bucket) + if self._last_gc_bucket == current_bucket: + return + self._last_gc_bucket = current_bucket + keep_from = current_bucket - 2 + to_delete = [k for k in self._counters.keys() if k[1] < keep_from] + for k in to_delete: + self._counters.pop(k, None) + + def allow(self, *, key: str, now_ts: float) -> None: + bucket = self._bucket(now_ts) + self._gc(bucket) + + k = (str(key), int(bucket)) + n = int(self._counters.get(k, 0)) + 1 + self._counters[k] = n + if n > int(self.limit): + raise HTTPException(status_code=429, detail="rate_limited") + + +_reco_rate_limiter = FixedWindowRateLimiter(limit=10, window_seconds=60) + + +async def rate_limit_reco_by_ip(request: Request) -> None: + """ + 推荐接口限流:按 IP,1 分钟 10 次。 + """ + + ip = "unknown" + if request.client and request.client.host: + ip = str(request.client.host) + + _reco_rate_limiter.allow(key=ip, now_ts=time.time()) + diff --git a/server/app/api/v1/__pycache__/__init__.cpython-313.pyc b/server/app/api/v1/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3ee1056074c9ce2f24e668f9a2083ddd5258da22 GIT binary patch literal 209 zcmey&%ge<81QVnhGG&4EV-N=h7@>^M96-iYhG2#whIB?vrczNLmn+Or!7;#7;l=j# z&!=pBIlJxYgbpr0O~za7@$o77$?@?k89sxw-*VRvElw>e*3Zh%%GY;EEzT~5- literal 0 HcmV?d00001 diff --git a/server/app/api/v1/__pycache__/reco.cpython-313.pyc b/server/app/api/v1/__pycache__/reco.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d2c99b0f8dde69e4d39970489e8aaea1ac9a1c2b GIT binary patch literal 6571 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 literal 0 HcmV?d00001 diff --git a/server/app/api/v1/__pycache__/user_profile_scoring.cpython-313.pyc b/server/app/api/v1/__pycache__/user_profile_scoring.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..78f639e47368be2b27ca8ee7e8cf18c0d7a54804 GIT binary patch literal 1164 zcmah|-D?zA6u)<7cJ{k|(9n=jSYp$%8Z*QqB~Y}jk)O=Ku8WgoHt=Nagf-UhQ;=d4|3Xr7cZP9o>o=z@v zmd0LOH%-gY9m6si(CF8PzwNeMhfpBR$6ce4)m%oXW>YI?6o{6iR-bmtC5(HRx!!@&RQZ@1FH?19Q)Ancw2OVN&Np^Uv(%+`=$h+4f4<|M=2rAo@qpcEwd*qi+$s9V@%y9QxZ zdhaTa^`D{QcJUMRg_y%>!My^TvEY|G!T8+r#W{a`zVz30@N2O&eJvcg|S$yeDj}Rc4B4WQfzms=C%CWZ0YixfA(~F?sj?pR{6mwY}IIR z>xO^!k5rzRgz65-YPzGC*1*cbc?kdf?u38&s(=4N@b8c1n=?W2=hF3irMnM;u{-|n zKa{4<#UO`?&Ay%p&S{DA4)nsh$p#2zGy#kzVS?0(l$>HbUnx&jc)P^0rrN_*O&FZE##1 zKDG;{Wi*0beuxrnOOg0wd@R0HSO2K4`C(o2Qew+w=UC^9u#}Kjkra|&FSx~ z`L(Ql_W$*b_vaMz=3EwETT&@$&-@qa7pis*HrlXyfU|;&+Rm?i*=b#QQ5k63Z14 N318tC9MT2|^cr@gY!3hc literal 0 HcmV?d00001 diff --git a/server/app/api/v1/reco.py b/server/app/api/v1/reco.py new file mode 100644 index 0000000..b40a08c --- /dev/null +++ b/server/app/api/v1/reco.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Optional + +from fastapi import APIRouter, Depends, Header +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.limits import rate_limit_reco_by_ip +from app.db.session import get_db +from app.features.personalized_reco.content_repository.interface import ContentRepository +from app.features.personalized_reco.content_repository.sqlalchemy_repo import SqlAlchemyContentRepository +from app.features.personalized_reco.reco_engine import recommend +from app.features.personalized_reco.reco_engine.types import RecoConstraints, RecoEngineResult +from app.features.user_profile_scoring.types import UserProfileV1_2 + +router = APIRouter( + prefix="/v1/reco", + tags=["reco"], + dependencies=[Depends(rate_limit_reco_by_ip)], +) + + +class RecoRequest(BaseModel): + k: Optional[int] = None + user_profile: UserProfileV1_2 + already_recommended_ids: list[Any] = Field(default_factory=list) + touched_or_viewed_ids: list[Any] = Field(default_factory=list) + now: Optional[datetime] = None + + +def _parse_now_from_header(x_now: Optional[str]) -> Optional[datetime]: + if not x_now: + return None + raw = str(x_now).strip() + if not raw: + return None + # 支持 Z + if raw.endswith("Z"): + raw = raw[:-1] + "+00:00" + try: + dt = datetime.fromisoformat(raw) + except Exception: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def _pick_now(*, header_now: Optional[str], body_now: Optional[datetime]) -> datetime: + dt = _parse_now_from_header(header_now) + if dt is not None: + return dt + if body_now is not None: + if body_now.tzinfo is None: + return body_now.replace(tzinfo=timezone.utc) + return body_now + return datetime.now(timezone.utc) + + +def _pick_locale_from_accept_language(accept_language: Optional[str]) -> str: + """ + 从 Accept-Language 映射 locale: + - 缺失/空 -> en + - 含 zh-TW/zh-HK/tc -> tc + - 其他 -> en + """ + + raw = (accept_language or "").strip().lower() + if not raw: + return "en" + if "zh-tw" in raw or "zh-hk" in raw or "tc" in raw: + return "tc" + return "en" + + +async def get_reco_repo(db: AsyncSession = Depends(get_db)) -> ContentRepository: + """ + 构造推荐 repo(可在测试中 override,避免依赖真实 DB)。 + """ + + return SqlAlchemyContentRepository(db) + + +@router.post("/feed", response_model=RecoEngineResult) +async def reco_feed( + req: RecoRequest, + repo: ContentRepository = Depends(get_reco_repo), + x_now: Optional[str] = Header(default=None, alias="X-Now"), + accept_language: Optional[str] = Header(default=None, alias="Accept-Language"), +) -> RecoEngineResult: + k_i = 30 if req.k is None else int(req.k) + now = _pick_now(header_now=x_now, body_now=req.now) + locale = _pick_locale_from_accept_language(accept_language) + + return await recommend( + repo=repo, + scene="feed", + user_profile=req.user_profile, + already_recommended_ids=list(req.already_recommended_ids or []), + touched_or_viewed_ids=list(req.touched_or_viewed_ids or []), + k=k_i, + now=now, + locale=locale, + constraints=RecoConstraints(), + ) + + +@router.post("/push", response_model=RecoEngineResult) +async def reco_push( + req: RecoRequest, + repo: ContentRepository = Depends(get_reco_repo), + x_now: Optional[str] = Header(default=None, alias="X-Now"), + accept_language: Optional[str] = Header(default=None, alias="Accept-Language"), +) -> RecoEngineResult: + k_i = 1 if req.k is None else int(req.k) + now = _pick_now(header_now=x_now, body_now=req.now) + locale = _pick_locale_from_accept_language(accept_language) + + return await recommend( + repo=repo, + scene="push", + user_profile=req.user_profile, + already_recommended_ids=list(req.already_recommended_ids or []), + touched_or_viewed_ids=list(req.touched_or_viewed_ids or []), + k=k_i, + now=now, + locale=locale, + constraints=RecoConstraints(), + ) + + +@router.post("/widget", response_model=RecoEngineResult) +async def reco_widget( + req: RecoRequest, + repo: ContentRepository = Depends(get_reco_repo), + x_now: Optional[str] = Header(default=None, alias="X-Now"), + accept_language: Optional[str] = Header(default=None, alias="Accept-Language"), +) -> RecoEngineResult: + k_i = 1 if req.k is None else int(req.k) + now = _pick_now(header_now=x_now, body_now=req.now) + locale = _pick_locale_from_accept_language(accept_language) + + return await recommend( + repo=repo, + scene="widget", + user_profile=req.user_profile, + already_recommended_ids=list(req.already_recommended_ids or []), + touched_or_viewed_ids=list(req.touched_or_viewed_ids or []), + k=k_i, + now=now, + locale=locale, + constraints=RecoConstraints(), + ) + diff --git a/server/app/db/__pycache__/session.cpython-313.pyc b/server/app/db/__pycache__/session.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fa65eb9df8e2e7ca1c68633e846e5d9c6c031cd6 GIT binary patch literal 1351 zcmZux|7#m%7=PZo%O$xaO=ew%qRTB)+~G(BH%mbW8QoaYQDoP`pq1M-xuofmyZGLv zwEICy6%>jse#t0~i3n5ogX>UqvJSue2Q3NkNJIi0@9-B(e{leJnb8}5Pa*9xDs^}QR8Yw%WCra1dq%8ZJso zc$e)_h#S&P({gmDWSO?YyWzCGVCGcAw$b4}q%%viJ+q=;FsR#~qlWGnnzv+_#gb`Y zo^a7{Fwk*IX3@@d57c@1Q1SqzeH@iKOp@MW@7N|x;<`5`;H=Qo|9z@N<%YS(LE&rT z=#`qUUv6&PYTjOIeR5Sk**U`9#U)8lBR31o;$W)R~!Qs>8fVK89=hJ-n&6QnV& zspJ@54K^~|gOM^6NdU$MarzI!5pNwC;$;dz7!G?IA&)>5I2q!23byNcJ`M#k#21Ld z)t6C6e=@&Bc_E%SSKEK3RvT_sAZ`E(K-nibp_4YP;)~_%!j46cn;}!(k-v!9S znTFBpij3>|X*z($V>oS=ewSe+LG0k2XyW^5;{9TyZ~t=S4<2HN(DTp#yJ+H1dl=)F zqG^e|Nsfqb@u<|rr}@hvR6Zn0;Uf{1>jcFOG41z%L59OO!>D}A`_VV1aIQ!Vr$$Z6 z?urs|B~2^T&|zpAWmm}^uwN=8R5M0pA!*Dz$qvpXs}oPzh4fe*MG_`+7Bx_qg;LQY zJR_bfc3H^U;6^oV&u0Q(!YaQ8w2x)#v?TCc#md*p#&OEnico+(g5npR5b`4=?!mb& zIM;;3zls5pCHDtFdSp4iDE{>9=|(Wx*gx3l>u*FKz8~}rahtGjv;Ubk_z3rVOe6!H GxBLzKrEyIF literal 0 HcmV?d00001 diff --git a/server/app/db/models/__pycache__/content_profile.cpython-313.pyc b/server/app/db/models/__pycache__/content_profile.cpython-313.pyc index 7e220eb338c78e73c870e81beda881584ef6bd4b..addf96c299f3831c534467a65ec6ede119bed480 100644 GIT binary patch delta 20 acmaDZ{#=~>>kes;J1PNLt-PX>Haok9)7wZjqY@>bAp5b=G&t)l+yL(DpNVtWO&yQZ0|{ zq9BCb=A_}1z?VFlb}Gx-+01T5omQ0~Y$Z}Z>Q`vWN@$55 z8jAm;SDooNRrKO$HY)Kh?|qXu^Lg+48eLtQBE#x)^>3Nq1MwGXvuJe6BCSj-RGC$~ zTVkJ5YD2_yu<3v)Y!O>`2#wdv8_YfWa5wpRP82SyC6A}?`A%Ns-#!lV>>*JvZ@#+; zO%sJ8KlJM5jMfAA$u0LU*y%yF^u#@1?Ci2~n(ui$wOf5XW~l;y^x0ajmP!8qjC(nj z%1WSwfbpqOI6XN6#C2A0_ zTXL|P|H$$g$pFo*CMb#O1_rg~J${x9h92uyD;)?$NEo3Tr4cw&G2x7jsSW4I9D&2w zo_B}^jior8)g2sy3%VJJ#takNHk5sfn8J`{!6*(nF^X+y$+yG|8TvAgAd15ThJQVk t;?GlvkZ$S@LcLbJ(%HX2A~6FG&~}Lru44zs`)pNJ^{b^_ZKG{c{s4vi)m;Dp literal 0 HcmV?d00001 diff --git a/server/app/features/personalized_reco/content_repository/__pycache__/interface.cpython-313.pyc b/server/app/features/personalized_reco/content_repository/__pycache__/interface.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3372a0963ae0ccc815323f51917ef83782883acf GIT binary patch literal 1670 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 list[ContentProfileDTO]: + """ + 按场景与用户画像拉取候选内容画像(用于候选池)。 + """ + + async def fetch_contents_by_ids( + self, + *, + content_ids: list[int], + locale: str, + ) -> list[ContentProfileDTO]: + """ + 按 content_id 批量获取内容画像(去重、按输入顺序返回;缺语言/缺记录的 id 跳过)。 + """ + diff --git a/server/app/features/personalized_reco/content_repository/normalization.py b/server/app/features/personalized_reco/content_repository/normalization.py new file mode 100644 index 0000000..4bb56d2 --- /dev/null +++ b/server/app/features/personalized_reco/content_repository/normalization.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from typing import Any + +from app.features.personalized_reco.content_repository.types import Locale, normalize_locale + + +CONTEXT_KEYS: tuple[str, ...] = ("family", "work", "relationship", "friends", "health") +NEED_KEYS: tuple[str, ...] = ( + "emotional_support", + "parenting_pressure", + "self_worth", + "anxiety_relief", + "rest_balance", +) + + +def _normalize_discrete_score(v: Any, *, default: float = 0.5) -> float: + """ + 将 suitability 的离散值规范化为 0/0.5/1。 + + 非法值一律兜底 default(默认 0.5)。 + """ + + try: + if v in (0, 0.0): + return 0.0 + if v in (0.5,): + return 0.5 + if v in (1, 1.0): + return 1.0 + # 允许字符串形式的 "0"/"0.5"/"1" + if isinstance(v, str): + s = v.strip() + if s == "0": + return 0.0 + if s == "0.5": + return 0.5 + if s == "1": + return 1.0 + except Exception: + return default + return default + + +def normalize_suitability(raw: Any, *, keys: tuple[str, ...]) -> dict[str, float]: + """ + 解析 suitability JSON,缺失时补齐全 0.5。 + + raw 期望为 dict;否则视为缺失。 + """ + + data: dict[str, Any] = raw if isinstance(raw, dict) else {} + return {k: _normalize_discrete_score(data.get(k), default=0.5) for k in keys} + + +def normalize_review_confidence(raw: Any) -> float: + """ + review_confidence 缺失/NULL 时兜底 0.7。 + """ + + try: + if raw is None: + return 0.7 + v = float(raw) + if 0.0 <= v <= 1.0: + return v + except Exception: + pass + return 0.7 + + +def normalize_personalization_power(raw: Any) -> float: + """ + DB 约定存 0/5/10,读取层输出 0/0.5/1。 + """ + + try: + if raw is None: + return 0.0 + v = int(raw) + if v == 0: + return 0.0 + if v == 5: + return 0.5 + if v == 10: + return 1.0 + except Exception: + pass + return 0.0 + + +_RISK_FLAG_MAP: dict[str, str] = { + "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", +} + + +def normalize_risk_flags(raw_flags: list[str] | None) -> list[str]: + """ + risk_flags 旧→新映射、去重、稳定排序(字典序)。 + """ + + flags = raw_flags or [] + mapped: set[str] = set() + for f in flags: + if not f: + continue + name = _RISK_FLAG_MAP.get(f, f) + mapped.add(name) + return sorted(mapped) + + +def pick_text(*, text_en: str | None, text_tc: str | None, locale: str) -> str | None: + """ + 按 locale 选择输出文案文本。 + + 当前仅支持 EN/TC,且不允许语言回退: + - locale=en*:必须使用 text_en + - locale=tc/zh-TW/zh-HK:必须使用 text_tc + """ + + loc: Locale = normalize_locale(locale) + if loc == "en": + return text_en if text_en else None + # loc == "tc" + return text_tc if text_tc else None + diff --git a/server/app/features/personalized_reco/content_repository/sqlalchemy_repo.py b/server/app/features/personalized_reco/content_repository/sqlalchemy_repo.py new file mode 100644 index 0000000..a57c6f9 --- /dev/null +++ b/server/app/features/personalized_reco/content_repository/sqlalchemy_repo.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from typing import Any, Iterable + +from sqlalchemy import Select, and_, desc, not_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models.content import Content +from app.db.models.content_profile import ContentProfile +from app.db.models.content_risk_flag import ContentRiskFlag +from app.features.personalized_reco.content_repository.interface import ContentRepository +from app.features.personalized_reco.content_repository.normalization import ( + CONTEXT_KEYS, + NEED_KEYS, + normalize_personalization_power, + normalize_review_confidence, + normalize_risk_flags, + normalize_suitability, + pick_text, +) +from app.features.personalized_reco.content_repository.types import ContentProfileDTO + + +@dataclass(frozen=True) +class _UserSignals: + """ + 从 user_profile 中提取 repository 级别需要的最小信号。 + + 注意:更复杂的规则(Hard Filter/Scoring/Rerank)不在本层处理。 + """ + + missing_need: bool + missing_context: bool + missing_emotion: bool + stage: str | None # expecting/parenting/unknown/general/None + + +def _bool(v: Any) -> bool: + return bool(v) + + +def _extract_user_signals(user_profile: object) -> _UserSignals: + """ + 兼容 pydantic model / dict / 其他对象的最小字段读取。 + """ + + def _get(obj: Any, key: str, default: Any = None) -> Any: + if obj is None: + return default + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + need = _get(user_profile, "need", {}) or {} + context = _get(user_profile, "context", {}) or {} + emotion_score = _get(user_profile, "emotion_score", None) + + missing_need = len(need) == 0 + missing_context = len(context) == 0 + missing_emotion = emotion_score is None + + # stage: from user_profile.stage (one-hot) + stage_obj = _get(user_profile, "stage", None) + stage: str | None = None + if stage_obj is not None: + expecting = _get(stage_obj, "expecting", None) + parenting = _get(stage_obj, "parenting", None) + unknown = _get(stage_obj, "unknown", None) + if _bool(expecting): + stage = "expecting" + elif _bool(parenting): + stage = "parenting" + elif _bool(unknown): + stage = "unknown" + + return _UserSignals( + missing_need=missing_need, + missing_context=missing_context, + missing_emotion=missing_emotion, + stage=stage, + ) + + +def _dedupe_preserve_order(ids: Iterable[int]) -> list[int]: + seen: set[int] = set() + out: list[int] = [] + for i in ids: + if i in seen: + continue + seen.add(i) + out.append(i) + return out + + +class SqlAlchemyContentRepository(ContentRepository): + """ + 基于 SQLAlchemy AsyncSession 的 ContentRepository 实现。 + """ + + def __init__(self, session: AsyncSession): + self._session = session + + async def fetch_contents_by_ids(self, *, content_ids: list[int], locale: str) -> list[ContentProfileDTO]: + """ + - 输入去重 + - 输出顺序与输入一致(按首次出现顺序) + - 缺记录或缺目标语言文本:跳过 + - 不产生 N+1(主体+画像一次,flags 一次) + """ + + unique_ids = _dedupe_preserve_order(content_ids) + if not unique_ids: + return [] + + # locale 文本存在性过滤(不允许语言回退) + # en -> 必须 text_en;tc -> 必须 text_tc + # 过滤在 DB 层做,避免后续组装无意义 + from app.features.personalized_reco.content_repository.types import normalize_locale + + loc = normalize_locale(locale) + text_filter = Content.text_en.is_not(None) if loc == "en" else Content.text_tc.is_not(None) + + stmt: Select = ( + select(Content, ContentProfile) + .join(ContentProfile, Content.content_id == ContentProfile.content_id) + .where(and_(Content.content_id.in_(unique_ids), text_filter)) + ) + + rows = (await self._session.execute(stmt)).all() + if not rows: + return [] + + # 先组装主体+画像,后续再补 risk_flags + by_id: dict[int, dict[str, Any]] = {} + valid_ids: list[int] = [] + for content, profile in rows: + cid = int(content.content_id) + text = pick_text(text_en=content.text_en, text_tc=content.text_tc, locale=locale) + if not text: + continue + by_id[cid] = { + "content": content, + "profile": profile, + "text": text, + } + valid_ids.append(cid) + + if not by_id: + return [] + + # 批量取 flags(避免 join 行膨胀) + flags_stmt = select(ContentRiskFlag.content_id, ContentRiskFlag.flag).where( + ContentRiskFlag.content_id.in_(list(by_id.keys())) + ) + flags_rows = (await self._session.execute(flags_stmt)).all() + flags_map: dict[int, list[str]] = defaultdict(list) + for cid, flag in flags_rows: + flags_map[int(cid)].append(str(flag)) + + result_by_id: dict[int, ContentProfileDTO] = {} + for cid, payload in by_id.items(): + content: Content = payload["content"] + profile: ContentProfile = payload["profile"] + text: str = payload["text"] + + dto = ContentProfileDTO( + content_id=cid, + text=text, + stage=profile.stage, # type: ignore[arg-type] + emotion_score=float(profile.emotion_score) if profile.emotion_score is not None else None, + context_suitability=normalize_suitability(profile.context_suitability_json, keys=CONTEXT_KEYS), + need_suitability=normalize_suitability(profile.need_suitability_json, keys=NEED_KEYS), + personalization_power=normalize_personalization_power(profile.personalization_power), + risk_flags=normalize_risk_flags(flags_map.get(cid)), + author_id=content.author_id, + template_id=content.template_id, + review_confidence=normalize_review_confidence(profile.review_confidence), + ) + result_by_id[cid] = dto + + # 按输入顺序返回(跳过缺失/被过滤的) + out: list[ContentProfileDTO] = [] + for cid in unique_ids: + dto = result_by_id.get(cid) + if dto is not None: + out.append(dto) + return out + + async def fetch_candidates( + self, + *, + scene: str, + user_profile: object, + fallback_level: int, + limit: int, + locale: str, + exclude_content_ids: list[int] | None = None, + ) -> list[ContentProfileDTO]: + """ + 两段式候选召回: + 1) 先查候选 content_id 列表(含粗过滤、locale 过滤、limit*multiplier) + 2) 再批量补全字段(复用 fetch_contents_by_ids) + """ + + if limit <= 0: + return [] + + signals = _extract_user_signals(user_profile) + effective_fallback = int(fallback_level) + if signals.missing_need or signals.missing_context or signals.missing_emotion: + effective_fallback = max(effective_fallback, 1) + + # locale 文本存在性过滤(不允许语言回退) + from app.features.personalized_reco.content_repository.types import normalize_locale + + loc = normalize_locale(locale) + text_filter = Content.text_en.is_not(None) if loc == "en" else Content.text_tc.is_not(None) + + filters: list[Any] = [text_filter] + if exclude_content_ids: + filters.append(not_(Content.content_id.in_(exclude_content_ids))) + + # fallback 约束(repository 只做“降级约束”,不做 hard filter) + if effective_fallback >= 1: + # personalization_power <= 5 代表 <= 0.5 + filters.append(ContentProfile.personalization_power <= 5) + if effective_fallback >= 2: + filters.append(ContentProfile.personalization_power == 0) + filters.append(ContentProfile.stage == "general") + if effective_fallback >= 3: + filters.append(ContentProfile.is_safe_pool.is_(True)) + filters.append(ContentProfile.personalization_power == 0) + filters.append(ContentProfile.stage == "general") + + # stage 粗过滤(仅 L0/L1 才做“用户阶段 + general”;L2/L3 已强制 general) + if effective_fallback < 2: + user_stage = signals.stage + if user_stage in {"expecting", "parenting"}: + filters.append(ContentProfile.stage.in_([user_stage, "general"])) + else: + # unknown 或无法判定:仅取 general,避免误推 + filters.append(ContentProfile.stage == "general") + + multiplier = 5 + raw_limit = max(limit * multiplier, limit) + + stmt_ids = ( + select(Content.content_id) + .join(ContentProfile, Content.content_id == ContentProfile.content_id) + .where(and_(*filters)) + .order_by(desc(ContentProfile.updated_at)) + .limit(raw_limit) + ) + + candidate_ids_rows = (await self._session.execute(stmt_ids)).scalars().all() + candidate_ids = [int(x) for x in candidate_ids_rows] + if not candidate_ids: + return [] + + # 复用按 ID 批量补全(会再次做 locale 过滤,但成本可接受,且可保证一致行为) + items = await self.fetch_contents_by_ids(content_ids=candidate_ids, locale=locale) + return items[:limit] + diff --git a/server/app/features/personalized_reco/content_repository/types.py b/server/app/features/personalized_reco/content_repository/types.py new file mode 100644 index 0000000..53c344c --- /dev/null +++ b/server/app/features/personalized_reco/content_repository/types.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Literal, Optional + +from pydantic import BaseModel, Field + + +# 当前阶段仅支持 EN / TC(繁体中文) +Locale = Literal["en", "tc"] + + +def normalize_locale(locale: str) -> Locale: + """ + 将客户端传入的 locale 归一化为内部枚举(仅 EN / TC)。 + + 约定: + - 任何以 "en" 开头的 locale 归一化为 "en"(例如 en、en-US) + - "tc"/"zh-TW"/"zh-HK" 归一化为 "tc" + - 其他 locale 视为不支持 + """ + + raw = (locale or "").strip() + if not raw: + raise ValueError("locale 不能为空(当前仅支持 en/tc)") + + low = raw.lower() + if low.startswith("en"): + return "en" + if low in {"tc", "zh-tw", "zh-hk", "zh_tw", "zh_hk"}: + return "tc" + + raise ValueError(f"不支持的 locale:{locale!r}(当前仅支持 en/tc)") + + +ContentStage = Literal["general", "expecting", "parenting", "unknown"] + + +class ContentProfileDTO(BaseModel): + """ + 推荐模块消费的内容画像(稳定字段契约)。 + + 注意: + - text 已按 locale 选择,不允许语言回退(缺语言文本的内容不返回) + - emotion_score 为 None 表示 general + - personalization_power 对上统一为 0/0.5/1 + - review_confidence 缺失时兜底 0.7 + """ + + content_id: int + text: str + stage: ContentStage + emotion_score: Optional[float] = None + + context_suitability: dict[str, float] = Field(default_factory=dict) + need_suitability: dict[str, float] = Field(default_factory=dict) + + personalization_power: float + risk_flags: list[str] = Field(default_factory=list) + + # 可选字段 + author_id: Optional[str] = None + template_id: Optional[str] = None + review_confidence: float = 0.7 + diff --git a/server/app/features/personalized_reco/observability/__init__.py b/server/app/features/personalized_reco/observability/__init__.py new file mode 100644 index 0000000..a1c16b5 --- /dev/null +++ b/server/app/features/personalized_reco/observability/__init__.py @@ -0,0 +1,20 @@ +""" +个性化推荐|Observability 子模块(可观测性与打点载荷) + +说明: +- 只负责统一 `RecoMeta` 结构与构建(builder),不负责埋点 SDK/落库/上报实现。 +- `RecoMeta` 需要同时被 `reco-engine` 与 `integration-api-worker` 使用。 +""" + +from .builder import RecoMetaBuilder +from .types import MissingFields, RecoMeta +from .utils import compute_empty_reason, compute_missing_fields + +__all__ = [ + "MissingFields", + "RecoMeta", + "RecoMetaBuilder", + "compute_empty_reason", + "compute_missing_fields", +] + diff --git a/server/app/features/personalized_reco/observability/__pycache__/__init__.cpython-313.pyc b/server/app/features/personalized_reco/observability/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e05858c787f1fed03243997e9ebd716a075fec08 GIT binary patch literal 747 zcmYjPO>fgM7p9{aVAhK3SA#Qob9G9m+T z5GI2Vc|VRu#qbPd0RvenD`%v%%w#Q`M>3Igltt4DQ~G%{6HBX1O~+YW0}y1DI}$rn zj3rfPSOOr?X>R$I4R7hWU)v0-^zP7`)oj+izsGTkQBOz9R$ zJ)~?JGqKtJ*?qbhDjtfSfTZM}9~}4k@zIj9J2Xx>CuZ>m!A60*@j->yx{#+O%ds)S zCCe@&hILL&_u^o+bWB8(eP&W&QDMTA5F#POLQn(ywt9yKG^8 z!gLkKCI&y@hD6;k5c&X)gcNWIyeA%#!znsN;TY!AHnpJfs0@q5#yTt!voPrxCgvQ5 z4~`ksEelR!-F6t}&=Oo~>IQj?3*tt33hC*O0TCi5u@M@v%5HqLL<^3Aud-p`9JY{O j67VY`Nz!jQCPn`a1L^WUxUvtf{fWlT$}=iECkp-nLYyFZ literal 0 HcmV?d00001 diff --git a/server/app/features/personalized_reco/observability/__pycache__/builder.cpython-313.pyc b/server/app/features/personalized_reco/observability/__pycache__/builder.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..841bac1ca7fdb355492f4d7594febd05d249f918 GIT binary patch literal 7377 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 literal 0 HcmV?d00001 diff --git a/server/app/features/personalized_reco/observability/__pycache__/types.cpython-313.pyc b/server/app/features/personalized_reco/observability/__pycache__/types.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c499882bb1766e1c67ab16348baa868de70e586a GIT binary patch literal 2113 zcmZ`)U2M}<6tRB+xoqv_0-IRV371X;LM~57H!x%HDUbld^)~O8I=hbB=xQ zJ?GpnOEonC4u-xzqfdD`?mi>S12`M}k^^{&lR25+!wv9+Z)fwWJyio9;u$~$4S0#y zwR!gV2K>Z7AP}LQiy|4ZPmpSudEHr{nd{(WUn?j3qy9HM;AH-wYZbs+y}}ww1jOJ& z79jF8sG6qbsE%%#w&M58#y z8b!&JeU$o6>RJLjBBFIiRiY_z-GGlMs$tMzoZ#azHR+N-(y|N%k0u>hC}obA)=4wQ zHnTh`o9CDM!E%Xc^uN%hUJV5UeFAim8rbMWF#j8MU;O z!R8@FIi6CDilZAy^r6jz|Dz2Z#nnG~QlB{7Crw;3e?xZCroy2xeGEM4XF&9`eZIM;ti~w~ zAW-;hs&sy$F!fdb%ge=?>kps}sE(Fz)lgLDk5 zu@S|u!NlYMR-thZ(~v7I(7KE!;7BkQ-=p5RVW|%F z!JUmO2SNDiG#pn`hNHyQm}8N0L(|&PvQWTsAaVBTT1}5Rhir#*w?kqM(>9{pBNc6f z3lg(ZrlSm}mAIiMXw$OJm9=l#|IAlFUk}qdwhKL>*ruAak6MmBX&Tr|>B5`VK4)xA&>dvC|H^ACGzqu!ix^CDx z)U(jOE{mG4tk0o!^WjKl*LW<4B8xt5la$$;ZP>sf!cCc-<2!Pw>90<4dh=Z8uFTNH zU=Hni?Blk+`6rs|9n7M&H(t%5L9j^M{^+0U+0E8Jl0&<}vY~srf9|!u%)W^u_fTK? z80-W?Vht1Cc#7#6C?oqjp+1l;kblBNhPZ#2K4un@X-zdjOo^X4+{AScZ?1NK dCiukX=UXRsKjGl-&X1@6<=E3b%zrb~g);yE literal 0 HcmV?d00001 diff --git a/server/app/features/personalized_reco/observability/__pycache__/utils.cpython-313.pyc b/server/app/features/personalized_reco/observability/__pycache__/utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a29b432a3cd63ba0ff9523a32394562e84719693 GIT binary patch literal 2148 zcmah~e{2&~9Dmnq@4D-{WzY>|I1dD+B0VIK$b@MkU>LvDT7#K*xwY4M<91ixT^+-a zG!ByThhYgDOyrLdNgPoy#HbUd{_CGJ^^fLdCK!4f{Iw>-|NXw}b!!L4*WBy(eSg3A zzMt<~42J~-W9aRiw!k6ufG(=TS0R|i_kb8faTI3`p>75_Y`>`0dWo^gX;-+M(Ftd1Q!qNW;dZImkoeR z!O7onZeN`rot(@6G(URJ8NN6_eb>2h$GLT7?)DE0(|PB+pCA1)GJpHZ-1lP-zdq;W zzYyPlCALF!E`H_wdTwF*!h_srf>4nxOkWkYiH54CfXZ4bmNVE)Yq}bvJh3!&cmC#G zaQN`fb@9`);#~f6Y3g(5*0^(R66}(uVXLR@m8Ks1|E$#kliuD+YmY5kNfWEohj`R9 z)cVlGPt!X}crO$Q*I7kb9E1bJ4KNYj6Gd6V_o+}kHYWaF)6_97g(y!NYxliuh-t-> zMjA*SsFV|F_Tnuws&PFHiw7gIGDuoVz=>K?zu9j}`&8?sZDu6>^pMo2*{UK9XhtfX z)eY6MB+&ar#gdZANNH8EvsksH4A|1**3Li%SWTLe*-Q15UQO5RAt`HXx)sX|k+!5c zkjdI=H7hyomOWk#xzKa4K7=WU9!F5)>XAJYZIh8gD3;qd+tfOuPaK@I3r#z7Z_P&9 z#}7|RQ#%WhH*!ZxYud;6T{<(`l{-AUu48=g%WZ$Qy_h>zYdW8~e(-nP^T)a?>K(2l5vY;)0Zq$#0q8A!_HRhhLzSq~CIW{|y9hwZy_S@Ga_@H0 z`R%-O{hO8O@xUf#bOrP;RNz##>e?=D>vF3Xu!5Drz2F8I8K^zk4OC%+T4`!jRMIxU z?_&k0;Qn-=J@p!ntmrz}R5a6AJ(W~4o|d=rG@SE4mhN4q!Lr6l!yGgOOuO_^A}j|d z%_}^UP>mD9dzR2_5!gaf@#Ti}&TO*Mw2x_koZv@e^@KqtO2?ay>SBKAzoGS2Zi_NP`LU^WWb3u3^ z_vUO%WTv&V(DGWYdp5FWX6^1mq%(KqJ}=~z;nT(D_99U43=fWUPPANlz1S=k`N%j^ z;GZcruP=roI6}`i>c>qq8P4qOSCcm3unJf%{tLq^#uv9HfUmG_^ zw`I78b`!Zfs+?_b&w!cA>S`B`f+j_!br7a9%P`DeXrqJL=g|j`xmC int: + try: + n = int(value) + except Exception: + return int(default) + return max(0, int(n)) + + +class RecoMetaBuilder: + """ + 在推荐 pipeline 中逐阶段填充 RecoMeta,避免“散落字段/散落日志”。 + + 说明(V1): + - set 调用允许任意顺序;build 时会做防御式兜底与单调性修正 + - 单调性约束:raw >= after_hard_filter >= after_dedup >= after_freqcap >= served_k + """ + + def __init__(self, *, scene: Scene, user_profile: object, k: int, now: Optional[datetime] = None) -> None: + self.scene: Scene = scene + self.user_profile = user_profile + self.k = _non_negative_int(k, default=0) + self.now = now + + self._raw: Optional[int] = None + self._after_hard: Optional[int] = None + self._after_dedup: Optional[int] = None + self._after_freqcap: Optional[int] = None + self._served_k: Optional[int] = None + self._fallback_level_final: Optional[int] = None + + self._risk_filtered_count_by_flag: dict[str, int] = {} + self._freqcap_filtered_counts: dict[str, int] = {} + self._config_snapshot: dict[str, Any] = {} + + def set_candidate_pool_size_raw(self, n: Any) -> "RecoMetaBuilder": + self._raw = _non_negative_int(n) + return self + + def set_after_hard_filter(self, n: Any, *, risk_filtered_count_by_flag: Optional[dict[str, Any]] = None) -> "RecoMetaBuilder": + self._after_hard = _non_negative_int(n) + if risk_filtered_count_by_flag: + self._risk_filtered_count_by_flag = {str(k): _non_negative_int(v) for k, v in risk_filtered_count_by_flag.items()} + return self + + def set_after_dedup(self, n: Any) -> "RecoMetaBuilder": + self._after_dedup = _non_negative_int(n) + return self + + def set_after_freqcap(self, n: Any, *, freqcap_filtered_counts: Optional[dict[str, Any]] = None) -> "RecoMetaBuilder": + self._after_freqcap = _non_negative_int(n) + if freqcap_filtered_counts: + self._freqcap_filtered_counts = {str(k): _non_negative_int(v) for k, v in freqcap_filtered_counts.items()} + return self + + def set_fallback_level_final(self, level: Any, *, reason: Optional[str] = None) -> "RecoMetaBuilder": + # reason 预留,V1 先不入 meta(可放入 config_snapshot 或后续字段) + self._fallback_level_final = _non_negative_int(level, default=0) + if reason: + self._config_snapshot.setdefault("fallback_trigger_reason", str(reason)) + return self + + def set_served_k(self, n: Any) -> "RecoMetaBuilder": + self._served_k = _non_negative_int(n) + return self + + def set_config_snapshot(self, snapshot: dict[str, Any]) -> "RecoMetaBuilder": + self._config_snapshot = dict(snapshot or {}) + return self + + def build(self) -> RecoMeta: + missing: MissingFields = compute_missing_fields(self.user_profile) + conf_u = getattr(self.user_profile, "profile_confidence", 1.0) + try: + conf_u_f = float(conf_u) + except Exception: + conf_u_f = 1.0 + if conf_u_f != conf_u_f: + conf_u_f = 1.0 + + raw = self._raw if self._raw is not None else 0 + after_hard = self._after_hard if self._after_hard is not None else raw + after_dedup = self._after_dedup if self._after_dedup is not None else after_hard + after_freqcap = self._after_freqcap if self._after_freqcap is not None else after_dedup + served_k = self._served_k if self._served_k is not None else 0 + + # 防御式单调性修正(以最保守值输出) + if after_hard > raw: + logger.debug("after_hard_filter(%s) > raw(%s),已修正为 raw", after_hard, raw) + after_hard = raw + if after_dedup > after_hard: + logger.debug("after_dedup(%s) > after_hard_filter(%s),已修正为 after_hard_filter", after_dedup, after_hard) + after_dedup = after_hard + if after_freqcap > after_dedup: + logger.debug("after_freqcap(%s) > after_dedup(%s),已修正为 after_dedup", after_freqcap, after_dedup) + after_freqcap = after_dedup + if served_k > after_freqcap: + logger.debug("served_k(%s) > after_freqcap(%s),已修正为 after_freqcap", served_k, after_freqcap) + served_k = after_freqcap + + fallback_level_final = self._fallback_level_final if self._fallback_level_final is not None else 0 + + empty_reason = compute_empty_reason( + served_k=served_k, + candidate_pool_size_raw=raw, + candidate_pool_size_after_hard_filter=after_hard, + candidate_pool_size_after_freqcap=after_freqcap, + ) + + return RecoMeta( + scene=self.scene, + candidate_pool_size_raw=int(raw), + candidate_pool_size_after_hard_filter=int(after_hard), + candidate_pool_size_after_dedup=int(after_dedup), + candidate_pool_size_after_freqcap=int(after_freqcap), + fallback_level_final=int(fallback_level_final), + served_k=int(served_k), + empty_reason=empty_reason, + conf_U=float(conf_u_f), + missing_fields=missing, + risk_filtered_count_by_flag=dict(self._risk_filtered_count_by_flag), + freqcap_filtered_counts=dict(self._freqcap_filtered_counts), + config_snapshot=dict(self._config_snapshot), + ) + diff --git a/server/app/features/personalized_reco/observability/types.py b/server/app/features/personalized_reco/observability/types.py new file mode 100644 index 0000000..cb3b5f7 --- /dev/null +++ b/server/app/features/personalized_reco/observability/types.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field + +Scene = Literal["feed", "push", "widget"] + +EmptyReason = Literal["hard_filter_all", "freqcap_all", "pool_empty", "unknown"] + + +class MissingFields(BaseModel): + """ + 画像字段缺失情况(布尔结构)。 + """ + + need: bool = False + context: bool = False + emotion: bool = False + + +class RecoMeta(BaseModel): + """ + 推荐模块统一可观测载荷(返回给调用方;调用方负责上报/落库/打点)。 + """ + + scene: Scene + + candidate_pool_size_raw: int = 0 + candidate_pool_size_after_hard_filter: int = 0 + candidate_pool_size_after_dedup: int = 0 + candidate_pool_size_after_freqcap: int = 0 + + fallback_level_final: int = 0 + served_k: int = 0 + + # served_k=0 时必填;served_k>0 时建议为 None + empty_reason: Optional[EmptyReason] = None + + conf_U: float = 1.0 + missing_fields: MissingFields = Field(default_factory=MissingFields) + + # 可选:Hard Filter 风险命中统计(按 flag 聚合) + risk_filtered_count_by_flag: dict[str, int] = Field(default_factory=dict) + + # 可选:Freqcap 过滤统计(sentence/author/template) + freqcap_filtered_counts: dict[str, int] = Field(default_factory=dict) + + # 可选:调参快照(V1 可先只在内部事件使用) + config_snapshot: dict[str, Any] = Field(default_factory=dict) + diff --git a/server/app/features/personalized_reco/observability/utils.py b/server/app/features/personalized_reco/observability/utils.py new file mode 100644 index 0000000..0737854 --- /dev/null +++ b/server/app/features/personalized_reco/observability/utils.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import Optional + +from app.features.personalized_reco.observability.types import EmptyReason, MissingFields + + +def compute_missing_fields(user_profile: object) -> MissingFields: + """ + 判定用户画像缺失字段(对齐算法规则 V1.2 口径)。 + + 规则: + - need:user_profile.need 为空对象 {} 或不存在 + - context:user_profile.context 为空对象 {} 或不存在 + - emotion:user_profile.emotion_score 为 None 或不存在 + """ + + need = getattr(user_profile, "need", None) + context = getattr(user_profile, "context", None) + emotion_score = getattr(user_profile, "emotion_score", None) + + need_missing = not bool(need) + context_missing = not bool(context) + emotion_missing = emotion_score is None + + return MissingFields(need=need_missing, context=context_missing, emotion=emotion_missing) + + +def compute_empty_reason( + *, + served_k: int, + candidate_pool_size_raw: int, + candidate_pool_size_after_hard_filter: int, + candidate_pool_size_after_freqcap: int, +) -> Optional[EmptyReason]: + """ + 判定 empty_reason(served_k=0 必填)。 + + 规则(对齐 plan): + - served_k>0 -> None + - raw==0 -> pool_empty + - raw>0 且 after_hard_filter==0 -> hard_filter_all + - after_freqcap==0 -> freqcap_all + - 其他 -> unknown + """ + + if int(served_k) > 0: + return None + + raw = int(candidate_pool_size_raw) + after_hard = int(candidate_pool_size_after_hard_filter) + after_freqcap = int(candidate_pool_size_after_freqcap) + + if raw == 0: + return "pool_empty" + if raw > 0 and after_hard == 0: + return "hard_filter_all" + if after_freqcap == 0: + return "freqcap_all" + return "unknown" + diff --git a/server/app/features/personalized_reco/reco_engine/__init__.py b/server/app/features/personalized_reco/reco_engine/__init__.py new file mode 100644 index 0000000..c5f2297 --- /dev/null +++ b/server/app/features/personalized_reco/reco_engine/__init__.py @@ -0,0 +1,11 @@ +""" +Reco Engine(推荐引擎编排)。 + +该模块负责将候选拉取、硬过滤、软打分、重排/频控、回退梯度串成一个稳定 Pipeline, +并输出统一结构:items + meta(可观测字段)。 +""" + +from app.features.personalized_reco.reco_engine.orchestrator import recommend + +__all__ = ["recommend"] + diff --git a/server/app/features/personalized_reco/reco_engine/__pycache__/__init__.cpython-313.pyc b/server/app/features/personalized_reco/reco_engine/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3a59f9d670aa711d64496975bb9a7a905edc1061 GIT binary patch literal 563 zcmZWn&ubGw6rM>^T370^f~UO(ap&koJn5x{A_lz_kHtt!^uerh-lvR^=Sw|FD=d@ za|(u+1lRxtKtTZ(GBa>GLo+fcWD%s1>n-p#Nrd84{G6<@?-ilY(S+Kzy z^(ffn^=E^i*7KJnDQ>^vtzGuA86WP%hp*VDW#&Im{2E`Wv9-;fzZmU)h<{di_qD+B z_cpIJSan(8q_Qe#P_p-w*Y`!uUTi16&-dEw_-()QnOE2Q9lzgsAGN-)gDv@%?hs>S z=843!^5!00~%O>$uVVbSANv_AQN1!HuHq2t+LmhQkBElTS4x^N84X9V zY4IL$J*s*(9WkXes$pRK4h&PRu!Qcof&$Ik^ESFc-20yGAmiZzDhb0XGIdLvD;pMZ zT_pM*5Q>C6I+I8L$)kVd5yrZudl)OuLO5~Fw6(H9uG4f4!~y{KqDa595W+v1F_`;% S0l-=K>-<#p%HlYk5O*iMP4R93 literal 0 HcmV?d00001 diff --git a/server/app/features/personalized_reco/reco_engine/__pycache__/defaults.cpython-313.pyc b/server/app/features/personalized_reco/reco_engine/__pycache__/defaults.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f729ebf5f4d5e9f0d7c8af5e8e52834eace0ddb1 GIT binary patch literal 748 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 RM!}q+k*VxQ2bonw`48?6ZXy5x literal 0 HcmV?d00001 diff --git a/server/app/features/personalized_reco/reco_engine/__pycache__/orchestrator.cpython-313.pyc b/server/app/features/personalized_reco/reco_engine/__pycache__/orchestrator.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2abe645c586d41613cee6e04250c79f5705106da GIT binary patch literal 16371 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

*M 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 f=Mg(m509k|zc?nMfX}7gkQker{5 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 literal 0 HcmV?d00001 diff --git a/server/app/features/personalized_reco/reco_engine/defaults.py b/server/app/features/personalized_reco/reco_engine/defaults.py new file mode 100644 index 0000000..68a0b84 --- /dev/null +++ b/server/app/features/personalized_reco/reco_engine/defaults.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from app.features.personalized_reco.reco_engine.types import RecoEngineConfig, Scene + + +def get_default_engine_config(scene: Scene) -> RecoEngineConfig: + """ + 获取推荐引擎默认配置(返回副本,避免被意外修改)。 + """ + + # V1:三种场景目前共用一套默认值;保留 scene 参数便于后续按场景拆分 + base = RecoEngineConfig() + return RecoEngineConfig.model_validate(base.model_dump()) + diff --git a/server/app/features/personalized_reco/reco_engine/hard_filter.py b/server/app/features/personalized_reco/reco_engine/hard_filter.py new file mode 100644 index 0000000..4379003 --- /dev/null +++ b/server/app/features/personalized_reco/reco_engine/hard_filter.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import Any, Iterable, Optional + +from app.features.personalized_reco.content_repository.types import ContentProfileDTO +from app.features.personalized_reco.reco_engine.types import HardFilterResult, RecoConstraints, Scene + + +def _user_stage_key(user_profile: object) -> str: + """ + 从 user_profile.stage(one-hot) 提取用户阶段。 + 约定:unknown 通常必填,但这里做防御。 + """ + + stage_obj = getattr(user_profile, "stage", None) + if stage_obj is None: + return "unknown" + if getattr(stage_obj, "expecting", 0) == 1: + return "expecting" + if getattr(stage_obj, "parenting", 0) == 1: + return "parenting" + return "unknown" + + +def _user_emotion_score(user_profile: object) -> Optional[float]: + v = getattr(user_profile, "emotion_score", None) + if v is None: + return None + try: + f = float(v) + except Exception: + return None + if f != f: + return None + return f + + +def _count_hits(counter: dict[str, int], hits: Iterable[str]) -> None: + for h in hits: + counter[str(h)] += 1 + + +def hard_filter( + *, + scene: Scene, + user_profile: object, + candidates: list[ContentProfileDTO], + constraints: Optional[RecoConstraints] = None, +) -> HardFilterResult: + """ + Hard Filter(硬过滤)。 + + V1:仅实现硬规则集合(不做软惩罚,不做扩展 hard_rules)。 + """ + + cons = constraints or RecoConstraints() + + exclude_author_ids = set([a for a in (cons.exclude_author_ids or []) if a is not None and str(a).strip() != ""]) + exclude_template_ids = set([t for t in (cons.exclude_template_ids or []) if t is not None and str(t).strip() != ""]) + exclude_content_ids = set([int(x) for x in (cons.exclude_content_ids or []) if x is not None]) + + u_stage = _user_stage_key(user_profile) + u_emotion = _user_emotion_score(user_profile) + emotion_low = u_emotion is not None and float(u_emotion) <= 0.2 + + kept: list[ContentProfileDTO] = [] + removed_count = 0 + + # 统计:按命中 key 聚合计数(risk_flags 直接用 flag 字符串;跨维度/约束用 rule:* / constraint:* 前缀) + hit_counts: dict[str, int] = defaultdict(int) + hits_by_content_id: dict[int, list[str]] = {} + + for c in candidates or []: + cid = int(c.content_id) + hits: list[str] = [] + + # 约束:按 content_id/author_id/template_id 排除(视为硬过滤) + if cid in exclude_content_ids: + hits.append("constraint:exclude_content_id") + if c.author_id and c.author_id in exclude_author_ids: + hits.append("constraint:exclude_author_id") + if c.template_id and c.template_id in exclude_template_ids: + hits.append("constraint:exclude_template_id") + + flags = set([str(x) for x in (c.risk_flags or []) if x is not None and str(x).strip() != ""]) + + # 全场景必挡 + if "block_health_medical" in flags: + hits.append("block_health_medical") + + # 与用户阶段相关 + if u_stage == "unknown" and "unsafe_for_stage_unknown" in flags: + hits.append("unsafe_for_stage_unknown") + if u_stage == "parenting" and "unsafe_for_stage_parenting" in flags: + hits.append("unsafe_for_stage_parenting") + + # 与用户情绪相关 + if emotion_low and "unsafe_for_emotion_low" in flags: + hits.append("unsafe_for_emotion_low") + + # 跨维度规则:unknown stage + parenting_pressure 强命中 + 高个性化 + if u_stage == "unknown": + try: + need_val = float(c.need_suitability.get("parenting_pressure", 0.0)) + except Exception: + need_val = 0.0 + if need_val >= 1.0 and float(getattr(c, "personalization_power", 0.0)) >= 1.0: + hits.append("rule:unknown_stage_parenting_pressure_power1") + + if hits: + removed_count += 1 + # 单条去重后再计数,避免同 key 重复 + uniq_hits = sorted(set(hits)) + hits_by_content_id[cid] = uniq_hits + _count_hits(hit_counts, uniq_hits) + continue + + hits_by_content_id[cid] = [] + kept.append(c) + + return HardFilterResult( + kept_items=kept, + removed_count=int(removed_count), + risk_filtered_count_by_flag=dict(hit_counts), + hits_by_content_id=hits_by_content_id, + ) + diff --git a/server/app/features/personalized_reco/reco_engine/orchestrator.py b/server/app/features/personalized_reco/reco_engine/orchestrator.py new file mode 100644 index 0000000..1aa5915 --- /dev/null +++ b/server/app/features/personalized_reco/reco_engine/orchestrator.py @@ -0,0 +1,396 @@ +from __future__ import annotations + +import logging +from collections import defaultdict +from datetime import datetime +from typing import Any, Optional + +from app.features.personalized_reco.content_repository.interface import ContentRepository +from app.features.personalized_reco.content_repository.types import ContentProfileDTO, normalize_locale +from app.features.personalized_reco.observability.builder import RecoMetaBuilder +from app.features.personalized_reco.reco_engine.defaults import get_default_engine_config +from app.features.personalized_reco.reco_engine.hard_filter import hard_filter +from app.features.personalized_reco.reco_engine.types import RecoConstraints, RecoEngineConfig, RecoEngineResult, RecommendedItem, Scene +from app.features.personalized_reco.reco_engine.utils import ( + clamp_personalization_power, + merge_exclude_ids, + normalize_or_default_locale, +) +from app.features.personalized_reco.rerank_freqcap.rerank import rerank_and_freqcap +from app.features.personalized_reco.rerank_freqcap.types import ScoredCandidate +from app.features.personalized_reco.scoring.defaults import get_default_config as get_default_score_config +from app.features.personalized_reco.scoring.score import score_content + +logger = logging.getLogger(__name__) + + +def _safe_int(value: Any, *, default: int = 0) -> int: + try: + n = int(value) + except Exception: + return int(default) + return int(n) + + +def _light_score_summary(score_result: Any) -> dict[str, Any]: + """ + 轻量 explanations:只保留少量关键字段,避免 payload 过大。 + """ + + bd = getattr(score_result, "breakdown", None) + if bd is None: + return {} + + def _get(name: str) -> Optional[float]: + v = getattr(bd, name, None) + if v is None: + return None + try: + f = float(v) + except Exception: + return None + if f != f: + return None + return f + + out: dict[str, Any] = { + "missing_fields": list(getattr(bd, "missing_fields", []) or []), + "S_core": _get("S_core"), + "S_personal": _get("S_personal"), + "P_uncertainty": _get("P_uncertainty"), + "P_risk": _get("P_risk"), + "P_widget_emotion_out_of_range": _get("P_widget_emotion_out_of_range"), + } + # 删除 None,减少噪音 + return {k: v for k, v in out.items() if v is not None and v != []} + + +def _apply_fallback_level_to_content(content: ContentProfileDTO, *, fallback_level: int) -> ContentProfileDTO: + """ + 对内容做防御式一致性处理(与回退梯度一致)。 + """ + + p2 = clamp_personalization_power(content.personalization_power, fallback_level=fallback_level) + if p2 == content.personalization_power: + return content + return content.model_copy(update={"personalization_power": float(p2)}) + + +def _merge_counter(dst: dict[str, int], src: dict[str, Any]) -> None: + for k, v in (src or {}).items(): + try: + n = int(v) + except Exception: + n = 0 + dst[str(k)] = int(dst.get(str(k), 0)) + max(0, int(n)) + + +async def recommend( + *, + repo: ContentRepository, + scene: Scene, + user_profile: object, + already_recommended_ids: list[Any], + touched_or_viewed_ids: list[Any], + k: int, + now: datetime, + locale: Optional[str] = None, + constraints: Optional[RecoConstraints] = None, + config: Optional[RecoEngineConfig] = None, +) -> RecoEngineResult: + """ + Reco Engine 主入口:编排候选→过滤→打分→重排→回退,并输出 items + meta。 + """ + + cfg = config or get_default_engine_config(scene) + cons = constraints or RecoConstraints() + + k_i = max(0, _safe_int(k, default=0)) + meta_builder = RecoMetaBuilder(scene=scene, user_profile=user_profile, k=k_i, now=now) + + if k_i <= 0: + meta_builder.set_candidate_pool_size_raw(0).set_after_hard_filter(0).set_after_dedup(0).set_after_freqcap(0).set_served_k(0).set_fallback_level_final(0) + meta_builder.set_config_snapshot({"engine_note": "k<=0,直接返回空结果"}) + return RecoEngineResult(items=[], meta=meta_builder.build()) + + # locale:默认 en;严格校验仅支持 en/tc + raw_locale = normalize_or_default_locale(locale) + try: + effective_locale = normalize_locale(raw_locale) + except Exception as e: + meta_builder.set_config_snapshot({"error": str(e), "stage": "normalize_locale", "locale": raw_locale}) + meta_builder.set_candidate_pool_size_raw(0).set_after_hard_filter(0).set_after_dedup(0).set_after_freqcap(0).set_served_k(0).set_fallback_level_final(0) + return RecoEngineResult(items=[], meta=meta_builder.build()) + + # 聚合统计(跨回退层级累加,确保 meta 单调性成立) + raw_total = 0 + after_hard_total = 0 + after_dedup_total = 0 + after_freqcap_total = 0 + + risk_counts_total: dict[str, int] = defaultdict(int) + freqcap_counts_total: dict[str, int] = defaultdict(int) + + fallback_trace: list[dict[str, Any]] = [] + selected: list[ScoredCandidate] = [] + selected_level_by_id: dict[int, int] = {} + + last_fallback_level = 0 + last_reason = None + + for level in [0, 1, 2, 3]: + last_fallback_level = int(level) + k_remaining = max(0, k_i - len(selected)) + if k_remaining <= 0: + break + + # Feed:允许不足且不补齐时,拿到任何结果就停止 + if scene == "feed" and cfg.feed_allow_partial and (not cfg.feed_fill_with_fallback) and len(selected) > 0: + break + + # exclude_ids:already/touched + constraints.exclude + 已选内容(避免跨层重复) + exclude_ids = merge_exclude_ids( + already_recommended_ids=list(already_recommended_ids or []) + [int(x.content_id) for x in selected], + touched_or_viewed_ids=list(touched_or_viewed_ids or []), + extra_exclude_content_ids=list(cons.exclude_content_ids or []), + ) + + multiplier = int(cfg.candidate_multiplier_feed if scene == "feed" else cfg.candidate_multiplier_push_widget) + base_limit = max(int(cfg.min_candidates_per_level), int(k_remaining) * max(1, int(multiplier))) + if cons.max_candidates_limit is not None and int(cons.max_candidates_limit) > 0: + limit = min(base_limit, int(cons.max_candidates_limit)) + else: + limit = base_limit + + # 1) Candidate + try: + cands = await repo.fetch_candidates( + scene=scene, + user_profile=user_profile, + fallback_level=int(level), + limit=int(limit), + locale=str(effective_locale), + exclude_content_ids=exclude_ids, + ) + except Exception as e: + logger.exception("fetch_candidates 失败:%s", e) + last_reason = "error:fetch_candidates" + fallback_trace.append( + { + "level": int(level), + "raw": 0, + "after_hard": 0, + "after_dedup": 0, + "after_freqcap": 0, + "served_total": len(selected), + "error": str(e), + } + ) + continue + + raw_total += len(cands) + + if not cands: + last_reason = "pool_empty" + fallback_trace.append( + { + "level": int(level), + "raw": 0, + "after_hard": 0, + "after_dedup": 0, + "after_freqcap": 0, + "served_total": len(selected), + "reason": "pool_empty", + } + ) + continue + + # 2) Hard Filter + hf = hard_filter(scene=scene, user_profile=user_profile, candidates=cands, constraints=cons) + kept = [x for x in hf.kept_items if isinstance(x, ContentProfileDTO)] + after_hard_total += len(kept) + _merge_counter(risk_counts_total, hf.risk_filtered_count_by_flag) + + if not kept: + last_reason = "hard_filter_all" + fallback_trace.append( + { + "level": int(level), + "raw": len(cands), + "after_hard": 0, + "after_dedup": 0, + "after_freqcap": 0, + "served_total": len(selected), + "reason": "hard_filter_all", + } + ) + continue + + # 3) Soft Scoring + score_cfg = get_default_score_config(scene) + if scene == "push": + # Push:强制启用不确定性惩罚(与 spec 对齐) + score_cfg = score_cfg.model_copy(update={"enable_uncertainty_penalty": True}) + + scored: list[ScoredCandidate] = [] + for c in kept: + c2 = _apply_fallback_level_to_content(c, fallback_level=int(level)) + try: + s = score_content(scene=scene, user_profile=user_profile, content_profile=c2, config=score_cfg, pass_filters=True, now=now) + except Exception as e: + # 单条异常不影响整体 + logger.exception("score_content 失败 content_id=%s:%s", getattr(c2, "content_id", None), e) + continue + + cid = int(c2.content_id) + hits = hf.hits_by_content_id.get(cid, []) + extra: dict[str, Any] = { + "text": c2.text, + "fallback_level_used": int(level), + } + if cfg.enable_explanations: + extra["hard_filter_hits"] = hits + extra["score_summary"] = _light_score_summary(s) + + scored.append( + ScoredCandidate( + content_id=cid, + final_score=float(getattr(s, "final_score", 0.0)), + author_id=c2.author_id, + template_id=c2.template_id, + content_profile=c2, + extra=extra, + ) + ) + + if not scored: + last_reason = "empty_after_scoring" + fallback_trace.append( + { + "level": int(level), + "raw": len(cands), + "after_hard": len(kept), + "after_dedup": 0, + "after_freqcap": 0, + "served_total": len(selected), + "reason": "empty_after_scoring", + } + ) + continue + + # 4) Rerank/Freqcap + try: + rer = rerank_and_freqcap( + scene=scene, + scored_candidates=scored, + already_recommended_ids=list(already_recommended_ids or []) + [int(x.content_id) for x in selected], + touched_or_viewed_ids=list(touched_or_viewed_ids or []), + k=int(k_remaining), + recent_author_ids=cons.recent_author_ids, + recent_template_ids=cons.recent_template_ids, + ) + except Exception as e: + logger.exception("rerank_and_freqcap 失败:%s", e) + last_reason = "error:rerank_and_freqcap" + fallback_trace.append( + { + "level": int(level), + "raw": len(cands), + "after_hard": len(kept), + "after_dedup": 0, + "after_freqcap": 0, + "served_total": len(selected), + "error": str(e), + } + ) + continue + + after_dedup_total += int(rer.meta.candidate_pool_size_after_dedup) + after_freqcap_total += int(rer.meta.candidate_pool_size_after_freqcap) + _merge_counter(freqcap_counts_total, rer.meta.freqcap_filtered_counts) + + served_level = list(rer.ranked_items or [])[:k_remaining] + if not served_level: + last_reason = "freqcap_all" + fallback_trace.append( + { + "level": int(level), + "raw": len(cands), + "after_hard": len(kept), + "after_dedup": int(rer.meta.candidate_pool_size_after_dedup), + "after_freqcap": int(rer.meta.candidate_pool_size_after_freqcap), + "served_total": len(selected), + "reason": "freqcap_all", + } + ) + continue + + for it in served_level: + cid = int(it.content_id) + selected.append(it) + selected_level_by_id[cid] = int(level) + + last_reason = None + fallback_trace.append( + { + "level": int(level), + "raw": len(cands), + "after_hard": len(kept), + "after_dedup": int(rer.meta.candidate_pool_size_after_dedup), + "after_freqcap": int(rer.meta.candidate_pool_size_after_freqcap), + "served_total": len(selected), + "served_added": len(served_level), + } + ) + + if len(selected) >= k_i: + break + + # 组装输出 items(按 selected 顺序) + items: list[RecommendedItem] = [] + for c in selected[:k_i]: + cid = int(c.content_id) + text = "" + if isinstance(c.extra, dict): + text = str(c.extra.get("text") or "") + + explanations = None + if cfg.enable_explanations and isinstance(c.extra, dict): + explanations = { + "fallback_level_used": c.extra.get("fallback_level_used"), + "hard_filter_hits": c.extra.get("hard_filter_hits"), + "score_summary": c.extra.get("score_summary"), + } + + items.append( + RecommendedItem( + content_id=cid, + text=text, + final_score=float(c.final_score), + fallback_level_final=int(selected_level_by_id.get(cid, last_fallback_level)), + explanations=explanations, + ) + ) + + served_k = len(items) + + # meta:使用聚合统计,确保单调性约束成立(raw>=after_hard>=after_dedup>=after_freqcap>=served_k) + # 注意:聚合统计理论上可能出现 after_* > raw_total(例如 repo 返回重复/异常),此处交由 builder 防御修正 + meta_builder.set_candidate_pool_size_raw(int(raw_total)) + meta_builder.set_after_hard_filter(int(after_hard_total), risk_filtered_count_by_flag=dict(risk_counts_total)) + meta_builder.set_after_dedup(int(after_dedup_total)) + meta_builder.set_after_freqcap(int(after_freqcap_total), freqcap_filtered_counts=dict(freqcap_counts_total)) + meta_builder.set_served_k(int(served_k)) + meta_builder.set_fallback_level_final(int(last_fallback_level), reason=last_reason) + + meta_builder.set_config_snapshot( + { + "fallback_trace": fallback_trace, + "engine_config": cfg.model_dump(), + "constraints": cons.model_dump(), + "locale": effective_locale, + } + ) + + return RecoEngineResult(items=items, meta=meta_builder.build()) + diff --git a/server/app/features/personalized_reco/reco_engine/types.py b/server/app/features/personalized_reco/reco_engine/types.py new file mode 100644 index 0000000..5a6d20d --- /dev/null +++ b/server/app/features/personalized_reco/reco_engine/types.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field + +from app.features.personalized_reco.observability.types import RecoMeta + +Scene = Literal["feed", "push", "widget"] + + +class RecoConstraints(BaseModel): + """ + 推荐请求的可选约束(调用方可按需传入)。 + """ + + exclude_content_ids: list[int] = Field(default_factory=list) + exclude_author_ids: list[str] = Field(default_factory=list) + exclude_template_ids: list[str] = Field(default_factory=list) + + # 候选池上限(用于资源保护) + max_candidates_limit: Optional[int] = None + + # Push/Widget 作者/模板冷却窗口内的历史集合(增强频控输入) + # 说明:若不提供(None),rerank_freqcap 会记录缺失并跳过该维度过滤 + recent_author_ids: Optional[list[str]] = None + recent_template_ids: Optional[list[str]] = None + + +class RecoEngineConfig(BaseModel): + """ + 引擎级配置(V1 可调参项)。 + """ + + # Feed 是否允许 served_k < k(允许不足) + feed_allow_partial: bool = True + # Feed 是否在不足时继续回退补齐 + feed_fill_with_fallback: bool = True + + # 候选拉取倍率(limit = min(max_candidates_limit, k * multiplier)) + candidate_multiplier_feed: int = 10 + candidate_multiplier_push_widget: int = 30 + + # 每层回退的最大候选数量下限(避免 k=1 但候选过少) + min_candidates_per_level: int = 30 + + # explanations 默认开启(但应保持轻量) + enable_explanations: bool = True + + +class RecommendedItem(BaseModel): + """ + 引擎最终下发的推荐项。 + """ + + content_id: int + text: str + final_score: float + fallback_level_final: int + + # 解释信息:默认开启,但建议保持轻量(避免 payload 过大) + explanations: Optional[dict[str, Any]] = None + + +class RecoEngineResult(BaseModel): + """ + 引擎输出容器:items + meta。 + """ + + items: list[RecommendedItem] = Field(default_factory=list) + meta: RecoMeta + + +class HardFilterResult(BaseModel): + """ + Hard Filter 输出。 + """ + + kept_items: list[Any] = Field(default_factory=list) + removed_count: int = 0 + risk_filtered_count_by_flag: dict[str, int] = Field(default_factory=dict) + # 每条内容的命中信息(仅用于 explanations;默认可为空) + hits_by_content_id: dict[int, list[str]] = Field(default_factory=dict) + + +class RecommendRequest(BaseModel): + """ + 内部便捷结构(单测/集成时可用)。 + """ + + scene: Scene + user_profile: Any + already_recommended_ids: list[Any] = Field(default_factory=list) + touched_or_viewed_ids: list[Any] = Field(default_factory=list) + k: int = 1 + now: datetime + locale: Optional[str] = None + constraints: Optional[RecoConstraints] = None + config: Optional[RecoEngineConfig] = None + diff --git a/server/app/features/personalized_reco/reco_engine/utils.py b/server/app/features/personalized_reco/reco_engine/utils.py new file mode 100644 index 0000000..4cb5a37 --- /dev/null +++ b/server/app/features/personalized_reco/reco_engine/utils.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import Any, Iterable, Optional + + +def normalize_int_id_list(mixed_ids: Iterable[Any]) -> list[int]: + """ + 将混合类型的 id 列表归一化为 int 列表。 + + 规则: + - int/可转 int 的 str -> int + - 其他(None/空字符串/不可解析)忽略 + """ + + out: list[int] = [] + for x in mixed_ids or []: + if x is None: + continue + if isinstance(x, bool): + # 避免 True/False 被当作 1/0 + continue + try: + s = str(x).strip() + if s == "": + continue + out.append(int(s)) + except Exception: + continue + return out + + +def merge_exclude_ids( + *, + already_recommended_ids: Iterable[Any], + touched_or_viewed_ids: Iterable[Any], + extra_exclude_content_ids: Optional[Iterable[int]] = None, +) -> list[int]: + """ + 合并并去重排除 id(保持首次出现顺序)。 + """ + + merged = list(normalize_int_id_list(list(already_recommended_ids or []) + list(touched_or_viewed_ids or []))) + if extra_exclude_content_ids: + merged += [int(x) for x in extra_exclude_content_ids if x is not None] + + seen: set[int] = set() + out: list[int] = [] + for cid in merged: + if cid in seen: + continue + seen.add(cid) + out.append(cid) + return out + + +def normalize_or_default_locale(locale: Optional[str]) -> str: + """ + locale 防御式归一化: + - 未传/空 -> 默认 "en" + - 其他 -> 原样返回,由下游 normalize_locale 做严格校验 + """ + + if locale is None: + return "en" + raw = str(locale).strip() + return raw or "en" + + +def clamp_personalization_power(power: Any, *, fallback_level: int) -> float: + """ + 按回退层级对 personalization_power 做防御式约束。 + + - L0:不改 + - L1:<= 0.5 + - L2/L3:= 0 + """ + + try: + p = float(power) + except Exception: + p = 0.0 + if p != p: + p = 0.0 + + if int(fallback_level) >= 2: + return 0.0 + if int(fallback_level) >= 1: + return min(p, 0.5) + return p + diff --git a/server/app/features/personalized_reco/rerank_freqcap/__init__.py b/server/app/features/personalized_reco/rerank_freqcap/__init__.py new file mode 100644 index 0000000..6accc3e --- /dev/null +++ b/server/app/features/personalized_reco/rerank_freqcap/__init__.py @@ -0,0 +1,22 @@ +""" +个性化推荐|Rerank & Freqcap 子模块(重排 / 去重 / 频控) + +说明(V1): +- 本模块在 Soft Scoring 后执行,消费候选的 `final_score`,输出可下发的排序结果。 +- 仅做 Dedup / Freqcap / Feed MMR,不做 Soft Scoring 与 Hard Filter。 +""" + +from .defaults import get_default_config +from .rerank import rerank_and_freqcap +from .types import RerankConfig, RerankMeta, RerankResult, ScoredCandidate, Scene + +__all__ = [ + "RerankConfig", + "RerankMeta", + "RerankResult", + "ScoredCandidate", + "Scene", + "get_default_config", + "rerank_and_freqcap", +] + diff --git a/server/app/features/personalized_reco/rerank_freqcap/__pycache__/__init__.cpython-313.pyc b/server/app/features/personalized_reco/rerank_freqcap/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7c19f187f12232a6d599d0db26bb57ff4e1e6573 GIT binary patch literal 803 zcmZ`$&rj4)5PscXT?$0P!K?2?vIl+fZoEKlzIKgxT8svW&;^OV z$ZCQY&}g_2v%>0s;iZZ3z_#q3Rwdk>w+jak&f)7!=bLY)Urz=H6$DYeS+VY>5IPdY zc|hHblQDQaM;hWtleE-?q^5WZi#$D%Rx>=)%`+2OHOF(R%w;vt^J*XOQx&dYM39!9 zl6Ze9SCGpWC4`h_{Xfvb!C>dD&?! zx0lwE@EImJ4i6!Rr2TFq33ob;6-8<7?I*87VBNV2b*HhVT*gW5V{bUF?PAR}1FRKY zZaF0!uZPLXPHTI$(^ySDFSqu;#Ff|W%F6N9V?1M8j&4&QsB8xMt#6z0(m`C^Yu2B} z)eWG)74bp!_;53MTRW;ef=$iCC-LGI9%Dw?gS*bJhm0`;PfkvQPP4uyIRD(ytcUo9 z&J8?n*#YB1tPReff)ovxSU?SC>Sa5iMb|N{l6cFz4pH4PsM+(Vkc|d4 z@@I$n-F}qoUDf49*j$xK#ydQj%fCmzhY@WZT!%%GeVU2ICGIUPb$;RhP|iKf1zsXv)~e`-_LQ9L=mfpDk5FurIlgL&c?*e?mDw;Z7+>d z(L%KzTJaB25k!>gP1HiK9{o932Jxba&8e2^-8XBaRo}3)^S*iW-nZ|YosY+32<-Wx zQvR5X&}ZS)EZ9Lvnx}w_qs>S|nsg9#NmNRTb?9KID@?;(GL@64H{6P}uqFd8Mx-P1 z8;NRlFcu@i4-lPFiA;V(QVuDbT9ANEEVF7 z-Gu7)seO)Z=5suvWr))z{)ogvW|SiY>i(!q9o#b6jj=)1eqp-ja13%W(Q^ z%!us~J4>*k_cOlYH?I5MNeB+mKhYj3x|Z(!imo<6oobyZ7Ur@^h{0voXD4~IFK^_C zH;^#$S+6G`w=KDt@p0aOJNF;hn>ln;%Sw=-X3&2W7X9vp#W?Dz;V6S5u(qY1Pz~x9 zvHw^r%1GUU&_A|g!QZh^YZJ88KLTKE=F0Q(=(Wno#qz|>^6i(E8&AL99IC#$J~Q#4 zJbJz|{%m&g(#+IY`PQBC@YBleC$p0y)pJwj3zui^KduaomLFXEGBr^ddoepX{JC^K zmXhgGNJ|Ab-c)o9V&OBol{cVgphw^ve$fR9BTR_K zQgS-0yQ)D<-M2iJcKi74OIIW{em85s+C>e}rg4uvjIk)bE0J`xetUW#qVee5l0_RE|K4a``~QjN=-lSS)=;V8 Sjk0MTC8W@g_NcTo;PMl%=u%n$ literal 0 HcmV?d00001 diff --git a/server/app/features/personalized_reco/rerank_freqcap/__pycache__/rerank.cpython-313.pyc b/server/app/features/personalized_reco/rerank_freqcap/__pycache__/rerank.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6cad5b158fa5ad51b7b1448950023116ab16cbf3 GIT binary patch literal 8485 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 literal 0 HcmV?d00001 diff --git a/server/app/features/personalized_reco/rerank_freqcap/__pycache__/types.cpython-313.pyc b/server/app/features/personalized_reco/rerank_freqcap/__pycache__/types.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..202cb02e02e6e12c8693b8c55bdfc19588eae3b7 GIT binary patch literal 2686 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 RerankConfig: + """ + 获取指定场景的默认参数(返回副本,避免被意外修改)。 + """ + + base = _DEFAULTS[scene] + return RerankConfig.model_validate(base.model_dump()) + diff --git a/server/app/features/personalized_reco/rerank_freqcap/rerank.py b/server/app/features/personalized_reco/rerank_freqcap/rerank.py new file mode 100644 index 0000000..fba44a1 --- /dev/null +++ b/server/app/features/personalized_reco/rerank_freqcap/rerank.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from typing import Any, Iterable, Optional + +from app.features.personalized_reco.rerank_freqcap.defaults import get_default_config +from app.features.personalized_reco.rerank_freqcap.types import RerankConfig, RerankMeta, RerankResult, ScoredCandidate, Scene +from app.features.personalized_reco.rerank_freqcap.utils import as_finite_float, build_tags, clamp, jaccard, normalize_int_id_set + + +def _sort_by_score_desc(cands: list[ScoredCandidate]) -> list[ScoredCandidate]: + return sorted(cands, key=lambda x: as_finite_float(x.final_score, default=float("-inf")), reverse=True) + + +def _dedup_by_seen_ids( + cands: list[ScoredCandidate], + *, + seen_ids: set[int], +) -> tuple[list[ScoredCandidate], int]: + kept: list[ScoredCandidate] = [] + removed = 0 + for c in cands: + if int(c.content_id) in seen_ids: + removed += 1 + continue + kept.append(c) + return kept, removed + + +def _apply_author_template_freqcap( + cands: list[ScoredCandidate], + *, + recent_author_ids: Optional[Iterable[str]], + recent_template_ids: Optional[Iterable[str]], +) -> tuple[list[ScoredCandidate], dict[str, int], list[str]]: + """ + V1 策略: + - 若 recent_*_ids 未提供(None),不执行该维度过滤,但在 meta 记录缺失 + - 若提供,则执行硬过滤 + """ + + filtered_counts: dict[str, int] = {"author": 0, "template": 0} + missing: list[str] = [] + + author_set: set[str] | None + if recent_author_ids is None: + author_set = None + missing.append("author") + else: + author_set = set([a for a in recent_author_ids if a is not None and str(a).strip() != ""]) + + template_set: set[str] | None + if recent_template_ids is None: + template_set = None + missing.append("template") + else: + template_set = set([t for t in recent_template_ids if t is not None and str(t).strip() != ""]) + + out: list[ScoredCandidate] = [] + for c in cands: + if author_set is not None and c.author_id and c.author_id in author_set: + filtered_counts["author"] += 1 + continue + if template_set is not None and c.template_id and c.template_id in template_set: + filtered_counts["template"] += 1 + continue + out.append(c) + + # 只返回真正生效的维度计数(避免 meta 噪音) + effective_counts: dict[str, int] = {} + if author_set is not None: + effective_counts["author"] = int(filtered_counts["author"]) + if template_set is not None: + effective_counts["template"] = int(filtered_counts["template"]) + + missing_sorted = sorted(set(missing)) + return out, effective_counts, missing_sorted + + +def _sim(a: ScoredCandidate, b: ScoredCandidate, *, tags_a: set[str], tags_b: set[str]) -> float: + # 离散特征版(V1 推荐),对齐 plan.md + if int(a.content_id) == int(b.content_id): + return 1.0 + + sim = 0.0 + if a.template_id and b.template_id and a.template_id == b.template_id: + sim += 0.6 + if a.author_id and b.author_id and a.author_id == b.author_id: + sim += 0.3 + + sim += 0.1 * jaccard(tags_a, tags_b) + return clamp(sim, 0.0, 1.0) + + +def _mmr_rerank( + *, + candidates: list[ScoredCandidate], + k: int, + lam: float, +) -> list[ScoredCandidate]: + if k <= 0: + return [] + + if not candidates: + return [] + + lam_f = clamp(as_finite_float(lam, default=0.7), 0.0, 1.0) + + # 预计算 tags,避免重复构造 + tags_map: dict[int, set[str]] = {} + for c in candidates: + tags_map[int(c.content_id)] = build_tags(c) + + remaining = _sort_by_score_desc(list(candidates)) + selected: list[ScoredCandidate] = [] + + # Top1:最高分 + selected.append(remaining.pop(0)) + + while remaining and len(selected) < k: + best_idx = 0 + best_val = float("-inf") + + for idx, c in enumerate(remaining): + rel = as_finite_float(c.final_score, default=float("-inf")) + + tags_c = tags_map.get(int(c.content_id), set()) + max_sim = 0.0 + for s in selected: + tags_s = tags_map.get(int(s.content_id), set()) + max_sim = max(max_sim, _sim(c, s, tags_a=tags_c, tags_b=tags_s)) + + val = lam_f * float(rel) - (1.0 - lam_f) * float(max_sim) + if val > best_val: + best_val = val + best_idx = idx + + selected.append(remaining.pop(best_idx)) + + return selected + + +def rerank_and_freqcap( + *, + scene: Scene, + scored_candidates: list[ScoredCandidate], + already_recommended_ids: list[Any], + touched_or_viewed_ids: list[Any], + k: int, + config: Optional[RerankConfig] = None, + recent_author_ids: Optional[list[str]] = None, + recent_template_ids: Optional[list[str]] = None, +) -> RerankResult: + """ + 主入口:对 scored_candidates 做去重/频控/重排,输出最终可下发序列。 + + V1 约定: + - 冷却窗口“按天”由调用方保证输入集合已经裁剪到窗口内,本模块以“集合代表窗口内历史”为准 + - Feed 默认只做 dedup + MMR;Push/Widget 做 dedup + freqcap + TopK + """ + + cfg = config or get_default_config(scene) + + # seen_ids = already_recommended_ids ∪ touched_or_viewed_ids + seen_ids = normalize_int_id_set(list(already_recommended_ids) + list(touched_or_viewed_ids)) + + # 先按分数降序,保证 Top1 与 TopK 一致 + base_sorted = _sort_by_score_desc(list(scored_candidates)) + + after_dedup, removed_sentence = _dedup_by_seen_ids(base_sorted, seen_ids=seen_ids) + candidate_pool_size_after_dedup = len(after_dedup) + + missing_history_fields: list[str] = [] + freqcap_counts: dict[str, int] = {"sentence": int(removed_sentence)} + + after_freqcap = after_dedup + + # Push/Widget:作者/模板冷却(增强项) + if scene in {"push", "widget"}: + after_freqcap, dim_counts, missing = _apply_author_template_freqcap( + after_freqcap, + recent_author_ids=recent_author_ids, + recent_template_ids=recent_template_ids, + ) + missing_history_fields = missing + freqcap_counts.update(dim_counts) + else: + # Feed:不强制作者/模板冷却(V1 可选,这里默认跳过) + missing_history_fields = [] + + candidate_pool_size_after_freqcap = len(after_freqcap) + + ranked: list[ScoredCandidate] + if scene == "feed": + # MMR 前截断,避免性能问题 + top_n = int(cfg.top_n_for_mmr) if int(cfg.top_n_for_mmr) > 0 else len(after_freqcap) + mmr_pool = after_freqcap[:top_n] + ranked = _mmr_rerank(candidates=mmr_pool, k=int(k), lam=cfg.mmr_lambda) + else: + ranked = after_freqcap[: max(0, int(k))] + + meta = RerankMeta( + candidate_pool_size_after_dedup=int(candidate_pool_size_after_dedup), + candidate_pool_size_after_freqcap=int(candidate_pool_size_after_freqcap), + missing_history_fields=missing_history_fields, + freqcap_filtered_counts=freqcap_counts, + ) + return RerankResult(ranked_items=ranked, meta=meta) + diff --git a/server/app/features/personalized_reco/rerank_freqcap/types.py b/server/app/features/personalized_reco/rerank_freqcap/types.py new file mode 100644 index 0000000..d18d8fb --- /dev/null +++ b/server/app/features/personalized_reco/rerank_freqcap/types.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field + +from app.features.personalized_reco.content_repository.types import ContentProfileDTO + +Scene = Literal["feed", "push", "widget"] + + +class ScoredCandidate(BaseModel): + """ + Soft Scoring 后的候选项(本模块消费的最小字段集合)。 + + 说明: + - `content_profile` 用于 Feed 的标签/相似度计算;缺失时需降级为仅使用 author/template 等字段 + """ + + content_id: int + final_score: float + + author_id: Optional[str] = None + template_id: Optional[str] = None + + content_profile: Optional[ContentProfileDTO] = None + + # 允许透传额外字段(例如 text、breakdown 等),便于上层直接下发 + extra: dict[str, Any] = Field(default_factory=dict) + + +class RerankConfig(BaseModel): + """ + 重排/频控配置(可调参)。 + """ + + # Feed:MMR + mmr_lambda: float = 0.7 + top_n_for_mmr: int = 200 + + # Push/Widget:冷却窗口(V1 主要用于配置与可观测;真正按天需要带时间戳的历史) + cooldown_sentence_days: int = 14 + cooldown_author_days: int = 7 + cooldown_template_days: int = 7 + + +class RerankMeta(BaseModel): + candidate_pool_size_after_dedup: int + candidate_pool_size_after_freqcap: int + + # 例如未提供 recent_author_ids/recent_template_ids 时记录 ["author","template"] + missing_history_fields: list[str] = Field(default_factory=list) + + # 可选但建议:按维度统计被过滤数量 + freqcap_filtered_counts: dict[str, int] = Field(default_factory=dict) + + +class RerankResult(BaseModel): + ranked_items: list[ScoredCandidate] = Field(default_factory=list) + meta: RerankMeta + diff --git a/server/app/features/personalized_reco/rerank_freqcap/utils.py b/server/app/features/personalized_reco/rerank_freqcap/utils.py new file mode 100644 index 0000000..1218d4f --- /dev/null +++ b/server/app/features/personalized_reco/rerank_freqcap/utils.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import logging +from typing import Any, Iterable + +logger = logging.getLogger(__name__) + + +def clamp(value: float, min_value: float, max_value: float) -> float: + if value != value: # NaN + return min_value + return max(min_value, min(max_value, value)) + + +def as_finite_float(value: Any, *, default: float) -> float: + try: + f = float(value) + except Exception: + return float(default) + if f != f: + return float(default) + if f == float("inf") or f == float("-inf"): + return float(default) + return f + + +def normalize_int_id_set(values: Iterable[Any]) -> set[int]: + """ + 将历史 ID 列表归一化为 int 集合(支持 str/int 混用)。 + + 说明: + - 无法转换的值会被忽略,并记录 debug 日志(不影响主流程) + """ + + out: set[int] = set() + for v in values: + try: + if isinstance(v, bool): + # 避免 True/False 被当作 1/0 + raise ValueError("bool 不是合法 id") + out.add(int(v)) + except Exception: + logger.debug("历史 id 无法转为 int,已忽略:%r", v) + return out + + +def jaccard(a: set[str], b: set[str]) -> float: + if not a and not b: + return 0.0 + inter = len(a & b) + union = len(a | b) + return float(inter) / float(union) if union > 0 else 0.0 + + +def argmax_key(d: dict[str, Any] | None) -> str | None: + """ + 从 suitability 字典中取最大值 key(V1 用作代表标签)。 + - 空字典/None -> None + - 值非法 -> 按 default=0 处理 + """ + + if not d: + return None + best_k: str | None = None + best_v = float("-inf") + for k, v in d.items(): + fv = as_finite_float(v, default=0.0) + if fv > best_v: + best_v = fv + best_k = k + return best_k + + +def build_tags(candidate: Any) -> set[str]: + """ + 构造离散标签集合(V1 写死): + - stage: + - need: + - context: + + 说明: + - candidate 可能是 ScoredCandidate 或具备 content_profile 的对象 + - 字段缺失时自动降级(只返回可得标签) + """ + + tags: set[str] = set() + + cp = getattr(candidate, "content_profile", None) + if cp is None: + return tags + + stage = getattr(cp, "stage", None) + if stage: + tags.add(f"stage:{stage}") + + need = getattr(cp, "need_suitability", None) + need_k = argmax_key(need) + if need_k: + tags.add(f"need:{need_k}") + + ctx = getattr(cp, "context_suitability", None) + ctx_k = argmax_key(ctx) + if ctx_k: + tags.add(f"context:{ctx_k}") + + return tags + diff --git a/server/app/features/personalized_reco/scoring/__init__.py b/server/app/features/personalized_reco/scoring/__init__.py new file mode 100644 index 0000000..ba5287d --- /dev/null +++ b/server/app/features/personalized_reco/scoring/__init__.py @@ -0,0 +1,22 @@ +""" +个性化推荐|Scoring 子模块(软打分与惩罚项) + +说明: +- 本模块只做软打分与本模块定义的惩罚项(P_uncertainty、Widget 情绪软降权)。 +- Hard Filter / 频控重排 / 新鲜度等由其他模块产出,通过入参注入(缺省按 0)。 +""" + +from .defaults import get_default_config +from .score import score_content +from .types import ExternalTerms, Scene, ScoreBreakdown, ScoreConfig, ScoreResult + +__all__ = [ + "ExternalTerms", + "Scene", + "ScoreBreakdown", + "ScoreConfig", + "ScoreResult", + "get_default_config", + "score_content", +] + diff --git a/server/app/features/personalized_reco/scoring/__pycache__/__init__.cpython-313.pyc b/server/app/features/personalized_reco/scoring/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..025ca824cce6c3abdc7799641b20dda07c78579b GIT binary patch literal 797 zcmZ`%O>fgc5M3v6oVo;6y(4_h0hS+tI6#Y#DqFK{_o07Tj=IzZnBy zKP10{@hA?4*yl0G0s^vJHa5b^F%px)JU$Ya6C@Gki4k5-lB6sUK~9mB+(-Ikk%$t2 zAj^+(q(7g`2*nc|0Ago*gO;1#{4%Yrbr+U;dyTAW5#7v7-sTcrTlH3+^xAXXy)9aO z=*`{hY}e@Yy1!QsR(E>sswj51KG3JNUb`-yl4#@AVTZT4;Z^GYH2t}HUGMBv{rcU% zWah4*qN!ryD7xvC_REzUx|YX|M5kx`-3=xaJX@eE(`?>;d5X=sq!3NIs2dI@5|o1H z_i3#e%r4N{14ilco8Vo;YrXV0tNx?6-pt3&?((6nPOIt7wtDUPpj_{Mn)PO0c#Bi? z{TfTy8~t|6Z&c`fRXTGtDPRi%8O|MKW{`$+O3`qTYMD7b&rZ`eTNDntgH0#HyXoOc z=EzixYnV*fE}vDgiQVJjKJj^im2u6wZMyvtzYwV$k*nBd${B(1Y#vF5AcT+#p)Z7Z z2nhz4Gsj#3ABs zu)hc*q?@{f(CI?SO`V;vw4#B}kwG>yT&{hR!M8Zaao=M}F7a~!aG5Va`U*xrgV7&} MRDY}@kO5Zn3&Y($5C8xG literal 0 HcmV?d00001 diff --git a/server/app/features/personalized_reco/scoring/__pycache__/defaults.cpython-313.pyc b/server/app/features/personalized_reco/scoring/__pycache__/defaults.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..91ae80e19d805337a9cc4afbb7c6ffb65b295287 GIT binary patch literal 1233 zcmZWoPiP!v6#r&-b~n3|?WSoOV+^vSZsS1MIY_Yq=^~~*B;ui8*GnS9bm!Y~;_l4w z%}mnmX)RS7s7A0UHWpDSNKw+>q&1L(CqW?^(DkE~LY-}LF)pij-#6Wnl0KL>@87)l zdw-^z&1MkL=*gz}mx$0qu0+)TSP;64AgrPS!UzkqXhxtyp3AY>*i4+pXGALI(Y!d0 za2$&`fs;6e`=%11B@MO&&S2?(OF!<12oYkkARU-W;ao9J6L?@gi4RUdB|L-&N6~^v zQ^q0r@Gb`-3rx~1%l0(Sv@KVTu>`IVi!cdSY)W3Ut*U7l)sTKG8Ee-#{a7+y3h9SP z*e;!t6D+x;T7>8{(x>-77aF_^fmE>5j{2!5bwlvC7*J9p8N$nhMjLAJNTjl`M|0WnS zJ0E=)tXyqh{vcRi3$EX7U%T~a?c(nDpYE*RXfJ;fTzWrPxzJv{y|-~`XY)$%+2_IX zt@ibA_cku?zPlN`_uNed5=HlpOO-L`Qh}VV=J46&SEzBPZzdpz?*g4zR!Df*{;S62|FLtu2Y_c=(lw%@t>(Hfs@739vrbS~K`j7`Lv=`HC<7mW$Ae@`TZ HsSweB^45O$ literal 0 HcmV?d00001 diff --git a/server/app/features/personalized_reco/scoring/__pycache__/score.cpython-313.pyc b/server/app/features/personalized_reco/scoring/__pycache__/score.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..44d277a18de35ef002e05fc459447539b865e2b8 GIT binary patch literal 9081 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 literal 0 HcmV?d00001 diff --git a/server/app/features/personalized_reco/scoring/__pycache__/types.cpython-313.pyc b/server/app/features/personalized_reco/scoring/__pycache__/types.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1283dacbb7f475beacf84c9d238d1f6154b04b48 GIT binary patch literal 2942 zcmZ`*?Qa~#8Q;Cz+k5lbj^FLX<_L1g(Zm;uK&?{M5lsSdLP%LatuPv|@8*uhd%MT% zUJ~ciC2pFF3bsW^FiD_Ed5J5vF)cv?$D|+n4>-CiI!3DG3g6oY`^@Z}ai|&T ze)~Mn%+Aa`FTZD3L!p2GW6Oc8_N^=k-!P>|6t z4T7~{gEg28MH;JYyNF1Gs$rP6YHOxpf$BY?*_fz$#6|s2s}_FFjANa84r*ABN8D6O zU>v7XI%B;?Pm`U)j zOe3Kst1M!fF98Cj^B0P_-&fDRRsQPc%0jLm!_td?_7abOIQC= zn!R3p|J~&;-m2XCW98O|#Va3Fv)R(0=8E&5z`+Ss*GJXZ7}D`^ti$Q$zg{X|{CH*I z%JS{A#nYFoGk-7Mxn5kD3deLcl@3r(LN`?#LVyew5dR5;QznZc^`1bMttK(E!F_Ce z(oR0X$3F+DN7d7>sWm-6NTpG1s|Vp>nmX;lhB~TalrdtM*s5mO<0uU_-5!s)l9%6O z)$z6<77;V00M=t~wRR`hlAbu+pjTVuAF zj_N1Jqe-X}HJZ|lcp{@4*s`Lq?l>k{ zqxN_jTLbBF5`<$ou(Y&(18`3G&Lw+%_nU=K%fxeqrnbD)Rsiv+BQCXeW)I|^%uAhx zmX7THTz_8bSbB8VqSSXy%1gWM%feIp#6_uh@u8pQrNP2u1B+7lTK_$1fQ0KuZCW2fRV>sZ)10bxLoG7R$E(jz0?9xmNJ9rs0;zx-Uc@A2Rky=A znX3SS;zyUN6SJkyW{YP(u3rBVD&ghF!%&6Q?D@?~kXIf7dNRg#CkTEcrlz6Fa+ks* z%(c6*k%Uwj2`|szD4v_&Sa;=x`SR7%r8m!q_pB_ux%`j!D|4sen9}6i#rdh_FD5H@ zF2Eb*+ZRe7{H{FvS@GuOKpF1G2MNK0dKCmG0_*vYp#&sMGJ~n_7=$zpp{6oOnl;Ar z=4d2Hy5SYl!(c0eZ47!D>|oHxfH{clWDsHS5CfKWPLLjE(j#?X4znHy_&zf_y0g#Z zUdl^dh0dPrpNxOvFyG(9Y{ z9;C=W7a{Cku=3C{>#$GpgVoPEj#49LkU~84&~Iw=%%rAuTl2c)&^4QFS+_Z)&1`M! zw)S7$LD@}DMH9gCYMj4JbA=4Su_M)Nn$ zls^9haDU~-hfd;^-1Vg=J%)b3yk^EmX2RiT+MG7i8C|t8 z8Xf2C`(iDhLqDF+JeA?QM=3IF;G9s{(g7W!(A0ZR>UI7ZTfn+3^wva%H7aO%?9Wjdx)LNNVf;XS z>G8N~KyM`cYm`TMH%vGk>k%gL=Bc(ht>pmto8MZ^7L=l1bD3N6J288bmYGR IJoq2^ACw+uVE_OC literal 0 HcmV?d00001 diff --git a/server/app/features/personalized_reco/scoring/__pycache__/utils.cpython-313.pyc b/server/app/features/personalized_reco/scoring/__pycache__/utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dc4b8339883f8b74992fe8047d1906ae7e5c8f9b GIT binary patch literal 2178 zcmaJ?eM}Tb6rcULZx9hMhWN42GwC(O)kKnpXp^gGj3Q~ho~6O&y58-bC&%t}_6|9$ zW>HZtpwOnshuDv(t=g(*V_F1^@t^)p#iV3|t3TGStu;DBWSN}SHoYK5PC```Nm`!Y9Rvw+8Z{FeRg?dhJAJRL9_0hhk>T$b^ z)|h6Y6-ZceF`B7Gh{Q$B1~Xrt6`qNVrW5_^{}gdBrc|uoU9sv2aA$ z5KU+%e?(}G;sOY0gRWEnQPDFyvKF2=HdVB0!n$hOv8*#O{K`OhvYelEY@A>>E`Sm8 z=P!+nEOg^~e*&fttzCMhC=aMaH&hze?m$cRR9^2yUit+To<(_0144l!0B5O|&U+E9 z@INa$Q3Yzu0f~?W3CrMa|1-WquR~50L|d74C`h@O_%=8UfjT`lHgmEE0M}D@GpBpz z?tYgZ8J!tUW(Iq?U_=&T^W*3BOSk53U)DPZ=k9%`pZ}Z-h(RG9i4o*ACM*Tp*ealq z1SZ2nvu$tni&2uWE(Tj6kY>rbXjCwt02t1Nv?4+A1;Y|R?lXLGDM(A8D+hoOv>4Oc z>4M_k^<8zH6v57RqGh_MwD-NPV-wajQxW>#|u1DhXNqqvw}R+ zx9L4A5pn>M5+hL(?PdNQ@x?RtGWkr&t|ma?@-iM420*)!3`SdGXXqrhk(|nCd&%2) zsd-Ed+p6Krq;bg0I$`%5QW%Mrs33Bg;$J7yIn!*ZPg2`SBrp6;}^#9M2s&$~`&tDK|Sl3V8-a zCYNMH3r6~xKH_YcWHoa)FB1vTT^d@{L8vF{uztN??-y)Zj;lGtR6 z*W8Viezy#z!qUF)B zzsZ+#@PQ?+0E6|=l^75e{ZWbvN~VfR9+y{qQ8idK%s+IvhxkvNy%ks9NmYEiFZIz# zZ7R{@9^}>9pB?UB9YtU~ZLxQ5K9T5klVVxY>rNi-T$U~@>892B;m3gF)}#Kj76yH zk}+|av&N+GVt5fbi7;nz9%5Xg2iKW>>VyrHUgVpDggl1x24jSVRF-nthvsV!=BvPY- zS+_LHfp|oG6T3i0az?2F0_BCG9-+#oXxSsQ^096CFSharw(@~DCT&jD{M>AzHa#yb Oq6&X^mQgE=<^BQrz+}t- literal 0 HcmV?d00001 diff --git a/server/app/features/personalized_reco/scoring/defaults.py b/server/app/features/personalized_reco/scoring/defaults.py new file mode 100644 index 0000000..10a67eb --- /dev/null +++ b/server/app/features/personalized_reco/scoring/defaults.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from app.features.personalized_reco.scoring.types import Scene, ScoreConfig + + +_DEFAULTS: dict[Scene, ScoreConfig] = { + # 来源:设计说明文档/個性化推薦算法規則.md(V1 建议权重) + "feed": ScoreConfig( + w_need=0.35, + w_emotion=0.20, + w_stage=0.15, + w_context=0.30, + # Feed 默认不启用不确定性惩罚(可按需开启) + enable_uncertainty_penalty=False, + ), + "push": ScoreConfig( + w_need=0.45, + w_emotion=0.35, + w_stage=0.15, + w_context=0.05, + # Push 默认启用不确定性惩罚 + enable_uncertainty_penalty=True, + ), + "widget": ScoreConfig( + w_need=0.25, + w_emotion=0.25, + w_stage=0.30, + w_context=0.20, + # Widget 默认不启用不确定性惩罚(可按需开启) + enable_uncertainty_penalty=False, + widget_emotion_soft_range=(0.4, 0.8), + widget_emotion_penalty_gamma=0.25, + ), +} + + +def get_default_config(scene: Scene) -> ScoreConfig: + """ + 获取指定场景的默认打分参数(返回副本,避免被意外修改)。 + """ + + base = _DEFAULTS[scene] + return ScoreConfig.model_validate(base.model_dump()) + diff --git a/server/app/features/personalized_reco/scoring/score.py b/server/app/features/personalized_reco/scoring/score.py new file mode 100644 index 0000000..52b510a --- /dev/null +++ b/server/app/features/personalized_reco/scoring/score.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from app.features.personalized_reco.content_repository.types import ContentProfileDTO +from app.features.personalized_reco.scoring.defaults import get_default_config +from app.features.personalized_reco.scoring.types import ExternalTerms, Scene, ScoreBreakdown, ScoreConfig, ScoreResult +from app.features.personalized_reco.scoring.utils import as_finite_float, clamp, pick_one_hot_key +from app.features.user_profile_scoring.types import UserProfileV1_2 + + +def _missing_fields(user_profile: UserProfileV1_2) -> list[str]: + missing: list[str] = [] + if not user_profile.need: + missing.append("need") + if not user_profile.context: + missing.append("context") + if user_profile.emotion_score is None: + missing.append("emotion") + return missing + + +def _score_need(user_profile: UserProfileV1_2, content: ContentProfileDTO) -> float: + key = pick_one_hot_key(user_profile.need) # type: ignore[arg-type] + if key is None: + return 0.5 + raw = content.need_suitability.get(key, 0.5) + return clamp(as_finite_float(raw, default=0.5), 0.0, 1.0) + + +def _score_context(user_profile: UserProfileV1_2, content: ContentProfileDTO) -> float: + key = pick_one_hot_key(user_profile.context) # type: ignore[arg-type] + if key is None: + return 0.5 + raw = content.context_suitability.get(key, 0.5) + return clamp(as_finite_float(raw, default=0.5), 0.0, 1.0) + + +def _score_emotion(user_profile: UserProfileV1_2, content: ContentProfileDTO) -> float: + # V1.2:用户情绪缺失 -> 0.8 + if user_profile.emotion_score is None: + return 0.8 + + # 文案 general(emotion_score=None)-> 0.8 + if content.emotion_score is None: + return 0.8 + + u = clamp(as_finite_float(user_profile.emotion_score, default=0.8), 0.0, 1.0) + c = clamp(as_finite_float(content.emotion_score, default=0.8), 0.0, 1.0) + return clamp(1.0 - abs(u - c), 0.0, 1.0) + + +def _user_stage_key(user_profile: UserProfileV1_2) -> str: + # 约定:UserStageOneHot.unknown 必填;但这里仍做防御 + stage = user_profile.stage + if getattr(stage, "expecting", 0) == 1: + return "expecting" + if getattr(stage, "parenting", 0) == 1: + return "parenting" + if getattr(stage, "unknown", 1) == 1: + return "unknown" + return "unknown" + + +def _score_stage(user_profile: UserProfileV1_2, content: ContentProfileDTO) -> float: + # 对齐算法规则: + # - general=1;命中=1;unknown对非unknown=0.7;其余=0 + if content.stage == "general": + return 1.0 + + u_stage = _user_stage_key(user_profile) + if content.stage == u_stage: + return 1.0 + + if u_stage == "unknown" and content.stage != "unknown": + return 0.7 + + return 0.0 + + +def _score_personal(alpha: float, personalization_power: float, s_need: float, s_context: float) -> float: + power = clamp(as_finite_float(personalization_power, default=0.0), 0.0, 1.0) + a = as_finite_float(alpha, default=0.0) + return float(a) * float(power) * max(float(s_need), float(s_context)) + + +def _penalty_uncertainty(beta: float, user_profile: UserProfileV1_2, content: ContentProfileDTO) -> float: + b = as_finite_float(beta, default=0.0) + power = clamp(as_finite_float(content.personalization_power, default=0.0), 0.0, 1.0) + + # V1 约定:conf_U 缺失时按 1.0(避免过惩罚) + conf_u = clamp(as_finite_float(getattr(user_profile, "profile_confidence", 1.0), default=1.0), 0.0, 1.0) + conf_c = clamp(as_finite_float(getattr(content, "review_confidence", 0.7), default=0.7), 0.0, 1.0) + + return float(b) * (1.0 - float(conf_u)) * (1.0 - float(conf_c)) * float(power) + + +def _widget_emotion_penalty(scene: Scene, content: ContentProfileDTO, config: ScoreConfig) -> float: + if scene != "widget": + return 0.0 + if content.emotion_score is None: + return 0.0 + + lo, hi = config.widget_emotion_soft_range + lo_f = as_finite_float(lo, default=0.4) + hi_f = as_finite_float(hi, default=0.8) + width = hi_f - lo_f + if width <= 0: + return 0.0 + + e = clamp(as_finite_float(content.emotion_score, default=0.6), 0.0, 1.0) + if e < lo_f: + d = lo_f - e + elif e > hi_f: + d = e - hi_f + else: + d = 0.0 + + gamma = as_finite_float(config.widget_emotion_penalty_gamma, default=0.25) + raw = float(gamma) * float(d) / float(width) + return clamp(raw, 0.0, float(gamma)) + + +def score_content( + *, + scene: Scene, + user_profile: UserProfileV1_2, + content_profile: ContentProfileDTO, + config: Optional[ScoreConfig] = None, + pass_filters: bool = True, + external_terms: Optional[ExternalTerms] = None, + now: Optional[datetime] = None, # 预留:V1 不使用 +) -> ScoreResult: + """ + 主入口:对单条内容 Cᵢ 进行软打分,返回 final_score 与 breakdown。 + + 说明(V1): + - `pass_filters` 来自 Hard Filter(本模块不做硬过滤) + - `external_terms` 可注入 S_fresh / P_fatigue / P_repeat / P_risk(缺省按 0) + - `now` 预留给未来的 freshness/时间衰减(V1 不实现) + """ + + cfg = config or get_default_config(scene) + ext = external_terms or ExternalTerms() + + missing = _missing_fields(user_profile) + + s_need = _score_need(user_profile, content_profile) + s_context = _score_context(user_profile, content_profile) + s_emotion = _score_emotion(user_profile, content_profile) + s_stage = _score_stage(user_profile, content_profile) + + w_need = as_finite_float(cfg.w_need, default=0.0) + w_emotion = as_finite_float(cfg.w_emotion, default=0.0) + w_stage = as_finite_float(cfg.w_stage, default=0.0) + w_context = as_finite_float(cfg.w_context, default=0.0) + + s_core = float(w_need) * s_need + float(w_emotion) * s_emotion + float(w_stage) * s_stage + float(w_context) * s_context + + s_personal = _score_personal(cfg.alpha, content_profile.personalization_power, s_need, s_context) + + p_uncertainty = 0.0 + if cfg.enable_uncertainty_penalty: + p_uncertainty = _penalty_uncertainty(cfg.beta, user_profile, content_profile) + + p_widget = _widget_emotion_penalty(scene, content_profile, cfg) + + s_fresh = as_finite_float(ext.S_fresh, default=0.0) + p_fatigue = as_finite_float(ext.P_fatigue, default=0.0) + p_repeat = as_finite_float(ext.P_repeat, default=0.0) + p_risk_external = as_finite_float(ext.P_risk, default=0.0) + + # Widget 软降权并入 P_risk(但在 breakdown 中单独暴露,便于打点) + p_risk = float(p_risk_external) + float(p_widget) + + raw_final = s_core + s_personal + float(s_fresh) - float(p_fatigue) - float(p_repeat) - float(p_risk) - float(p_uncertainty) + final_score = float(raw_final) if pass_filters else 0.0 + + breakdown = ScoreBreakdown( + scene=scene, + **{ + "pass": bool(pass_filters), + }, + missing_fields=missing, + S_need=float(s_need), + S_context=float(s_context), + S_stage=float(s_stage), + S_emotion=float(s_emotion), + S_core=float(s_core), + S_personal=float(s_personal), + S_fresh=float(s_fresh), + P_fatigue=float(p_fatigue), + P_repeat=float(p_repeat), + P_risk=float(p_risk), + P_uncertainty=float(p_uncertainty), + P_widget_emotion_out_of_range=float(p_widget), + ) + + return ScoreResult(final_score=float(final_score), breakdown=breakdown) + diff --git a/server/app/features/personalized_reco/scoring/types.py b/server/app/features/personalized_reco/scoring/types.py new file mode 100644 index 0000000..d829fd3 --- /dev/null +++ b/server/app/features/personalized_reco/scoring/types.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + +Scene = Literal["feed", "push", "widget"] + + +class ScoreConfig(BaseModel): + """ + 打分配置(可调参)。 + + 说明: + - 默认值由 `defaults.get_default_config(scene)` 提供 + - 本模块不负责回退梯度(fallback_level)策略;仅做防御式 clamp + """ + + w_need: float + w_emotion: float + w_stage: float + w_context: float + + alpha: float = 0.15 + beta: float = 0.30 + + enable_uncertainty_penalty: bool = False + + # Widget 情绪软区间与软降权强度 + widget_emotion_soft_range: tuple[float, float] = (0.4, 0.8) + widget_emotion_penalty_gamma: float = 0.25 + + +class ExternalTerms(BaseModel): + """ + 外部注入项(V1 可选)。 + + 说明: + - 由 `rerank-freqcap` 或 `reco-engine` 产出 + - 本模块缺省按 0,保证可排序与输出结构稳定 + """ + + S_fresh: float = 0.0 + P_fatigue: float = 0.0 + P_repeat: float = 0.0 + P_risk: float = 0.0 + + +class ScoreBreakdown(BaseModel): + """ + 可观测分解项(用于调参与回归测试)。 + """ + + scene: Scene + passed: bool = Field(alias="pass") + + missing_fields: list[str] = Field(default_factory=list) + + S_need: float + S_context: float + S_stage: float + S_emotion: float + + S_core: float + S_personal: float + S_fresh: float + + P_fatigue: float + P_repeat: float + P_risk: float + P_uncertainty: float + + # Widget 专用:区间外软降权(建议保留,便于打点) + P_widget_emotion_out_of_range: float = 0.0 + + model_config = { + "populate_by_name": True, + } + + +class ScoreResult(BaseModel): + final_score: float + breakdown: ScoreBreakdown + diff --git a/server/app/features/personalized_reco/scoring/utils.py b/server/app/features/personalized_reco/scoring/utils.py new file mode 100644 index 0000000..18f6735 --- /dev/null +++ b/server/app/features/personalized_reco/scoring/utils.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def clamp(value: float, min_value: float, max_value: float) -> float: + """ + 将值裁剪到区间内,并对 NaN 做兜底。 + """ + + if value != value: # NaN + return min_value + return max(min_value, min(max_value, value)) + + +def as_finite_float(value: Any, *, default: float) -> float: + """ + 将任意值尽量转为有限 float;失败则返回 default。 + """ + + try: + f = float(value) + except Exception: + return float(default) + + # NaN / inf 都视为不可用 + if f != f: + return float(default) + if f == float("inf") or f == float("-inf"): + return float(default) + return f + + +def pick_one_hot_key(one_hot: dict[str, Any] | None) -> str | None: + """ + 从稀疏 one-hot({key: 1})中取唯一 key。 + + 约定: + - None / {} → 缺失,返回 None + - 单 key → 返回该 key + - 多 key → 取“字典序最小”的 key,并记录 debug 日志(避免静默歧义) + """ + + if not one_hot: + return None + + keys = [k for k, v in one_hot.items() if v == 1 or v is True] + if not keys: + return None + if len(keys) == 1: + return keys[0] + + chosen = sorted(keys)[0] + logger.debug("one-hot 出现多个 key=1,已按字典序选择:chosen=%s keys=%s", chosen, keys) + return chosen + diff --git a/server/app/features/user_profile_scoring/__pycache__/__init__.cpython-313.pyc b/server/app/features/user_profile_scoring/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d7ceb7a2ea1b203bdac11c9fe1427da5c8f6a731 GIT binary patch literal 525 zcmZuu&rcdb6y8-+$tL~-J?)i4nbf0cJTyHwCZs(&5E#fJu$!ITrrhvHV9~-F3u-Bv zY>nEc?b4(_XbQpqgCj7r>xoAGfzG&^7}Ix|dGqFb-}~M-U09F=7TXVt%2QDgMm)I< zmS4v-#Eym#6?7pjhG)zrR*+s`q{}P1no_bzjwV!H$)_hh%d3B+R>!M%$)-&!Z;4f! z^eQhOM;0Z?Z~vf&wMnljJ(8)tJMO<97F(AGE~$3BuXXBJ3{LE}-}&xelo@JR+LQ0L zyD7Vl=N^oT>-_8a2K~7c+`WoLTVKeVeU2<1dCe`_s*-KT``jk(u~+-XSpD-YV!a!8 zPvscaPy((g2Kb*2z^jp5GR7$R?5ySZcTJMznD6#|*9nx+{W5h<0C7K1>ldeK*i1?w z+CQ^NrF_4|A5tz!&%@$i9>S!Wfbf$rgb}SU2tCQENi&O<^#_cO V`--{ke`iHeyqcL8=kz(2{sU_o^Nj!i literal 0 HcmV?d00001 diff --git a/server/app/features/user_profile_scoring/__pycache__/scoring.cpython-313.pyc b/server/app/features/user_profile_scoring/__pycache__/scoring.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..553856e26c49a97843a40d4aa0474dda9366ca44 GIT binary patch literal 6817 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 R>|1m)eElC>0MStX{{WhrU5@|& literal 0 HcmV?d00001 diff --git a/server/app/features/user_profile_scoring/__pycache__/types.cpython-313.pyc b/server/app/features/user_profile_scoring/__pycache__/types.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6b4d13152524232e04c27f89c29c97336da8e5a6 GIT binary patch literal 4497 zcmb7H|8o=95xm^kbglQ&v?w6>2xNsWa%ehaDVAf-F+tsnW&wq zX0*3&_q})T?e1rH@2Srxaqv6cnABDP_ahDpKbOt$PaAlSJI{r<5Wkb_;0fEzLmPzM=w5~)t zmr3hEIxjr8c!oMAoe$E6lDYLg3j&QfJZ6BW6^gDKmSSm!Zo<$TQ7qNc`qY4vI@|Pq z>e;1PDp6w8yC;s5lvuz)#m$PT?lvN7jJmdIYAgb?MfF%*?Y1<%hl+89s5)ak3H^v} z9MuDE>WmpjX@&8=N{;rbu|73IU6w}R;nCHEg|860l~^BjA2#}bM|U+>WLCf)gvZ#_o_V@fZ)IP&LqHHGOaf4X2ws;abPE^xO5S2=% z8jH%XrPWKT6#bZ{TKzKYt*KFbhiS=&l$fG-tKGIIZJKv0|8yUW&vRjp@F9*kLOgLc zLs$_ZF`jeeRL`6PE`IQ_{;_V%~g?@vlncH~xO_$+9o zRsMsWhOQp;XD?r$zIqR)O@DSHd*v&8W-xQ%QaU;MX!O+7owMm1S2Cw=Pk%Ks{ppAP zgL)zsJLpf3+|At|dNeu+8>PR#kh$^Q^yt0x)o)-$=Hi!AqnAK6b@wmXYbU1e+{?ec zbD*IR8wYk9eIZNf$sfOqihV|(Y@%dfesR)P+fVityQnAcw~`rK4Bb+XS;ZOM_IRMK zxJRd|Mha>{hrt(Ipn`g3Sy%d0S*DUK_Zg8y4Eah~elMZK@|tQ{j%viTVw$e%2BjCe41tUuTTNK%4*njV4h)*%{$Fc07mRO0bqR8=g9PBWMQAq1-t)!^)A zw?P0=L90KmnycgeWC^^15sV4*RUjw1Cl0sE`?!W{4JLP_s$0f|)sro)$v0BI7srLx zQg&%`vOOg&nGl-E%ECV7gM1^Xe;VXr2yzy(4&a0$X9qmW+)U;nt{!hdq}6-vTi9+= zBRRAY6%H8&qzx7m&)Jq4u`}Q%&3L-yNN_{40?A8AT9M#3_61fU7c3BzoAZF2?NKa2i_Px&*FcN zu5LH19GX_1JkGtBGOUMLA#H&mhA8hB$u1tLwKyFPXAEM&Cog9kq( zFXk>Lv!DMaJ@j$r!r!JZo(3)CF-&MDK1$!XYp1Qu;F;|4`TX#wjZ!@9U#A(4HMZq3{}*pndnw;0n*X0)NeCF{8M^#zm+=i{Q$)2-tDv+XLWy3^G-~RWq&znu|Fws>M;*3;q&AbHQH{^n|WZ z{d3X^CY{Gt&L@>UHGu{@Q|0qB>oGRLP6NXG&|eHx7jjVx4qLitb)g(Vu`;1Sp}zM@ zTa|%^;(}RnSXotAX&MRAtrE;8w5}lRQFUl7Ej0qAl)9oZ10J;nCHOn4LA<~!%-OW2 zpio$6q()eBBWpp|IZ$2f3)n7Q)R*rmSOryCI1bc5199QNn1qX^-A=f^vfV-rh zqp*PA@~M&;cN^?L-a^9Q%TT%>hvm#1LIn~$f$0YV7+&bVSvR)uHOP+rTSQOc>#vJe&H|?szTs?mRF;KD01j^B0UHhS4ie_F z1IUGulyliHP=N$*Z@vs9U)-K}xxnhw#{FX}-o{22pzt;}vb$2XtfyVll-!aM7fuLG zwhM8K&cHlu`lAVKqoE;pg3Qi0Zs?w&i6-o0-Hl=o5bBNhM-=!9Z zW__wqAL~&H-z8Lf_7P$?=ByQEjTbB1EM6J FastAPI: # 业务路由 app.include_router(user_profile_router) + app.include_router(reco_router) @app.get("/healthz") async def healthz() -> dict: diff --git a/server/app/tasks/__pycache__/__init__.cpython-313.pyc b/server/app/tasks/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..181caaad2ca19a48596f8f03f3612713d151d7ac GIT binary patch literal 211 zcmey&%ge<81T&bcG8KUIV-N=h7@>^M96-iYhG2#whIB?vrcwzYm&-XdC$*?j;mPjZ zPrDYroZa?xLdWBVCN4is##`+1@hSPq@$oAeK7&lS<)$B6oLW?@pOv4LukVstoL!P% zpr2Dwsh^%%lA5TWo0*rAR+^KST3oCTR9BW-q@P$&pkI<$oL#IRAD@|*SrQ+wS5SG2 l!zMRBr8Fniu80k2EXZ}mAjf}TW@Kdi#KgqNQp5t}0s!nmJj(z8 literal 0 HcmV?d00001 diff --git a/server/app/tasks/__pycache__/reco.cpython-313.pyc b/server/app/tasks/__pycache__/reco.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..70900b2cb0f183d7981c4c4cadaaf465bb7164c2 GIT binary patch literal 5711 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 literal 0 HcmV?d00001 diff --git a/server/app/tasks/reco.py b/server/app/tasks/reco.py new file mode 100644 index 0000000..0e34fc2 --- /dev/null +++ b/server/app/tasks/reco.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from typing import Any, Optional + +from celery import shared_task + +from app.db.session import AsyncSessionLocal +from app.features.personalized_reco.content_repository.sqlalchemy_repo import SqlAlchemyContentRepository +from app.features.personalized_reco.content_repository.types import normalize_locale +from app.features.personalized_reco.reco_engine import recommend +from app.features.personalized_reco.reco_engine.types import RecoConstraints, RecoEngineResult, Scene +from app.features.user_profile_scoring.types import UserProfileV1_2 + + +def _ensure_now(now: Optional[datetime]) -> datetime: + if now is None: + return datetime.now(timezone.utc) + if now.tzinfo is None: + return now.replace(tzinfo=timezone.utc) + return now + + +def _ensure_locale(locale: Optional[str]) -> str: + raw = (locale or "").strip() or "en" + # 严格校验只支持 en/tc(允许 en-US 等在 normalize_locale 内归一化) + return str(normalize_locale(raw)) + + +async def _run_reco_async( + *, + scene: Scene, + user_profile: UserProfileV1_2, + already_recommended_ids: list[Any], + touched_or_viewed_ids: list[Any], + k: int, + now: datetime, + locale: str, +) -> RecoEngineResult: + async with AsyncSessionLocal() as session: + repo = SqlAlchemyContentRepository(session) + return await recommend( + repo=repo, + scene=scene, + user_profile=user_profile, + already_recommended_ids=list(already_recommended_ids or []), + touched_or_viewed_ids=list(touched_or_viewed_ids or []), + k=int(k), + now=now, + locale=locale, + constraints=RecoConstraints(), + ) + + +def _run_reco_sync( + *, + scene: Scene, + user_profile: UserProfileV1_2, + already_recommended_ids: list[Any], + touched_or_viewed_ids: list[Any], + k: int, + now: Optional[datetime], + locale: Optional[str], +) -> dict[str, Any]: + effective_now = _ensure_now(now) + effective_locale = _ensure_locale(locale) + result = asyncio.run( + _run_reco_async( + scene=scene, + user_profile=user_profile, + already_recommended_ids=already_recommended_ids, + touched_or_viewed_ids=touched_or_viewed_ids, + k=int(k), + now=effective_now, + locale=effective_locale, + ) + ) + # 默认不存结果,但返回值可用于开发调试(worker 通常 ignore_result) + return result.model_dump() + + +@shared_task(name="tasks.reco.generate") +def generate( + *, + scene: Scene, + user_profile: dict[str, Any], + already_recommended_ids: Optional[list[Any]] = None, + touched_or_viewed_ids: Optional[list[Any]] = None, + k: Optional[int] = None, + now: Optional[str] = None, + locale: Optional[str] = None, +) -> dict[str, Any]: + """ + 推荐生成任务(通用入口)。 + + 说明: + - 入参尽量保持小(避免 Redis 队列膨胀) + - 默认 worker 配置为 ignore_result,但这里仍返回结构,便于本地调试 + """ + + # 解析 user_profile(严格按 V1.2) + u = UserProfileV1_2.model_validate(user_profile or {}) + + # k 默认按场景(与 API 一致) + if k is None: + k_i = 30 if scene == "feed" else 1 + else: + k_i = int(k) + + # now 支持 ISO 字符串 + dt: Optional[datetime] + if not now: + dt = None + else: + raw = str(now).strip() + if raw.endswith("Z"): + raw = raw[:-1] + "+00:00" + try: + dt = datetime.fromisoformat(raw) + except Exception: + dt = None + + return _run_reco_sync( + scene=scene, + user_profile=u, + already_recommended_ids=list(already_recommended_ids or []), + touched_or_viewed_ids=list(touched_or_viewed_ids or []), + k=k_i, + now=dt, + locale=locale, + ) + + +def _deliver_push_placeholder(payload: dict[str, Any]) -> None: + """ + Push 下游写入占位函数(V1 不接真实推送系统)。 + """ + + _ = payload + return None + + +@shared_task(name="tasks.reco.push_once") +def push_once( + *, + user_profile: dict[str, Any], + already_recommended_ids: Optional[list[Any]] = None, + touched_or_viewed_ids: Optional[list[Any]] = None, + now: Optional[str] = None, + locale: Optional[str] = None, +) -> dict[str, Any]: + """ + 单次 Push 生成(占位任务)。 + """ + + payload = generate( + scene="push", + user_profile=user_profile, + already_recommended_ids=already_recommended_ids, + touched_or_viewed_ids=touched_or_viewed_ids, + k=1, + now=now, + locale=locale, + ) + _deliver_push_placeholder(payload) + return payload + diff --git a/server/requirements.txt b/server/requirements.txt index ad0748f..9ce85d6 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -5,6 +5,7 @@ uvicorn[standard]>=0.27 SQLAlchemy>=2.0 aiomysql>=0.2 greenlet>=3.0 +aiosqlite>=0.20 # 配置 pydantic>=2.6 @@ -19,3 +20,5 @@ redis>=5.0 # 测试 pytest>=8.0 +pytest-asyncio>=0.23 +httpx>=0.27 diff --git a/server/run.sh b/server/run.sh new file mode 100755 index 0000000..86905fd --- /dev/null +++ b/server/run.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 一键启动 FastAPI 后端: +# - 自动创建/复用虚拟环境(.venv) +# - 自动安装 requirements.txt 依赖 +# - 自动启动 uvicorn(默认开启 --reload) +# +# 用法示例: +# ./run.sh # 默认 host=0.0.0.0 port=8000 env=dev reload=on +# ./run.sh --env prod # 使用 .env.prod(若存在且可被 source) +# ./run.sh --port 9000 # 改端口 +# ./run.sh --no-reload # 关闭热更新 +# ./run.sh --install-only # 只安装依赖,不启动 + +usage() { + cat <<'EOF' +用法: + ./run.sh [--env dev|prod] [--host 0.0.0.0] [--port 8000] [--no-reload] [--skip-install] [--install-only] + +参数: + --env dev|prod 优先尝试加载 .env.dev 或 .env.prod(如果存在)。 + --host uvicorn host(默认 0.0.0.0) + --port uvicorn port(默认 8000) + --no-reload 关闭 uvicorn --reload + --skip-install 跳过依赖安装(默认会安装/更新 requirements.txt) + --install-only 只安装依赖,不启动服务 + -h, --help 显示帮助 + +说明: + - 若你的 .env.* 不是 shell 可 source 的格式(例如包含空格/特殊字符未加引号),建议改成 KEY=value 形式。 + - 启动后访问: + /healthz 健康检查 + /docs OpenAPI 文档 +EOF +} + +# 始终从脚本所在目录运行(避免在别处执行导致路径错) +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +ENV_NAME="dev" +HOST="0.0.0.0" +PORT="8000" +RELOAD="1" +SKIP_INSTALL="0" +INSTALL_ONLY="0" + +while [[ $# -gt 0 ]]; do + case "$1" in + --env) + ENV_NAME="${2:-}" + shift 2 + ;; + --host) + HOST="${2:-}" + shift 2 + ;; + --port) + PORT="${2:-}" + shift 2 + ;; + --no-reload) + RELOAD="0" + shift 1 + ;; + --skip-install) + SKIP_INSTALL="1" + shift 1 + ;; + --install-only) + INSTALL_ONLY="1" + shift 1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "未知参数:$1" >&2 + echo "" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ "$ENV_NAME" != "dev" && "$ENV_NAME" != "prod" ]]; then + echo "--env 仅支持 dev 或 prod,当前:$ENV_NAME" >&2 + exit 2 +fi + +ENV_FILE=".env.${ENV_NAME}" +if [[ -f "$ENV_FILE" ]]; then + # 让 source 进来的变量自动 export(供 pydantic-settings/应用读取) + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a +fi + +# 选择 python 命令(优先 python3) +PY_BIN="" +if command -v python3 >/dev/null 2>&1; then + PY_BIN="python3" +elif command -v python >/dev/null 2>&1; then + PY_BIN="python" +else + echo "未找到 python/python3,请先安装 Python 3.11+。" >&2 + exit 1 +fi + +VENV_DIR=".venv" +if [[ ! -d "$VENV_DIR" ]]; then + echo "创建虚拟环境:$VENV_DIR" + "$PY_BIN" -m venv "$VENV_DIR" +fi + +# 激活虚拟环境 +# shellcheck disable=SC1091 +source "$VENV_DIR/bin/activate" + +if [[ "$SKIP_INSTALL" == "0" ]]; then + if [[ -f "requirements.txt" ]]; then + echo "升级 pip 并安装依赖(requirements.txt)" + python -m pip install -U pip + python -m pip install -r requirements.txt + else + echo "未找到 requirements.txt,跳过依赖安装。" >&2 + fi +fi + +if [[ "$INSTALL_ONLY" == "1" ]]; then + echo "依赖安装完成(install-only),退出。" + exit 0 +fi + +UVICORN_ARGS=(app.main:app --host "$HOST" --port "$PORT") +if [[ "$RELOAD" == "1" ]]; then + UVICORN_ARGS+=(--reload) +fi + +echo "启动服务:uvicorn ${UVICORN_ARGS[*]}" +exec uvicorn "${UVICORN_ARGS[@]}" + diff --git a/server/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc b/server/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3783d2f5a2b76b39b88b2f1f6a0fdd4042f77eae GIT binary patch literal 10653 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 literal 0 HcmV?d00001 diff --git a/server/tests/__pycache__/test_content_repository.cpython-313-pytest-9.0.2.pyc b/server/tests/__pycache__/test_content_repository.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1800d81919704d5d145a3b0cc82a06df4484517c GIT binary patch literal 18510 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 literal 0 HcmV?d00001 diff --git a/server/tests/__pycache__/test_observability.cpython-313-pytest-9.0.2.pyc b/server/tests/__pycache__/test_observability.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..765472209e9738bc8bc158afb6589a4b01d706d2 GIT binary patch literal 18938 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 literal 0 HcmV?d00001 diff --git a/server/tests/__pycache__/test_rerank_freqcap.cpython-313-pytest-9.0.2.pyc b/server/tests/__pycache__/test_rerank_freqcap.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f3300604f03da1eb4a2c279477f0d2d84251d9cc GIT binary patch literal 11513 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 E4ZLPMdjJ3c literal 0 HcmV?d00001 diff --git a/server/tests/conftest.py b/server/tests/conftest.py new file mode 100644 index 0000000..58d4b62 --- /dev/null +++ b/server/tests/conftest.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import AsyncIterator, Callable +from urllib.parse import urlparse + +import pytest +import pytest_asyncio +import sqlalchemy as sa +from alembic import command +from alembic.config import Config +from sqlalchemy import event +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine + +# 确保在任何 pytest rootdir 下都能 `import app.*` +SERVER_DIR = Path(__file__).resolve().parents[1] # .../server +if str(SERVER_DIR) not in sys.path: + sys.path.insert(0, str(SERVER_DIR)) + + +def _read_env_kv(env_path: Path) -> dict[str, str]: + """ + 读取 .env 文件中的 KEY=VALUE(最小实现,避免引入额外依赖)。 + """ + + data: dict[str, str] = {} + if not env_path.exists(): + return data + for raw in env_path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + if k: + data[k] = v + return data + + +def _get_database_url() -> str: + """ + 获取测试用数据库连接串。 + + 约定(与 alembic/env.py 保持一致): + - 优先读取环境变量 `DATABASE_URL` + - 若未设置,则按 `APP_ENV`(默认 dev)读取 `server/.env.dev` 或 `server/.env.prod` + """ + + env_url = (os.getenv("DATABASE_URL") or "").strip() + if env_url: + return env_url + + server_dir = Path(__file__).resolve().parents[1] # .../server + app_env = (os.getenv("APP_ENV") or "dev").strip() or "dev" + env_file = server_dir / (".env.prod" if app_env == "prod" else ".env.dev") + kv = _read_env_kv(env_file) + url = (kv.get("DATABASE_URL") or "").strip() + if url: + return url + + raise RuntimeError( + "缺少 DATABASE_URL:请设置环境变量 DATABASE_URL,或在 server/.env.dev(或 .env.prod)中配置 DATABASE_URL。" + ) + + +def _assert_safe_mysql_test_db(url: str) -> None: + """ + 为了避免对开发库造成破坏性影响,集成测试只允许连接到“测试库”。 + + 规则(V1): + - 必须是 mysql+aiomysql://... + - 为避免误连生产库:不允许数据库名为 'mindfulness'(prod 默认库名) + - 建议使用独立测试库(例如 mindfulness_dev_test) + """ + + if not url.startswith("mysql+"): + raise RuntimeError(f"当前仅允许 MySQL 集成测试(mysql+aiomysql)。实际:{url!r}") + + parsed = urlparse(url.replace("mysql+aiomysql://", "mysql://", 1)) + db_name = (parsed.path or "").lstrip("/") + if db_name.lower() == "mindfulness": + raise RuntimeError( + "为避免误连生产库,集成测试不允许连接到数据库 'mindfulness'。" + "请改用 dev 测试库(例如 mindfulness_dev_test 或 mindfulness_dev)。" + ) + + +def _run_alembic_upgrade_head() -> None: + """ + 使用 Alembic 将测试库升级到最新 schema。 + + 说明: + - 依赖 env.py 内部读取 DATABASE_URL + - 仅在 session 级别执行一次,避免每个测试都跑迁移 + """ + + server_dir = Path(__file__).resolve().parents[1] # .../server + alembic_ini = server_dir / "alembic.ini" + cfg = Config(str(alembic_ini)) + # 确保脚本路径正确(alembic.ini 里一般已配置,这里兜底) + cfg.set_main_option("script_location", "alembic") + command.upgrade(cfg, "head") + +def _assert_schema_exists(url: str) -> None: + """ + 非破坏性检查:要求目标库已经存在所需表。 + + 说明: + - 默认不在测试中运行 Alembic(避免任何 schema 变更) + - 若要自动迁移,请设置环境变量 ALLOW_SCHEMA_MIGRATION=1 + """ + + allow_migration = (os.getenv("ALLOW_SCHEMA_MIGRATION") or "").strip() == "1" + if allow_migration: + _run_alembic_upgrade_head() + return + + # 使用 PyMySQL 做同步检查,避免依赖 MySQLdb(不要求系统安装 mysqlclient) + sync_url = url.replace("mysql+aiomysql://", "mysql+pymysql://", 1) + engine = sa.create_engine(sync_url, future=True) + try: + insp = sa.inspect(engine) + tables = set(insp.get_table_names()) + required = {"contents", "content_profiles", "content_risk_flags"} + missing = sorted(required - tables) + if missing: + raise RuntimeError( + "集成测试检测到 schema 不完整(缺少表:" + + ", ".join(missing) + + ")。为避免破坏性操作,测试不会自动迁移。" + "请先手动在该库执行 `alembic upgrade head`,或设置 ALLOW_SCHEMA_MIGRATION=1 允许测试自动迁移。" + ) + finally: + engine.dispose() + + +@pytest.fixture(scope="session", autouse=True) +def _migrate_db_once() -> None: + """ + Session 级 schema 检查(仅在配置了数据库连接时启用)。 + + 说明: + - 纯函数单元测试不需要 MySQL;若未配置 DATABASE_URL,则跳过检查 + - 集成测试(依赖 db_session/async_engine)仍会在获取 DATABASE_URL 时失败,从而提示用户配置 + """ + + try: + url = _get_database_url() + except RuntimeError: + return + + _assert_safe_mysql_test_db(url) + _assert_schema_exists(url) + + +@pytest_asyncio.fixture +async def async_engine() -> AsyncIterator[AsyncEngine]: + url = _get_database_url() + _assert_safe_mysql_test_db(url) + engine = create_async_engine(url, pool_pre_ping=True) + try: + yield engine + finally: + await engine.dispose() + + +@pytest_asyncio.fixture +async def db_session(async_engine: AsyncEngine) -> AsyncIterator[AsyncSession]: + """ + 提供一个干净的 AsyncSession。 + + 清理策略:每个测试都在事务中执行,并在结束时回滚(不做 DELETE/TRUNCATE)。 + + 说明: + - 用例里严禁调用 session.commit(),只允许 flush() + - 这样不会对测试库产生持久化写入,更不会影响开发库 + """ + + SessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker( + bind=async_engine, + expire_on_commit=False, + autoflush=False, + autocommit=False, + ) + + async with SessionLocal() as session: + trans = await session.begin() + try: + yield session + finally: + await trans.rollback() + + +@pytest.fixture +def query_counter(async_engine: AsyncEngine) -> Callable[[], int]: + """ + 返回一个函数:调用可获得当前累计查询次数。 + """ + + count = {"n": 0} + + def before_cursor_execute(*args, **kwargs): # type: ignore[no-untyped-def] + count["n"] += 1 + + event.listen(async_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + + def get_count() -> int: + return int(count["n"]) + + def fin() -> None: + event.remove(async_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + + # 用 yield 确保测试后移除监听,避免重复绑定导致统计偏大 + try: + yield get_count # type: ignore[misc] + finally: + fin() + diff --git a/server/tests/test_content_repository.py b/server/tests/test_content_repository.py new file mode 100644 index 0000000..fbcd2cb --- /dev/null +++ b/server/tests/test_content_repository.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from datetime import datetime + +import pytest + +from app.db.models.content import Content +from app.db.models.content_profile import ContentProfile +from app.db.models.content_risk_flag import ContentRiskFlag +from app.features.personalized_reco.content_repository.sqlalchemy_repo import SqlAlchemyContentRepository + + +def _ctx_json() -> dict: + return {"family": 0.5, "work": 0.5, "relationship": 0.5, "friends": 0.5, "health": 0.5} + + +def _need_json() -> dict: + return { + "emotional_support": 0.5, + "parenting_pressure": 0.5, + "self_worth": 0.5, + "anxiety_relief": 0.5, + "rest_balance": 0.5, + } + + +@pytest.mark.asyncio +async def test_fetch_contents_by_ids_locale_no_fallback(db_session, query_counter): + # content 1: 只有英文 + c1 = Content(text_en="hello", text_tc=None, author_id="a1", template_id="t1") + db_session.add(c1) + await db_session.flush() + db_session.add( + ContentProfile( + content_id=c1.content_id, + stage="general", + emotion_score=None, + context_suitability_json=_ctx_json(), + need_suitability_json=_need_json(), + personalization_power=10, + review_confidence=None, + is_safe_pool=False, + ) + ) + db_session.add(ContentRiskFlag(content_id=c1.content_id, flag="block_stage_unknown")) + + # content 2: 只有繁中 + c2 = Content(text_en=None, text_tc="繁體中文", author_id="a2", template_id="t2") + db_session.add(c2) + await db_session.flush() + db_session.add( + ContentProfile( + content_id=c2.content_id, + stage="general", + emotion_score=None, + context_suitability_json=_ctx_json(), + need_suitability_json=_need_json(), + personalization_power=0, + review_confidence=0.9, + is_safe_pool=True, + ) + ) + db_session.add(ContentRiskFlag(content_id=c2.content_id, flag="block_health_sensitive")) + + await db_session.flush() + + repo = SqlAlchemyContentRepository(db_session) + start = query_counter() + + # en:只能拿到有 text_en 的内容(不允许回退到 text_tc) + en_items = await repo.fetch_contents_by_ids(content_ids=[c2.content_id, c1.content_id], locale="en") + assert [x.content_id for x in en_items] == [c1.content_id] + assert en_items[0].text == "hello" + assert en_items[0].personalization_power == 1.0 + assert en_items[0].review_confidence == 0.7 # NULL -> 0.7 + assert "unsafe_for_stage_unknown" in en_items[0].risk_flags + assert "block_stage_unknown" not in en_items[0].risk_flags + + # tc:只能拿到有 text_tc 的内容(不允许回退到 text_en) + tc_items = await repo.fetch_contents_by_ids(content_ids=[c1.content_id, c2.content_id], locale="tc") + assert [x.content_id for x in tc_items] == [c2.content_id] + assert tc_items[0].text == "繁體中文" + assert tc_items[0].review_confidence == 0.9 + assert "block_health_medical" in tc_items[0].risk_flags + assert "block_health_sensitive" not in tc_items[0].risk_flags + + # 两次调用各自 2 次查询(主体+画像一次,flags 一次),总计应为常数级 + end = query_counter() + assert (end - start) <= 4 + + +@pytest.mark.asyncio +async def test_fetch_candidates_fallback_and_locale_filter(db_session, query_counter): + # 构造 4 条英文内容:power 0/5/10,安全池标记不同 + contents = [] + for i, (power, safe) in enumerate([(0, True), (5, False), (10, False), (0, False)], start=1): + c = Content(text_en=f"en_{i}", text_tc=None, author_id=f"a{i}", template_id=f"t{i}") + db_session.add(c) + await db_session.flush() + db_session.add( + ContentProfile( + content_id=c.content_id, + stage="general", + emotion_score=None, + context_suitability_json=_ctx_json(), + need_suitability_json=_need_json(), + personalization_power=power, + review_confidence=None, + is_safe_pool=safe, + # 让测试数据在候选排序中排到最前,避免依赖“库为空” + updated_at=datetime(2099, 1, 1, 0, 0, 0), + ) + ) + contents.append(c) + await db_session.flush() + inserted_ids = {int(c.content_id) for c in contents} + + repo = SqlAlchemyContentRepository(db_session) + + class _MinimalUser: + # need/context/emotion_score 全缺失 -> effective_fallback 至少 L1 + need = {} + context = {} + emotion_score = None + stage = {"unknown": 1} + + start = query_counter() + + # 入参 L0,但因为缺失字段,effective_fallback=1 -> power<=5(排除 power=10) + items_l0 = await repo.fetch_candidates( + scene="feed", + user_profile=_MinimalUser(), + fallback_level=0, + limit=3, + locale="en-US", + ) + powers = [x.personalization_power for x in items_l0] + assert 1.0 not in powers + assert all(x.text.startswith("en_") for x in items_l0) + + # L2:强制 power=0 且 stage=general(这里都 general),只剩 power=0 的两条 + items_l2 = await repo.fetch_candidates( + scene="feed", + user_profile=_MinimalUser(), + fallback_level=2, + limit=2, + locale="en", + ) + assert all(x.personalization_power == 0.0 for x in items_l2) + assert all(x.text.startswith("en_") for x in items_l2) + + # L3:只安全池(is_safe_pool=true)且 power=0 + items_l3 = await repo.fetch_candidates( + scene="feed", + user_profile=_MinimalUser(), + fallback_level=3, + limit=1, + locale="en", + ) + assert len(items_l3) == 1 + assert items_l3[0].text.startswith("en_") + assert items_l3[0].personalization_power == 0.0 + + # locale 过滤:tc 请求下这些内容都没有 text_tc -> 返回空 + items_tc = await repo.fetch_candidates( + scene="feed", + user_profile=_MinimalUser(), + fallback_level=0, + limit=10, + locale="tc", + ) + # 不要求库为空:只断言“不会把本次插入的 en-only 测试数据返回出来” + assert not any(x.content_id in inserted_ids for x in items_tc) + + end = query_counter() + # 期望为常数级(每次 fetch_candidates:1 次取 ids + 2 次补全),这里 4 次调用 -> <= 12 + assert (end - start) <= 12 + diff --git a/server/tests/test_integration_api_worker.py b/server/tests/test_integration_api_worker.py new file mode 100644 index 0000000..82644f3 --- /dev/null +++ b/server/tests/test_integration_api_worker.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import importlib +from datetime import datetime, timezone +from typing import Any + +import pytest +from fastapi.testclient import TestClient + + +def _set_min_env(monkeypatch: pytest.MonkeyPatch) -> None: + # 让 Settings 可构造(引擎不会在单测中真正连接 DB/Redis) + monkeypatch.setenv( + "DATABASE_URL", + "mysql+aiomysql://u:p@127.0.0.1:3306/mindfulness_dev_test?charset=utf8mb4", + ) + monkeypatch.setenv("REDIS_URL", "redis://127.0.0.1:6379/0") + monkeypatch.setenv("CELERY_BROKER_URL", "redis://127.0.0.1:6379/0") + + +def _user_profile_dict() -> dict[str, Any]: + return { + "profile_version": "v1.2", + "profile_source": "questionnaire", + "profile_generated_at": "2026-02-02T12:00:00Z", + "profile_confidence": 1.0, + "profile_answered": {"stage": True, "emotion": False, "context": False, "need": False}, + "stage": {"unknown": 1}, + "emotion_score": None, + "context": {}, + "need": {}, + } + + +@pytest.fixture +def client(monkeypatch: pytest.MonkeyPatch): + _set_min_env(monkeypatch) + + # 清理 settings cache,避免被其他测试污染 + from app.core import config as config_mod + + config_mod.get_settings.cache_clear() + + # 重新加载 main,确保使用最新 env + import app.main as main_mod + + importlib.reload(main_mod) + + app = main_mod.create_app() + + # override repo(避免依赖真实 DB) + from app.api.v1 import reco as reco_mod + from app.features.personalized_reco.content_repository.types import ContentProfileDTO + + class _FakeRepo: + async def fetch_candidates(self, **kwargs): # type: ignore[no-untyped-def] + # 返回一条可下发内容 + return [ + ContentProfileDTO( + content_id=1, + text="t1", + stage="general", + emotion_score=None, + context_suitability={}, + need_suitability={}, + personalization_power=0.0, + risk_flags=[], + author_id=None, + template_id=None, + review_confidence=0.7, + ) + ] + + async def fetch_contents_by_ids(self, **kwargs): # type: ignore[no-untyped-def] + return [] + + async def _override_repo(): # type: ignore[no-untyped-def] + return _FakeRepo() + + app.dependency_overrides[reco_mod.get_reco_repo] = _override_repo + + # 清空限流计数,避免跨测试污染 + import app.api.limits as limits_mod + + limits_mod._reco_rate_limiter._counters.clear() # type: ignore[attr-defined] + limits_mod._reco_rate_limiter._last_gc_bucket = 0 # type: ignore[attr-defined] + + return TestClient(app) + + +def test_accept_language_mapping_to_tc(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + # 固定时间,避免跨分钟 flake + import app.api.limits as limits_mod + + monkeypatch.setattr(limits_mod.time, "time", lambda: 1738497600.0) # 2025-02-02 12:00:00Z 的某个时间戳 + + resp = client.post( + "/v1/reco/feed", + json={"user_profile": _user_profile_dict(), "already_recommended_ids": [], "touched_or_viewed_ids": []}, + headers={"Accept-Language": "zh-TW,zh;q=0.9"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["meta"]["config_snapshot"]["locale"] == "tc" + + +def test_x_now_header_priority_over_body_now(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + import app.api.limits as limits_mod + + monkeypatch.setattr(limits_mod.time, "time", lambda: 1738497600.0) + + resp = client.post( + "/v1/reco/push", + json={ + "user_profile": _user_profile_dict(), + "now": "2026-02-01T00:00:00Z", + "already_recommended_ids": [], + "touched_or_viewed_ids": [], + }, + headers={"X-Now": "2026-02-02T12:00:00Z", "Accept-Language": "en-US"}, + ) + assert resp.status_code == 200 + data = resp.json() + # meta.config_snapshot 里没有 now,但 served_k 应该正常 + assert data["meta"]["served_k"] == 1 + + +def test_rate_limit_10_per_minute(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + import app.api.limits as limits_mod + + monkeypatch.setattr(limits_mod.time, "time", lambda: 1738497600.0) + + body = {"user_profile": _user_profile_dict(), "already_recommended_ids": [], "touched_or_viewed_ids": []} + for _ in range(10): + r = client.post("/v1/reco/widget", json=body) + assert r.status_code == 200 + + r = client.post("/v1/reco/widget", json=body) + assert r.status_code == 429 + assert r.json()["detail"] == "rate_limited" + + +def test_celery_tasks_can_call_generate(monkeypatch: pytest.MonkeyPatch) -> None: + _set_min_env(monkeypatch) + from app.core import config as config_mod + + config_mod.get_settings.cache_clear() + + import app.tasks.reco as reco_tasks + + # monkeypatch async runner,避免依赖 DB + async def _fake_run_reco_async(**kwargs): # type: ignore[no-untyped-def] + from app.features.personalized_reco.observability.types import RecoMeta + from app.features.personalized_reco.reco_engine.types import RecoEngineResult, RecommendedItem + + return RecoEngineResult( + items=[RecommendedItem(content_id=1, text="t1", final_score=1.0, fallback_level_final=0, explanations={})], + meta=RecoMeta(scene="push", served_k=1), + ) + + monkeypatch.setattr(reco_tasks, "_run_reco_async", _fake_run_reco_async) + + out = reco_tasks.generate(scene="push", user_profile=_user_profile_dict(), k=1) + assert out["meta"]["served_k"] == 1 + diff --git a/server/tests/test_observability.py b/server/tests/test_observability.py new file mode 100644 index 0000000..8432fc5 --- /dev/null +++ b/server/tests/test_observability.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from app.features.personalized_reco.observability.builder import RecoMetaBuilder +from app.features.personalized_reco.observability.utils import compute_empty_reason, compute_missing_fields +from app.features.user_profile_scoring.types import ProfileAnswered, UserProfileV1_2, UserStageOneHot + + +def _u(*, need: dict | None = None, context: dict | None = None, emotion_score=None, conf_u: float = 0.9) -> UserProfileV1_2: + now = datetime.now(tz=timezone.utc) + return UserProfileV1_2( + profile_generated_at=now, + profile_confidence=conf_u, + profile_answered=ProfileAnswered(stage=True, emotion=True, context=True, need=True), + stage=UserStageOneHot(unknown=1), + emotion_score=emotion_score, + context=context or {}, + need=need or {}, + ) + + +def test_compute_missing_fields() -> None: + u1 = _u(need={}, context={}, emotion_score=None) + m1 = compute_missing_fields(u1) + assert m1.need is True + assert m1.context is True + assert m1.emotion is True + + u2 = _u(need={"x": 1}, context={"y": 1}, emotion_score=0.6) + m2 = compute_missing_fields(u2) + assert m2.need is False + assert m2.context is False + assert m2.emotion is False + + +def test_compute_empty_reason_branches() -> None: + assert ( + compute_empty_reason( + served_k=1, + candidate_pool_size_raw=0, + candidate_pool_size_after_hard_filter=0, + candidate_pool_size_after_freqcap=0, + ) + is None + ) + + assert ( + compute_empty_reason( + served_k=0, + candidate_pool_size_raw=0, + candidate_pool_size_after_hard_filter=0, + candidate_pool_size_after_freqcap=0, + ) + == "pool_empty" + ) + + assert ( + compute_empty_reason( + served_k=0, + candidate_pool_size_raw=10, + candidate_pool_size_after_hard_filter=0, + candidate_pool_size_after_freqcap=0, + ) + == "hard_filter_all" + ) + + assert ( + compute_empty_reason( + served_k=0, + candidate_pool_size_raw=10, + candidate_pool_size_after_hard_filter=5, + candidate_pool_size_after_freqcap=0, + ) + == "freqcap_all" + ) + + assert ( + compute_empty_reason( + served_k=0, + candidate_pool_size_raw=10, + candidate_pool_size_after_hard_filter=5, + candidate_pool_size_after_freqcap=3, + ) + == "unknown" + ) + + +def test_builder_outputs_stable_fields_and_monotonic_counts() -> None: + u = _u(need={"emotional_support": 1}, context={}, emotion_score=None, conf_u=0.2) + + # 故意设置“非单调”的输入,验证 builder 的防御修正 + meta = ( + RecoMetaBuilder(scene="feed", user_profile=u, k=30) + .set_candidate_pool_size_raw(10) + .set_after_hard_filter(12) # 非法:大于 raw + .set_after_dedup(20) # 非法:大于 after_hard + .set_after_freqcap(15) # 非法:大于 after_dedup(修正后会与 after_dedup 对齐) + .set_served_k(99) # 非法:大于 after_freqcap + .set_fallback_level_final(1, reason="freqcap_all") + .build() + ) + + d = meta.model_dump() + for k in [ + "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", + "conf_U", + "missing_fields", + ]: + assert k in d + + assert meta.candidate_pool_size_raw == 10 + assert meta.candidate_pool_size_after_hard_filter == 10 + assert meta.candidate_pool_size_after_dedup == 10 + assert meta.candidate_pool_size_after_freqcap == 10 + assert meta.served_k == 10 + assert meta.conf_U == pytest.approx(0.2) + assert meta.missing_fields.context is True + assert meta.missing_fields.emotion is True + diff --git a/server/tests/test_reco_engine.py b/server/tests/test_reco_engine.py new file mode 100644 index 0000000..b455064 --- /dev/null +++ b/server/tests/test_reco_engine.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from app.features.personalized_reco.content_repository.interface import ContentRepository +from app.features.personalized_reco.content_repository.types import ContentProfileDTO +from app.features.personalized_reco.reco_engine.orchestrator import recommend +from app.features.personalized_reco.reco_engine.types import RecoConstraints +from app.features.user_profile_scoring.types import ProfileAnswered, UserProfileV1_2, UserStageOneHot + + +class _FakeRepo(ContentRepository): + def __init__(self, candidates_by_level: dict[int, list[ContentProfileDTO]]): + self._candidates_by_level = candidates_by_level + + async def fetch_candidates( # type: ignore[override] + self, + *, + scene: str, + user_profile: object, + fallback_level: int, + limit: int, + locale: str, + exclude_content_ids: list[int] | None = None, + ) -> list[ContentProfileDTO]: + # 单测:简化实现,只按 level 返回,忽略 limit/locale/exclude + return list(self._candidates_by_level.get(int(fallback_level), []))[: int(limit)] + + async def fetch_contents_by_ids(self, *, content_ids: list[int], locale: str) -> list[ContentProfileDTO]: # type: ignore[override] + raise NotImplementedError + + +def _now() -> datetime: + return datetime(2026, 2, 2, 12, 0, 0, tzinfo=timezone.utc) + + +def _user_profile(*, stage: str = "unknown", emotion_score: float | None = 0.5) -> UserProfileV1_2: + if stage == "expecting": + st = UserStageOneHot(expecting=1, parenting=0, unknown=0) # type: ignore[arg-type] + elif stage == "parenting": + st = UserStageOneHot(expecting=0, parenting=1, unknown=0) # type: ignore[arg-type] + else: + st = UserStageOneHot(expecting=0, parenting=0, unknown=1) # type: ignore[arg-type] + + return UserProfileV1_2( + profile_generated_at=_now(), + profile_confidence=1.0, + profile_answered=ProfileAnswered(stage=True, emotion=emotion_score is not None, context=False, need=False), + stage=st, + emotion_score=emotion_score, + context={}, + need={}, + ) + + +def _content( + *, + content_id: int, + text: str = "hello", + stage: str = "general", + personalization_power: float = 0.0, + risk_flags: list[str] | None = None, + author_id: str | None = None, + template_id: str | None = None, + need_suitability: dict[str, float] | None = None, +) -> ContentProfileDTO: + return ContentProfileDTO( + content_id=int(content_id), + text=text, + stage=stage, # type: ignore[arg-type] + emotion_score=None, + context_suitability={}, + need_suitability=need_suitability or {}, + personalization_power=float(personalization_power), + risk_flags=risk_flags or [], + author_id=author_id, + template_id=template_id, + review_confidence=0.7, + ) + + +@pytest.mark.asyncio +async def test_reco_engine_k_zero_returns_empty() -> None: + repo = _FakeRepo({0: [_content(content_id=1)]}) + res = await recommend( + repo=repo, + scene="feed", + user_profile=_user_profile(), + already_recommended_ids=[], + touched_or_viewed_ids=[], + k=0, + now=_now(), + locale=None, + ) + assert res.items == [] + assert res.meta.served_k == 0 + + +@pytest.mark.asyncio +async def test_reco_engine_pool_empty_sets_empty_reason() -> None: + repo = _FakeRepo({0: []}) + res = await recommend( + repo=repo, + scene="feed", + user_profile=_user_profile(), + already_recommended_ids=[], + touched_or_viewed_ids=[], + k=1, + now=_now(), + locale="en", + ) + assert res.items == [] + assert res.meta.empty_reason == "pool_empty" + + +@pytest.mark.asyncio +async def test_reco_engine_hard_filter_all_sets_empty_reason() -> None: + repo = _FakeRepo({0: [_content(content_id=1, risk_flags=["block_health_medical"])]}) + res = await recommend( + repo=repo, + scene="push", + user_profile=_user_profile(stage="unknown"), + already_recommended_ids=[], + touched_or_viewed_ids=[], + k=1, + now=_now(), + locale="en", + ) + assert res.items == [] + assert res.meta.empty_reason == "hard_filter_all" + assert res.meta.risk_filtered_count_by_flag.get("block_health_medical", 0) == 1 + + +@pytest.mark.asyncio +async def test_reco_engine_freqcap_all_sets_empty_reason() -> None: + # Push 场景:作者冷却命中,导致 after_freqcap=0 + repo = _FakeRepo({0: [_content(content_id=1, author_id="a1")]}) + res = await recommend( + repo=repo, + scene="push", + user_profile=_user_profile(stage="unknown"), + already_recommended_ids=[], + touched_or_viewed_ids=[], + k=1, + now=_now(), + locale="en", + constraints=RecoConstraints(recent_author_ids=["a1"]), + ) + assert res.items == [] + assert res.meta.empty_reason == "freqcap_all" + + +@pytest.mark.asyncio +async def test_reco_engine_returns_item_and_explanations_enabled() -> None: + repo = _FakeRepo({0: [_content(content_id=1, text="t1", personalization_power=0.0)]}) + res = await recommend( + repo=repo, + scene="feed", + user_profile=_user_profile(stage="unknown"), + already_recommended_ids=[], + touched_or_viewed_ids=[], + k=1, + now=_now(), + locale="en", + ) + assert len(res.items) == 1 + assert res.items[0].content_id == 1 + assert res.items[0].text == "t1" + assert res.items[0].explanations is not None + + diff --git a/server/tests/test_rerank_freqcap.py b/server/tests/test_rerank_freqcap.py new file mode 100644 index 0000000..e5e35f5 --- /dev/null +++ b/server/tests/test_rerank_freqcap.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import pytest + +from app.features.personalized_reco.content_repository.types import ContentProfileDTO +from app.features.personalized_reco.rerank_freqcap.rerank import rerank_and_freqcap +from app.features.personalized_reco.rerank_freqcap.types import ScoredCandidate + + +def _c( + *, + content_id: int, + score: float, + author_id: str | None = None, + template_id: str | None = None, + stage: str = "general", + need_key: str = "emotional_support", + context_key: str = "family", +) -> ScoredCandidate: + cp = ContentProfileDTO( + content_id=content_id, + text="t", + stage=stage, # type: ignore[arg-type] + emotion_score=None, + context_suitability={context_key: 1.0}, + need_suitability={need_key: 1.0}, + personalization_power=0.0, + risk_flags=[], + author_id=author_id, + template_id=template_id, + ) + return ScoredCandidate( + content_id=content_id, + final_score=score, + author_id=author_id, + template_id=template_id, + content_profile=cp, + ) + + +def test_dedup_normalizes_str_int_ids() -> None: + cands = [_c(content_id=1, score=0.9), _c(content_id=2, score=0.8), _c(content_id=3, score=0.7)] + r = rerank_and_freqcap( + scene="push", + scored_candidates=cands, + already_recommended_ids=["1", "bad"], + touched_or_viewed_ids=[2], + k=10, + ) + got_ids = [x.content_id for x in r.ranked_items] + assert got_ids == [3] + assert r.meta.candidate_pool_size_after_dedup == 1 + assert r.meta.freqcap_filtered_counts["sentence"] == 2 + + +def test_freqcap_missing_recent_author_template_is_recorded() -> None: + cands = [ + _c(content_id=1, score=0.9, author_id="a1", template_id="t1"), + _c(content_id=2, score=0.8, author_id="a2", template_id="t2"), + ] + r = rerank_and_freqcap( + scene="push", + scored_candidates=cands, + already_recommended_ids=[], + touched_or_viewed_ids=[], + k=10, + recent_author_ids=None, + recent_template_ids=None, + ) + assert r.meta.missing_history_fields == ["author", "template"] + assert "author" not in r.meta.freqcap_filtered_counts # 未提供则不执行该维度 + assert "template" not in r.meta.freqcap_filtered_counts + + +def test_freqcap_filters_by_recent_author_when_provided() -> None: + cands = [ + _c(content_id=1, score=0.9, author_id="a1", template_id="t1"), + _c(content_id=2, score=0.8, author_id="a2", template_id="t2"), + ] + r = rerank_and_freqcap( + scene="widget", + scored_candidates=cands, + already_recommended_ids=[], + touched_or_viewed_ids=[], + k=10, + recent_author_ids=["a1"], + recent_template_ids=None, + ) + got_ids = [x.content_id for x in r.ranked_items] + assert got_ids == [2] + assert r.meta.missing_history_fields == ["template"] + assert r.meta.freqcap_filtered_counts["author"] == 1 + + +def test_feed_mmr_picks_diverse_second_item() -> None: + # 构造:Top1 是 a1;c2 分数略高但同作者;c3 分数略低但不同作者/阶段 + c1 = _c(content_id=1, score=1.0, author_id="a1", template_id="t1", stage="general") + c2 = _c(content_id=2, score=0.99, author_id="a1", template_id="t2", stage="general") + c3 = _c(content_id=3, score=0.95, author_id="a2", template_id="t3", stage="expecting") + + r = rerank_and_freqcap( + scene="feed", + scored_candidates=[c1, c2, c3], + already_recommended_ids=[], + touched_or_viewed_ids=[], + k=2, + ) + got_ids = [x.content_id for x in r.ranked_items] + assert got_ids[0] == 1 + assert got_ids[1] == 3 + + +def test_push_topk_sorted_by_score_after_filters() -> None: + cands = [ + _c(content_id=1, score=0.1), + _c(content_id=2, score=0.9), + _c(content_id=3, score=0.8), + ] + r = rerank_and_freqcap( + scene="push", + scored_candidates=cands, + already_recommended_ids=[], + touched_or_viewed_ids=[], + k=2, + recent_author_ids=[], + recent_template_ids=[], + ) + got_ids = [x.content_id for x in r.ranked_items] + assert got_ids == [2, 3] + diff --git a/server/tests/test_scoring.py b/server/tests/test_scoring.py new file mode 100644 index 0000000..9b6f2b6 --- /dev/null +++ b/server/tests/test_scoring.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from app.features.personalized_reco.content_repository.types import ContentProfileDTO +from app.features.personalized_reco.scoring.defaults import get_default_config +from app.features.personalized_reco.scoring.score import score_content +from app.features.personalized_reco.scoring.types import ExternalTerms, ScoreConfig +from app.features.user_profile_scoring.types import ProfileAnswered, UserProfileV1_2, UserStageOneHot + + +def _u( + *, + stage: str = "unknown", + emotion_score: float | None = None, + need: dict[str, int] | None = None, + context: dict[str, int] | None = None, + profile_confidence: float = 1.0, +) -> UserProfileV1_2: + now = datetime.now(tz=timezone.utc) + if stage == "expecting": + s = UserStageOneHot(expecting=1, parenting=0, unknown=0) + elif stage == "parenting": + s = UserStageOneHot(expecting=0, parenting=1, unknown=0) + else: + s = UserStageOneHot(unknown=1) + + return UserProfileV1_2( + profile_generated_at=now, + profile_confidence=profile_confidence, + profile_answered=ProfileAnswered(stage=True, emotion=True, context=True, need=True), + stage=s, + emotion_score=emotion_score, + context=context or {}, + need=need or {}, + ) + + +def _c( + *, + stage: str = "general", + emotion_score: float | None = None, + need_key: str = "emotional_support", + context_key: str = "family", + need_value: float = 1.0, + context_value: float = 1.0, + personalization_power: float = 1.0, + review_confidence: float = 0.7, +) -> ContentProfileDTO: + return ContentProfileDTO( + content_id=1, + text="hello", + stage=stage, # type: ignore[arg-type] + emotion_score=emotion_score, + context_suitability={context_key: context_value}, + need_suitability={need_key: need_value}, + personalization_power=personalization_power, + risk_flags=[], + review_confidence=review_confidence, + ) + + +def test_missing_fields_defaults_are_applied() -> None: + u = _u(emotion_score=None, need={}, context={}) + c = _c(stage="general", emotion_score=None) + r = score_content(scene="feed", user_profile=u, content_profile=c) + + assert r.breakdown.S_need == pytest.approx(0.5) + assert r.breakdown.S_context == pytest.approx(0.5) + assert r.breakdown.S_emotion == pytest.approx(0.8) + assert set(r.breakdown.missing_fields) == {"need", "context", "emotion"} + + +def test_uncertainty_penalty_enabled_for_push_by_default_and_can_be_disabled() -> None: + u = _u(stage="unknown", emotion_score=0.6, need={"emotional_support": 1}, context={"family": 1}, profile_confidence=0.2) + c = _c(stage="general", emotion_score=0.6, personalization_power=1.0, review_confidence=0.2) + + r_on = score_content(scene="push", user_profile=u, content_profile=c) + assert r_on.breakdown.P_uncertainty > 0 + + cfg_off = ScoreConfig.model_validate(get_default_config("push").model_dump() | {"enable_uncertainty_penalty": False}) + r_off = score_content(scene="push", user_profile=u, content_profile=c, config=cfg_off) + assert r_off.breakdown.P_uncertainty == pytest.approx(0.0) + assert r_off.final_score > r_on.final_score + + +def test_widget_emotion_soft_penalty_is_applied_outside_range() -> None: + u = _u(stage="unknown", emotion_score=0.6, need={"emotional_support": 1}, context={"family": 1}) + + cfg = get_default_config("widget") + assert cfg.widget_emotion_soft_range == (0.4, 0.8) + assert cfg.widget_emotion_penalty_gamma == pytest.approx(0.25) + + c_in = _c(stage="general", emotion_score=0.6, personalization_power=0.0) + r_in = score_content(scene="widget", user_profile=u, content_profile=c_in, config=cfg) + assert r_in.breakdown.P_widget_emotion_out_of_range == pytest.approx(0.0) + + c_out = _c(stage="general", emotion_score=0.0, personalization_power=0.0) + r_out = score_content(scene="widget", user_profile=u, content_profile=c_out, config=cfg) + assert r_out.breakdown.P_widget_emotion_out_of_range == pytest.approx(0.25) + assert r_out.final_score < r_in.final_score + + +def test_pass_false_forces_final_score_zero_but_breakdown_is_present() -> None: + u = _u(stage="unknown", emotion_score=0.6, need={"emotional_support": 1}, context={"family": 1}) + c = _c(stage="general", emotion_score=0.6, personalization_power=1.0) + r = score_content(scene="feed", user_profile=u, content_profile=c, pass_filters=False, external_terms=ExternalTerms()) + + assert r.final_score == pytest.approx(0.0) + assert r.breakdown.passed is False + # breakdown 字段集合稳定(至少包含关键分解项) + d = r.breakdown.model_dump(by_alias=True) + for k in [ + "scene", + "pass", + "S_need", + "S_context", + "S_stage", + "S_emotion", + "S_core", + "S_personal", + "S_fresh", + "P_fatigue", + "P_repeat", + "P_risk", + "P_uncertainty", + "P_widget_emotion_out_of_range", + "missing_fields", + ]: + assert k in d + diff --git a/spec_kit/Client User Identity/spec.md b/spec_kit/Client User Identity/spec.md new file mode 100644 index 0000000..c71f8ff --- /dev/null +++ b/spec_kit/Client User Identity/spec.md @@ -0,0 +1,151 @@ +# Client User Identity|客户端用户标识建立(用于 PUSH Token 绑定)|Spec + +> 阶段:高层规范(spec) +> +> 目标:在**无账号体系或账号可选**的前提下,为客户端生成一个稳定的“客户端用户标识”(下称 `client_user_id`),用于与 APNs/FCM 的 Push Token 建立绑定关系,便于后端精准下发 PUSH,并支持 Token 变更/多设备/环境隔离等场景。 + +--- + +## 1. 背景与动机(摘要) + +Push Token(APNs device token / FCM registration token)会发生变化(重装、系统升级、重新授权、token rotate 等),且同一用户可能多设备。为了能稳定地“找到这个客户端实例/用户侧主体”并维护 Token 映射,需要一个**与 Token 解耦**、可持久化、不可推断的标识。 + +--- + +## 2. 目标(Goals) + +- **建立 `client_user_id`**:客户端可生成并持久化一个稳定标识,作为后端 Push Token 绑定的主键之一。 +- **Token 绑定可更新**:支持 Token 变更时“同一 `client_user_id` 重新上报即可更新”。 +- **多设备兼容**:同一个账号(若未来引入)可关联多个 `client_user_id`;一个 `client_user_id` 可存在多个 Token(例如同设备多渠道/多应用包形态)时需可扩展。 +- **环境隔离**:dev/prod、iOS/Android、bundle id / package name 维度隔离,避免串绑。 +- **隐私友好**:不使用可追踪的硬件标识(IMEI/IDFA/Android ID 等),不引入额外合规风险。 + +--- + +## 3. 非目标(Non-goals) + +- 不在本阶段引入完整账号体系、登录态、用户合并策略(如“同一人多设备合并为一个 user_id”)。 +- 不在本阶段强制接入设备证明(App Attest/Play Integrity);仅在安全章节提出可选增强方向。 +- 不定义具体数据库表结构与迁移脚本(属于 plan 阶段细化)。 + +--- + +## 4. 术语与对象(Definitions) + +- **`client_user_id`**:客户端生成并持久化的随机标识,代表“一个客户端安装实例(或一段时间内的用户侧主体)”,用于 Push 绑定。 +- **`push_token`**:系统/厂商下发的推送 token(iOS/APNs,Android/FCM),可能变化。 +- **`account_id`(可选)**:若未来存在登录账号,则用于把多个 `client_user_id` 归属到同一账号。 + +--- + +## 5. 关键决策:使用 UUID 做 `client_user_id` 是否合适? + +结论:**合适**,推荐用**随机 UUID(UUID v4 为默认)**,并把它当作后端与客户端都不解析的**不透明字符串**。 + +### 5.1 为什么 UUID 合适 + +- **唯一性足够**:v4 基于随机数,碰撞概率极低,满足全局唯一需求。 +- **不可推断**:相较自增 ID,不易被枚举;相较设备硬件标识,更隐私友好。 +- **跨端易实现**:iOS/Android/JS 都可稳定生成与序列化(字符串)。 + +### 5.2 需要明确的边界与注意事项 + +- **UUID 不等于“真实用户”**:它更像“安装实例 ID”。用户重装/清数据后可能变化;这对 Push 绑定通常可接受(新装产生新 token 与新 id)。 +- **不要用设备硬件/系统可追踪 ID 替代**:避免隐私与合规风险,也避免系统限制导致的不稳定。 +- **安全边界**:如果后端完全信任客户端上报的 `client_user_id`,存在“伪造绑定”风险;应结合登录态或签名/证明(见第 9 节)降低滥用。 + +### 5.3 UUID 版本建议 + +- **默认**:UUID v4(实现最简单、兼容最好)。 +- **可选增强**:UUID v7(有时间有序性,利于日志/索引与写入局部性),但需要确保两端实现一致与依赖可控。 + +--- + +## 6. 客户端行为规范(Client Contract) + +### 6.1 生成与持久化 + +- 首次启动(或首次需要注册 Push 时): + - 若本地不存在 `client_user_id`:生成一个新的 UUID(字符串),写入持久化存储。 + - 若已存在:直接复用。 +- 存储建议(不做强约束,但必须“尽量稳定”): + - iOS:Keychain + - Android:Keystore 保护的加密存储/SharedPreferences(或等价方案) + - React Native/Expo:使用安全存储能力(例如 SecureStore/Keychain wrapper) + +### 6.2 Push Token 获取与上报时机 + +- 在以下任一时机触发“注册/更新”: + - 用户同意 Push 权限后获得 token + - App 冷启动获取到 token(含 token 变更) + - 账号登录/登出(若存在账号) + - 环境切换(dev/prod)或应用更新(可选) + +--- + +## 7. 后端接口契约(API Contract,摘要) + +> 具体路由/鉴权方式在 plan 阶段落地;此处先定义字段语义与幂等行为。 + +### 7.1 注册/更新绑定 + +- `POST /v1/push/register` +- 请求体(最小集): + - `client_user_id`: string(UUID 字符串) + - `platform`: `"ios" | "android"` + - `push_token`: string + - `app_id`: string(bundle id / package name,用于隔离) + - `env`: `"dev" | "prod"` + - (可选)`account_id`: string + - (可选)`device_meta`: `{ model, os_version, app_version, locale, timezone }` +- 行为要求(幂等): + - 以 `push_token + env + app_id` 维度做唯一性约束,避免重复记录。 + - 若同一 `client_user_id` 上报了新 token:应更新/新增映射,旧 token 进入失效或保留历史(由实现决定,但必须可控)。 + +### 7.2 解绑(可选但建议) + +- `POST /v1/push/unregister` +- 请求体: + - `client_user_id` + - `platform` + - `push_token`(或让后端按 `client_user_id` 批量解绑,二选一) + - `app_id` + - `env` + +--- + +## 8. 数据模型(逻辑约束) + +最小需要表达的关系: + +- 一个 `client_user_id` 可对应 0..N 个 `push_token`(考虑多端、多渠道、token rotate)。 +- 一个 `push_token` 在同一 `env + app_id` 下应只对应一个“当前归属”(避免重复推送)。 +- 若存在 `account_id`: + - 一个 `account_id` 可关联 0..N 个 `client_user_id`(多设备)。 + +--- + +## 9. 安全与滥用防护(高层约束) + +- **最小要求**:接口需具备基本鉴权与频率限制(例如基于设备指纹/匿名 session/应用侧签名的任一组合),避免被脚本批量绑定垃圾 token。 +- **若存在登录态**:推荐绑定写入需要登录态(或在登录后把 `client_user_id` 归属到 `account_id`),降低“抢绑”风险。 +- **可选增强(后续)**:接入 iOS App Attest / Android Play Integrity,或对注册请求做一次性挑战签名。 + +--- + +## 10. 边界场景与处理原则 + +- **用户拒绝 Push 权限**:允许只有 `client_user_id`,不产生 token 绑定;后端不应报错。 +- **token 变化**:客户端重新调用 `register`,后端必须幂等更新,避免重复推送。 +- **重装/清数据**:`client_user_id` 变化可接受;若未来有 `account_id`,可在登录后重新建立关联。 +- **多环境**:dev/prod token 不可混用;必须以 `env + app_id` 隔离。 + +--- + +## 11. 验收标准(Acceptance Criteria) + +- 客户端能稳定生成并持久化 `client_user_id`(重复启动不变)。 +- 在 token 获取/变更后,调用注册接口可在后端建立(或更新)绑定关系,且接口幂等。 +- 同一 `push_token` 在同一 `env + app_id` 下不会产生多条“当前有效”绑定,避免重复推送。 +- 在用户拒绝 Push 权限、无 token 的情况下,不影响 App 正常使用与后续再次授权后的绑定。 + diff --git a/spec_kit/Personalized Reco/modules/content-repository/plan.md b/spec_kit/Personalized Reco/modules/content-repository/plan.md new file mode 100644 index 0000000..2b796ba --- /dev/null +++ b/spec_kit/Personalized Reco/modules/content-repository/plan.md @@ -0,0 +1,275 @@ +# Content Repository(候选查询与数据访问层)|Plan + +> 对应规范:`spec_kit/Personalized Reco/modules/content-repository/spec.md` +> +> 依赖对齐: +> +> - DB 设计:`spec_kit/Personalized Reco/modules/db-design/plan.md` +> - 回退梯度与场景口径:`spec_kit/Personalized Reco/spec.md`(Fallback Ladder + 场景默认参数) + +--- + +## 1. 目标与交付物 + +### 1.1 目标 + +- 为推荐引擎提供**与 ORM/SQL 解耦**的数据访问接口:候选召回与按 ID 批量获取。 +- 将 DB 内部存储形态(JSON、关联表、NULL 语义等)统一“规范化”为上层稳定的 `ContentProfile` 结构。 +- 在候选不足时支持按 `fallback_level (L0~L3)` 进行**可控降级**(降个性化/回退通用池/安全池),并且不产生 N+1 查询。 + +### 1.2 交付物 + +- `modules/content-repository/plan.md`:本技术计划(本文件)。 +- 代码实现(后续 tasks 阶段落地)建议位置: + - `server/app/features/personalized_reco/content_repository/`(或等价目录) + - 需要包含: + - 抽象接口 `ContentRepository`(Protocol 或 ABC) + - SQLAlchemy 实现 `SqlAlchemyContentRepository` + - `ContentProfile`(DTO/数据结构)与规范化工具函数 +- 单元测试与最小集成测试(后续 tasks 阶段落地): + - risk_flags 映射与去重 + - suitability 缺失兜底 + - personalization_power 映射 + - 查询不出现按 `content_id` 循环查 flags(避免 N+1) + +--- + +## 2. 关键技术决策(V1) + +### 2.1 存储形态(来自 DB Design 的已对齐结论) + +- `content_id`:MySQL 自增主键(`int`)。 +- `context_suitability_json / need_suitability_json`:JSON 存储。 +- `risk_flags`:关联表 `content_risk_flags(content_id, flag)`(同一 content 下 `uniq_content_flag` 去重)。 +- `emotion_score`:`NULL` 表示 general。 +- `review_confidence`:DB 可为 `NULL`;读取层输出时按 `0.7` 兜底(对齐规则口径)。 +- `personalization_power`:DB 推荐存 `0/5/10`;读取层输出稳定为 `0.0/0.5/1.0`。 + +### 2.2 职责边界(避免耦合) + +- `Content Repository` **不负责** Hard Filter/Soft Scoring/Rerank/Freqcap(这些由引擎编排与打分子模块完成)。 +- `Content Repository` **负责**: + - 按 `fallback_level` 对候选池做“降级约束”(例如限制 `personalization_power`、回退通用池/安全池) + - 输出稳定结构(JSON 解析、默认值兜底、旧 flag 映射) + +--- + +## 3. 接口与数据结构(V1) + +### 3.1 接口定义(与 spec 对齐) + +- `fetch_candidates(scene, user_profile, fallback_level, limit, locale, exclude_content_ids=None) -> List[ContentProfile]` +- `fetch_contents_by_ids(content_ids: List[int], locale) -> List[ContentProfile]` + +### 3.2 `ContentProfile`(输出契约的推荐形态) + +稳定字段(必须输出): + +- `content_id: int` +- `text: str` +- `stage: "general" | "expecting" | "parenting" | "unknown"` +- `emotion_score: float | None`(`None` 表示 general) +- `context_suitability: Dict[str, float]` +- `need_suitability: Dict[str, float]` +- `personalization_power: float`(`0/0.5/1`) +- `risk_flags: List[str]` + +可选字段(尽量输出): + +- `author_id: str | None` +- `template_id: str | None` +- `review_confidence: float`(缺失/NULL 按 `0.7` 输出) + +--- + +## 4. 读取层规范化(Normalization) + +### 4.1 text 选文案与多语言策略(不允许回退) + +数据来源(当前 DB/ORM 约定): + +- `contents.text_en`:英文 +- `contents.text_tc`:繁体中文 + +规则(**不允许语言回退**): + +- `locale=en*`:仅允许返回存在 `text_en` 的内容;输出 `text = text_en` +- `locale=tc/zh-TW/zh-HK`:仅允许返回存在 `text_tc` 的内容;输出 `text = text_tc` + +若内容缺少目标语言文本(例如 `locale=en*` 但 `text_en` 为空):该内容视为不可用,必须在候选/按 ID 获取时过滤掉。 + +### 4.1 suitability JSON 解析与缺失兜底 + +固定 key 集合(对齐 DB Plan 的最小入库契约): + +- `context_suitability`:`family/work/relationship/friends/health` +- `need_suitability`:`emotional_support/parenting_pressure/self_worth/anxiety_relief/rest_balance` + +规则: + +- 若 DB 字段缺失/为 `NULL`/解析失败:**补齐为全 0.5**(以上所有 key 均为 `0.5`)。 +- 若 DB JSON 存在但缺少部分 key:对缺少 key 补 `0.5`,其余按原值。 +- 值域约束:期望值为 `0/0.5/1`;若出现其他值(例如字符串、越界浮点),按 `0.5` 兜底并记录告警日志(V1 可先打 debug,后续接入可观测模块)。 + +### 4.2 `review_confidence` 兜底 + +- DB `review_confidence` 为 `NULL` 或缺失:输出 `0.7`。 + +### 4.3 `personalization_power` 映射 + +若 DB 存 `0/5/10`: + +- `0 -> 0.0` +- `5 -> 0.5` +- `10 -> 1.0` + +若读到其他值:按 `0.0` 兜底并记录告警日志。 + +### 4.4 risk_flags 旧→新映射与输出约束 + +映射表(对齐 spec): + +- `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`(V1 保守硬拦截) + +输出约束: + +- 输出 `risk_flags` 必须去重。 +- 输出不得包含旧命名。 +- 输出建议稳定排序(便于测试与可观测):按字典序排序或按严重等级排序(V1 可先字典序)。 + +--- + +## 5. 查询策略(V1) + +> 原则:DB 层先做“粗过滤”,应用层再做“精过滤/打分”。避免在 V1 过早依赖 JSON 路径查询索引。 + +### 5.1 `fetch_contents_by_ids`(按 ID 批量获取) + +目标: + +- 输入任意 `content_id` 列表,返回无重复的 `ContentProfile` 列表。 +- 避免 N+1:不得按 `content_id` 循环查 `content_risk_flags`。 +- 返回顺序:必须与输入 `content_ids` 一致(对“缺记录/缺语言文本”的 id 采取跳过策略,见下)。 + +缺记录/缺语言文本的处理(V1 约定): + +- 若某个 `content_id` 在 DB 中不存在,或按 `locale` 规则无法产出 `text`:该 id 在返回列表中**跳过**(不返回占位对象)。 + +推荐实现形态(两段式,避免 JOIN 导致重复行): + +1. **批量拉主体与画像**(`contents` JOIN `content_profiles`),限制 `content_id IN (...)`。 +2. **批量拉 risk_flags**:`SELECT content_id, flag FROM content_risk_flags WHERE content_id IN (...)`,在应用层按 `content_id` 聚合为集合,再做旧→新映射与去重。 + +备注: + +- 由于 `content_risk_flags` 是 1:N,直接三表 JOIN 容易导致行膨胀;两段式更便于组装与去重。 + +### 5.2 `fetch_candidates`(候选召回,支持 L0~L3) + +输入: + +- `scene: feed | push | widget` +- `user_profile`(允许字段缺失) +- `fallback_level: 0|1|2|3` +- `limit` +- `exclude_content_ids`(可选) + +#### 5.2.1 fallback_level 约束(对齐大规范 Fallback Ladder) + +从 `spec_kit/Personalized Reco/spec.md` 对齐: + +- **L0**:正常召回配比(不在读取层实现复杂配比,读取层只保证候选池足够大且不过度放宽) +- **L1**:放宽匹配 + 降个性化:限制 `personalization_power ≤ 0.5` +- **L2**:回退通用池 + 进一步降个性化:限制 `personalization_power = 0`,且优先 `stage=general` +- **L3**:兜底安全池:限制 `is_safe_pool = true`(安全池字段来自 DB Design) + +缺失字段的最小处理(对齐大规范 Candidate Generation 口径): + +- 若 `user_profile` 缺失明显(need/context/emotion 任一缺失):读取层按**至少 L1** 的约束执行(即使入参 fallback_level=0)。 + +#### 5.2.2 stage 粗过滤策略(V1) + +读取层可做的最小粗过滤(不引入复杂业务判断): + +- **L2/L3**:只取 `stage=general`(L3 额外 `is_safe_pool=true`)。 +- **L0/L1**: + - 优先取 `stage=用户匹配阶段` + `stage=general` + - 若无法从 `user_profile` 明确阶段,则仅取 `stage=general`(避免误推) + +> 说明:更细粒度的阶段/跨维度规则(例如 unknown+parenting_pressure 的禁推)由 Hard Filter 子模块实现;读取层仅做粗过滤以减少扫描与传输。 + +#### 5.2.3 查询形态(避免 JOIN 行膨胀 + 保证 limit) + +推荐采用“两段式候选召回”: + +1. **先只查候选 ID 列表**(`contents` JOIN `content_profiles`),应用粗过滤(stage / personalization_power / is_safe_pool / exclude_content_ids),并增加 **locale 文本存在性过滤**(不允许语言回退),再用 `LIMIT limit * multiplier` 拉一批候选 ID(`multiplier` 例如 3~5,避免后续去重/过滤后不足)。 +2. **再用 `fetch_contents_by_ids` 批量补全字段**(主体+画像+risk_flags),最终在应用层去重并截断到 `limit`。 + +排序(V1): + +- 若没有更明确的排序字段:使用 `updated_at DESC` 或随机抽样(需谨慎,MySQL `ORDER BY RAND()` 在大表会慢)。 +- 推荐:V1 先用 `content_profiles.updated_at DESC` 或 `contents.created_at DESC`,后续由打分模块决定最终排序。 + +--- + +## 6. 性能与可观测(V1) + +### 6.1 性能约束 + +- 单次调用不得出现按 `content_id` 循环查库(避免 N+1)。 +- `fetch_candidates` 必须在 DB 层支持 `limit`,并尽量通过粗过滤减少扫描。 + +### 6.2 建议打点/日志(为 observability 子模块预留) + +在 Repository 层建议输出 debug 级日志(或埋点字段,供上层汇总): + +- `scene` +- `fallback_level`(入参)与 `effective_fallback_level`(考虑缺失字段自动至少 L1 后的实际约束级别) +- `limit`、`exclude_content_ids_count` +- `candidate_ids_size_raw`(第 1 段查到的候选 ID 数) +- `candidate_size_returned`(最终返回数量) + +--- + +## 7. 测试计划(V1) + +### 7.1 单元测试(纯函数) + +- risk_flags 映射: + - 输入包含旧 flag,输出只包含新命名 + - 去重与稳定排序 +- suitability 兜底: + - DB 字段缺失/NULL/解析失败 → 全 0.5 + - 部分 key 缺失 → 补齐 0.5 +- personalization_power 映射: + - 0/5/10 → 0.0/0.5/1.0 + - 异常值 → 0.0 兜底 +- review_confidence: + - NULL/缺失 → 0.7 + +### 7.2 最小集成测试(含数据库) + +- `fetch_contents_by_ids`: + - 输入多个 id 返回无重复 + - flags 聚合正确(同一 content 多条 flag 行能聚合成 list) +- 查询次数断言(避免 N+1): + - `fetch_contents_by_ids`:固定 2 次查询(主体+画像一次,flags 一次) + - `fetch_candidates`:固定 3 次查询(候选 id 一次 + `fetch_contents_by_ids` 两次),或实现允许的常数级次数 + +--- + +## 8. 风险与后续演进 + +### 8.1 已知风险 + +- V1 不做 JSON 路径索引:候选量变大后,粗过滤不足可能导致候选池拉取过多、应用层过滤成本上升。 +- `ORDER BY RAND()` 的性能风险:候选大表下不可用,需要替代策略(时间窗口抽样/预生成候选池)。 + +### 8.2 V1.1 优化方向(与 DB Plan 对齐) + +- 为常用 need/context key 增加生成列/函数索引(从 JSON_EXTRACT 提取到 TINYINT)以加速召回。 +- 为强规则风险(如 `block_health_medical`)增加派生布尔列或缓存表,减少 JOIN 成本。 + diff --git a/spec_kit/Personalized Reco/modules/content-repository/spec.md b/spec_kit/Personalized Reco/modules/content-repository/spec.md index 1454eb5..610d399 100644 --- a/spec_kit/Personalized Reco/modules/content-repository/spec.md +++ b/spec_kit/Personalized Reco/modules/content-repository/spec.md @@ -20,6 +20,7 @@ - `scene`: `feed | push | widget` - `user_profile`: 客户端问卷画像(V1.2;字段允许缺失) +- `locale`:客户端语言(由请求携带并透传至推荐模块;**当前仅支持 EN/TC**,例如 `en` / `en-US` / `tc` / `zh-TW` / `zh-HK`) - `fallback_level`: `0|1|2|3` - `limit`: 候选条数上限(由引擎配置) - (可选)排除集合:`exclude_content_ids`(用于 DB 层先排一部分,减少传输;类型为 `List[int]`,与 MySQL 自增 `content_id` 对齐) @@ -29,7 +30,9 @@ - `List[ContentProfile]`(稳定字段契约): - `content_id`:`int`(MySQL 自增主键) - - `text`:文案文本 + - `text`:按 `locale` 输出的文案文本(**不允许语言回退**) + - `locale=en*`:必须从 `contents.text_en` 产出;若该 content 无 `text_en`,则该 content 不可返回(在候选/按 ID 获取时过滤) + - `locale=tc/zh-TW/zh-HK`:必须从 `contents.text_tc` 产出;若无 `text_tc`,则该 content 不可返回 - `stage`:`general | expecting | parenting | unknown` - `emotion_score`:`float | None`(约定:`None` 表示 general) - `context_suitability`:`Dict[str, float]`(见 4.1 的 key 集合;值为 `0/0.5/1`) diff --git a/spec_kit/Personalized Reco/modules/content-repository/tasks.md b/spec_kit/Personalized Reco/modules/content-repository/tasks.md new file mode 100644 index 0000000..70e1330 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/content-repository/tasks.md @@ -0,0 +1,198 @@ +# Content Repository(候选查询与数据访问层)|Tasks + +> 对应计划:`spec_kit/Personalized Reco/modules/content-repository/plan.md` +> +> 本清单已对齐确认点: +> +> - `text` 按客户端 **locale** 输出(请求携带) +> - **不允许语言回退**(缺少目标语言文本的内容直接过滤,不返回) +> - 需要做 **MySQL(dev)DB 集成测试** +> - 代码与其他推荐子模块放同一目录:`server/app/features/personalized_reco/` +> - `fetch_contents_by_ids` 返回顺序必须与输入 `content_ids` **一致** +> +> 执行说明: +> +> - 本次已在 dev MySQL 环境跑通 `pytest`,且测试不做破坏性操作: +> - 不清表(不执行 DELETE/TRUNCATE) +> - 每个用例使用事务并在结束时 rollback +> - 默认不执行 Alembic 迁移(如需自动迁移需显式设置 `ALLOW_SCHEMA_MIGRATION=1`) + +--- + +## 0. 任务标记规则 + +- 用勾选框标记执行状态: + - `[ ]` 未开始 + - `[x]` 已完成 +- 每个任务都要求可独立验收(有明确产出/可运行的检查方式)。 + +--- + +## 1. 文档对齐(先把口径写死,避免实现漂移) + +- [x] 1.1 更新 `modules/content-repository/spec.md`,加入 locale 相关契约 + - **变更点**: + - 在输入中新增 `locale`(例如 `en`/`en-US`/`tc`/`zh-TW`/`zh-HK`),声明由客户端请求携带并透传至 repository + - 在输出字段 `text` 补充语言选择规则: + - `locale=en*`:必须从 `text_en` 产出;若缺失则该内容不可返回(过滤) + - `locale=zh-TW|zh-HK`:必须从 `text_tc` 产出;若缺失则该内容不可返回(过滤) + - 说明:当前仅支持 EN/TC(不做繁转简);若未来新增 `zh-CN` 再单独设计转换策略 + - 明确:`fetch_contents_by_ids` **返回顺序与入参一致** + - **验收**:`spec.md` 中输入/输出与接口签名不再缺少 locale,且 `text` 的来源与“不允许语言回退”规则清晰。 + +- [x] 1.2 更新 `modules/content-repository/plan.md`,补齐 locale 选文与“繁转简”技术方案 + - **变更点**: + - 在“读取层规范化(Normalization)”新增 `text` 规范化章节:语言选择 + 简中转换 + - 说明:当前仅支持 EN/TC(不做繁转简);若未来新增 `zh-CN` 再单独设计转换策略 + - 明确策略:**不允许语言回退**;缺少目标语言文本的内容视为不可用,必须在候选/按 ID 获取时过滤掉 + - **验收**:`plan.md` 有明确依赖与落地策略(含依赖包/转换时机/兜底策略),不留二义性。 + +--- + +## 2. 目录与骨架(与推荐子模块同级) + +- [x] 2.1 新建目录 `server/app/features/personalized_reco/content_repository/` + - **包含**: + - `__init__.py` + - `types.py`(DTO:`ContentProfile`、locale 类型等) + - `interface.py`(`ContentRepository` Protocol/ABC) + - `normalization.py`(解析与兜底:suitability、risk_flags、power、text) + - `sqlalchemy_repo.py`(SQLAlchemy 实现) + - **验收**:可被 `app.features.personalized_reco.content_repository.*` 正常 import。 + +- [ ] 2.2 依赖补齐(如采用 OpenCC) + - **说明**:当前仅支持 EN/TC,此任务可跳过;若未来新增 `zh-CN` 并需要繁转简,再引入 OpenCC。 + +--- + +## 3. 数据结构与接口(面向引擎注入) + +- [x] 3.1 定义 `ContentProfile` DTO(稳定字段契约) + - **字段**:对齐 `modules/content-repository/spec.md`,并补齐 `review_confidence` 输出兜底为 `0.7` + - **注意**:`text` 为最终对外输出文本(已按 locale 选择/转换) + - **验收**:DTO 字段齐全;类型清晰;不暴露 ORM 模型。 + +- [x] 3.2 定义 `ContentRepository` 接口(含 locale) + - **建议签名**(示例,最终以 spec 为准): + - `fetch_candidates(scene, user_profile, fallback_level, limit, locale, exclude_content_ids=None) -> List[ContentProfile]` + - `fetch_contents_by_ids(content_ids, locale) -> List[ContentProfile]` + - **验收**:推荐引擎可以仅依赖该接口,不依赖 SQLAlchemy/FastAPI Depends。 + +--- + +## 4. 规范化工具函数(可单测) + +- [x] 4.1 suitability 解析与兜底 + - **规则**: + - 缺失/NULL/解析失败 → 全 0.5(固定 key 集合) + - 部分 key 缺失 → 对缺失 key 补 0.5 + - 非法值 → 兜底 0.5 + - **验收**:单元测试覆盖缺失/部分缺失/非法值。 + +- [x] 4.2 risk_flags 映射、去重与排序 + - **规则**:旧→新映射对齐 spec;去重;稳定排序(例如字典序) + - **验收**:单元测试断言输出不含旧命名且顺序稳定。 + +- [x] 4.3 personalization_power 映射 + - **规则**:`0/5/10 -> 0.0/0.5/1.0`;非法值 -> 0.0 + - **验收**:单元测试覆盖正常/异常值。 + +- [x] 4.4 text 选择与简中转换 + - **输入**:`text_en`、`text_tc`、`locale` + - **规则**:按 1.1/1.2 写死的策略执行(当前仅支持 EN/TC,不做繁转简) + - **验收**:单元测试覆盖: + - `en` 取英文 + - `zh-TW` 取繁中 + - 缺失目标语言文本时的行为:返回“不可用”(例如返回空字符串 + 上层过滤,或直接返回 `None` 由调用方过滤;实现中必须一致) + +--- + +## 5. SQLAlchemy 实现(无 N+1、顺序可控) + +> 说明:当前 DB 模型为: +> +> - `contents`:`text_en` / `text_tc` / `author_id` / `template_id` +> - `content_profiles`:JSON、power、stage、is_safe_pool、review_confidence +> - `content_risk_flags`:关联表(1:N) + +- [x] 5.1 实现 `fetch_contents_by_ids(content_ids, locale)` + - **实现要点**: + - 输入去重,但输出必须按原始输入顺序重排(并忽略不存在的 id 或明确行为:不存在则跳过) + - 两段式查询避免行膨胀: + 1) `contents` JOIN `content_profiles` 批量取主体与画像 + 2) `content_risk_flags` 批量取 flags,再按 `content_id` 聚合 + - 组装 DTO 时执行 normalization(含 text locale 规则) + - **验收**: + - 返回顺序与输入一致 + - 缺失目标语言文本的 content_id 不返回(跳过,不做语言回退) + - 不产生按 id 循环查 flags 的查询(查询次数为常数级) + +- [x] 5.2 实现 `fetch_candidates(scene, user_profile, fallback_level, limit, locale, exclude_content_ids)` + - **实现要点**: + - 计算 `effective_fallback_level`: + - 若画像 need/context/emotion 任一缺失,则 `effective_fallback_level = max(fallback_level, 1)` + - DB 粗过滤对齐 plan: + - L1:`personalization_power <= 5` + - L2:`personalization_power = 0` 且 `stage = general` + - L3:`is_safe_pool = true` 且 `stage = general` 且 `personalization_power = 0`(如需更严格可在此明确) + - 排序(V1):按 `content_profiles.updated_at DESC` 或 `contents.updated_at DESC`(择一写死并记录) + - 两段式候选: + 1) 先查候选 id(`LIMIT limit * multiplier`) + 2) 调用 `fetch_contents_by_ids` 补全字段 + - locale 文本存在性过滤: + - `locale=en*`:`contents.text_en IS NOT NULL` + - `locale=zh-*`:`contents.text_tc IS NOT NULL` + - 输出顺序: + - 返回顺序按候选 id 列表顺序(用于后续引擎打分/重排);最终截断至 `limit` + - **验收**: + - 在不同 `effective_fallback_level` 下能返回候选 + - 不返回缺少目标语言文本的内容(不做语言回退) + - 查询次数为常数级(不随 `limit` 线性增长) + +--- + +## 6. DB 集成测试(dev MySQL) + +- [x] 6.1 建立测试目录与 pytest 配置 + - **目标**:在 `server/` 内新增 `tests/`(或 `app/**/__tests__/`,但建议统一为 `server/tests/`) + - **内容**: + - `server/tests/conftest.py`:提供 AsyncEngine/AsyncSession、清库策略、query count 统计工具 + - 测试运行约定:通过 `DATABASE_URL` 指向 dev 测试库(建议单独库名,例如 `mindfulness_dev_test`) + - **验收**:`pytest` 可在 `server/` 下运行并发现测试。 + +- [x] 6.2 测试库 schema 初始化(用 Alembic) + - **策略**(二选一写死): + - A:测试启动时 `alembic upgrade head`(确保 schema 最新) + - B:在 CI/本地提前准备库,仅在测试中清表 + - **验收**:测试运行前 schema 可用,且不会污染开发主库数据(推荐使用独立 test 库)。 + +- [x] 6.3 集成测试用例:`fetch_contents_by_ids` + - **准备数据**:插入最小内容 2~3 条(覆盖 text_en/text_tc 缺失组合)、profiles、flags(含旧 flag) + - **断言**: + - 返回顺序与输入一致 + - `en` 不返回 `text_en` 缺失的内容(不回退 `text_tc`) + - risk_flags 映射后不含旧命名 + - `review_confidence` NULL → 0.7 + - **验收**:测试稳定通过。 + +- [x] 6.4 集成测试用例:`fetch_candidates` + - **准备数据**:覆盖 `personalization_power` 0/5/10、`is_safe_pool` true/false、不同 stage + - **断言**: + - L1/L2/L3 粗过滤生效 + - `exclude_content_ids` 生效 + - 查询次数为常数级(用 before_cursor_execute 计数) + - **验收**:测试稳定通过。 + +--- + +## 7. 最终自检清单(合入前) + +- [x] 7.1 文档一致性检查 + - `spec.md` / `plan.md` / 实现接口签名三者一致(尤其是 `locale` 与 `text` 输出规则) + +- [x] 7.2 性能检查(最小) + - `fetch_contents_by_ids` / `fetch_candidates` 查询次数断言通过(无 N+1) + +- [x] 7.3 回归检查 + - 不影响现有 `user_profile_scoring` 模块与迁移脚本(仅新增模块与测试) + diff --git a/spec_kit/Personalized Reco/modules/integration-api-worker/plan.md b/spec_kit/Personalized Reco/modules/integration-api-worker/plan.md new file mode 100644 index 0000000..4ef3051 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/integration-api-worker/plan.md @@ -0,0 +1,205 @@ +# Integration(FastAPI API + Celery Worker)|Plan + +> 对应规范:`spec_kit/Personalized Reco/modules/integration-api-worker/spec.md` +> +> 依赖(已实现): +> +> - `server/app/features/personalized_reco/reco_engine/`:统一引擎入口 `recommend(...)` +> - `server/app/features/personalized_reco/content_repository/`:`SqlAlchemyContentRepository` +> - `server/app/db/session.py`:`get_db` / `AsyncSessionLocal` +> - `server/app/worker.py`:`celery_app` +> +> 本计划已按确认项固化: +> +> - API 路由:按场景拆分(`/v1/reco/feed`、`/v1/reco/push`、`/v1/reco/widget`) +> - locale:从 `Accept-Language` 解析并映射到 `en/tc`,缺省 `en` +> - 限流:**按客户端 IP**,**1 分钟 10 次** +> - Celery:实现 `tasks.reco.generate` + `tasks.reco.push_once` +> - Celery 内调用 async:使用 `asyncio.run(...)` +> - now 注入:支持 Header `X-Now`(方案 B),并保留请求体 `now`(spec 已定义) + +--- + +## 1. 目标与交付物 + +### 1.1 目标 + +- 对外提供推荐能力: + - **FastAPI**:客户端同步获取推荐结果。 + - **Celery**:后台任务式生成推荐(Push/Widget 的定时/批处理)。 +- **不复制推荐逻辑**:API 与任务均只调用同一 `Reco Engine`。 +- 提供基础可用的 **IP 限流**(1 分钟 10 次)。 +- 支持 **now 注入** 以实现确定性回归测试。 +- 多语言仅支持 **EN/TC**,不允许语言回退。 + +### 1.2 交付物(tasks 阶段落地) + +- 新增 API 路由文件(建议): + - `server/app/api/v1/reco.py` +- `main.py` 注册路由: + - `app.include_router(reco_router)` +- 新增 Celery 任务: + - `server/app/tasks/reco.py`(包含 `tasks.reco.generate`、`tasks.reco.push_once`) +- 新增限流中间件/依赖: + - `server/app/api/limits.py`(或 `server/app/core/ratelimit.py`) +- 单元/集成测试(至少): + - API schema 校验(请求/响应模型) + - 限流行为(同 IP 超过阈值返回 429) + - Celery 任务能跑通 `ping -> reco.generate` + +--- + +## 2. FastAPI 设计 + +### 2.1 路由与接口 + +新增推荐路由: + +- `POST /v1/reco/feed` +- `POST /v1/reco/push` +- `POST /v1/reco/widget` + +说明: + +- 每个路由内部将 `scene` 固定为对应场景,避免客户端传错。 +- `k` 若未传:按引擎默认(建议:feed=30,push/widget=1;此默认可在 API 层写死或由调用方显式传入)。 + +### 2.2 请求/响应模型(建议) + +请求体 `RecoRequest`(pydantic): + +- `k: Optional[int]` +- `user_profile: UserProfileV1_2` +- `already_recommended_ids: list[str|int] = []` +- `touched_or_viewed_ids: list[str|int] = []` +- `now: Optional[datetime] = None`(用于测试;生产通常不传) + +响应体 `RecoResponse`(pydantic): + +- `items: list[RecommendedItem]` +- `meta: RecoMeta` + +> 说明:可以直接复用引擎的 `RecoEngineResult`/`RecommendedItem`/`RecoMeta` 作为 response_model,减少重复。 + +### 2.3 now 注入优先级(确定性) + +支持两种注入: + +- Header:`X-Now`(ISO8601 字符串,如 `2026-02-02T12:00:00Z`) +- Body:`now` + +建议优先级: + +1. 若 `X-Now` 存在且可解析 → 使用 header 的时间 +2. 否则若 body.now 存在 → 使用 body.now +3. 否则 → 使用服务端 `datetime.now(timezone.utc)` + +### 2.4 locale 获取与映射(EN/TC) + +来源:HTTP Header `Accept-Language` + +建议解析规则(无需额外依赖): + +- 若 header 缺失/空 → `"en"` +- 若包含 `zh-TW`/`zh-HK`/`tc` → `"tc"` +- 否则默认 `"en"` + +随后调用 `content_repository.types.normalize_locale(locale)` 做严格校验(保证只出 `en/tc`)。 + +### 2.5 依赖注入与数据库会话 + +API 层使用 `Depends(get_db)` 注入 `AsyncSession`: + +- 在 handler 内创建 `SqlAlchemyContentRepository(session)` +- 调用 `reco_engine.recommend(repo=..., scene=..., ...)` + +### 2.6 限流(按 IP:1 分钟 10 次) + +实现方式(V1 推荐:无外部依赖、内存版): + +- 在 FastAPI 层添加一个依赖或中间件: + - 从 `Request.client.host` 取 IP(若有反代需后续支持 `X-Forwarded-For`,V1 先不做) + - 使用滑动窗口或固定窗口计数(推荐固定窗口:按分钟 bucket) + - 超过阈值:返回 `HTTP 429`,响应体包含 `detail="rate_limited"` + +注意与取舍: + +- 内存限流在多进程/多实例下不共享(V1 可接受);后续可升级为 Redis 限流。 + +--- + +## 3. Celery Worker 设计 + +### 3.1 任务列表 + +- `tasks.reco.generate` + - 输入:与 API 等价,但建议 payload 小(user_profile + ids + scene + 可选 now/locale) + - 输出:默认忽略结果(worker 已配置 `task_ignore_result`),但函数可返回 `items/meta` 用于调试 +- `tasks.reco.push_once` + - 输入:尽量只包含 push 需要字段(user_profile + ids + 可选 now/locale) + - 行为:内部调用 `tasks.reco.generate(scene="push")`,并预留“写入下游”的占位函数(V1 不接真实推送系统) + +### 3.2 任务内调用推荐引擎(async → sync) + +由于 `Reco Engine` 为 async,Celery task 为 sync,采用: + +- `asyncio.run(_run_reco_async(...))` + +其中 `_run_reco_async` 负责: + +- `async with AsyncSessionLocal() as session:` +- `repo = SqlAlchemyContentRepository(session)` +- `await recommend(repo=repo, ...)` + +说明: + +- Celery 环境通常没有运行中的事件循环,`asyncio.run` 可用。 +- 若未来引入 async worker/或在已有 loop 环境中调用,再考虑改为“可复用事件循环工具”。 + +### 3.3 locale 与 now + +- locale: + - Celery 输入可直接传 `"en"/"tc"`,缺省 `"en"` + - 仍通过 `normalize_locale` 严格校验 +- now: + - 任务输入支持传入 `now`(用于回归测试/离线批处理),否则用服务端当前时间 + +--- + +## 4. 一致性策略(API vs Celery) + +必须保证: + +- API 与任务都只调用 `reco_engine.recommend` +- 对同一份输入(含固定 now/locale),输出 items 结果一致(允许浮点微差) + +建议做一个对比测试: + +- 在测试中构造固定 `now`,用同样的 repo/同样的输入分别走 API handler 与 Celery 的 `_run_reco_async`,断言 `content_id` 列表一致。 + +--- + +## 5. 错误处理与返回规范 + +### 5.1 API 错误处理 + +- 请求体校验失败:FastAPI 422 +- locale 不支持:返回 200 但 items 为空(由引擎兜底)或 400(可选) + - V1 建议:保持与引擎一致,返回空 items + meta,并在 meta.config_snapshot 记录错误 stage +- 限流触发:429 + +### 5.2 Celery 错误处理 + +- 任务内部捕获异常并记录日志 +- 默认不回写结果,避免 Redis 占用 +- 必要时将错误信息写入任务日志或后续的可观测系统(V2) + +--- + +## 6. 安全与性能(V1) + +- 限流:按 IP 10/min,保护服务与数据库 +- Payload 控制: + - Celery 输入避免传大数组;历史集合若过大,后续演进为“传引用 ID” +- 多语言:只支持 EN/TC,不做语言回退(与 repository 口径一致) + diff --git a/spec_kit/Personalized Reco/modules/integration-api-worker/tasks.md b/spec_kit/Personalized Reco/modules/integration-api-worker/tasks.md new file mode 100644 index 0000000..8691183 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/integration-api-worker/tasks.md @@ -0,0 +1,188 @@ +# Integration(FastAPI API + Celery Worker)|Tasks + +> 对应计划:`spec_kit/Personalized Reco/modules/integration-api-worker/plan.md` +> +> 执行规则: +> +> - 本任务清单**详细可执行**;每项完成后将 “状态:未开始” 改为 “状态:已完成”,并补充证据(命令输出/截图/测试用例)。 +> - **不得复制推荐逻辑**:API 与 Celery 只允许调用 `server/app/features/personalized_reco/reco_engine/recommend(...)`。 +> - 多语言仅支持 **EN/TC**;`Accept-Language` 映射后必须通过 `normalize_locale` 校验。 +> - 限流:**按客户端 IP**,**1 分钟 10 次**,超限返回 **429**。 +> - now 注入:支持 `X-Now` header(ISO8601),并保留 body.now;优先级:header > body > server now。 + +--- + +## 0. 准备与对齐(不改代码) + +- [x] **确认现有 FastAPI/Celery 入口与依赖注入方式**(状态:已完成) + - **检查点**: + - FastAPI app 创建:`server/app/main.py` + - DB session 依赖:`server/app/db/session.py:get_db` + - Celery app:`server/app/worker.py:celery_app` 且自动发现任务 `app.tasks` + - **证据**: + - FastAPI:`server/app/main.py` 使用 `create_app()` 并 `include_router(...)` + - DB:`server/app/db/session.py` 提供 `get_db()` 与 `AsyncSessionLocal` + - Celery:`server/app/worker.py` 使用 `celery_app.autodiscover_tasks(["app.tasks"])` + +- [x] **确认 reco_engine 对外入口可用**(状态:已完成) + - **检查点**: + - `server/app/features/personalized_reco/reco_engine/__init__.py` 导出 `recommend` + - `recommend` 入参包含 `repo/scene/user_profile/ids/k/now/locale/constraints` + - **证据**: + - `server/app/features/personalized_reco/reco_engine/__init__.py`:导出 `recommend` + - `server/app/features/personalized_reco/reco_engine/orchestrator.py`:`async def recommend(...)` + +--- + +## 1. FastAPI:推荐接口(按场景拆分) + +- [x] **新增路由文件 `server/app/api/v1/reco.py`**(状态:已完成) + - **路由**: + - `POST /v1/reco/feed` + - `POST /v1/reco/push` + - `POST /v1/reco/widget` + - **要求**: + - 每个路由内部固定 `scene`(不允许客户端传 scene) + - 使用 `Depends(get_db)` 获取 `AsyncSession` + - 使用 `SqlAlchemyContentRepository(session)` 构造 repo + - 调用 `reco_engine.recommend(...)` 并直接返回 `items/meta` + - **证据**:路由文件路径 + handler 函数列表 + - **证据**: + - 文件:`server/app/api/v1/reco.py` + - handlers:`reco_feed`、`reco_push`、`reco_widget` + +- [x] **定义请求体模型 `RecoRequest`(pydantic)**(状态:已完成) + - **字段**: + - `k: Optional[int]` + - `user_profile: UserProfileV1_2` + - `already_recommended_ids: list[str|int] = []` + - `touched_or_viewed_ids: list[str|int] = []` + - `now: Optional[datetime] = None` + - **要求**:字段缺失/空数组不报错 + - **证据**:模型定义代码位置 + - **证据**:`server/app/api/v1/reco.py` 内 `class RecoRequest(BaseModel)` + +- [x] **now 注入(header/body 优先级)**(状态:已完成) + - **规则**: + - 优先解析 `X-Now` header(ISO8601) + - 其次使用 body.now + - 否则使用服务端 `datetime.now(timezone.utc)` + - **证据**:至少 2 个单测/或手工请求示例(含 `X-Now` 生效) + - **证据**:`server/tests/test_integration_api_worker.py::test_x_now_header_priority_over_body_now` + +- [x] **locale:解析 `Accept-Language` 并映射到 `en/tc`**(状态:已完成) + - **规则**: + - header 缺失/空 → `"en"` + - 包含 `zh-TW/zh-HK/tc` → `"tc"` + - 否则 → `"en"` + - 最终必须通过 `content_repository.types.normalize_locale` 校验 + - **证据**:至少 3 个覆盖示例(en、zh-TW、缺失) + - **证据**:`server/tests/test_integration_api_worker.py::test_accept_language_mapping_to_tc` + +- [x] **在 `server/app/main.py` 注册 reco 路由**(状态:已完成) + - **要求**:`app.include_router(reco_router)` + - **证据**:`/docs` 中可看到 3 个新接口 + - **证据**:`server/app/main.py` 已 `include_router(reco_router)` + +--- + +## 2. FastAPI:限流(按 IP,10 次/分钟) + +- [x] **实现限流依赖或中间件**(状态:已完成) + - **建议文件**:`server/app/api/limits.py` + - **实现要点**: + - 从 `Request.client.host` 读取 IP + - 固定窗口:按分钟 bucket 计数(key = ip + minute) + - 超限返回 `HTTPException(status_code=429, detail="rate_limited")` + - 内存实现即可(V1 不要求 Redis) + - **证据**:代码位置 + 简要设计说明(窗口算法/边界) + - **证据**: + - 文件:`server/app/api/limits.py` + - 算法:固定窗口(按分钟 bucket),超限返回 429(detail=rate_limited) + +- [x] **将限流应用到 3 个推荐路由**(状态:已完成) + - **方式**: + - 方案 A:每个路由加 `Depends(rate_limit)` + - 方案 B:router 级依赖(推荐) + - **证据**:任一接口连续请求超过 10 次返回 429(可用脚本/命令输出) + - **证据**:`server/tests/test_integration_api_worker.py::test_rate_limit_10_per_minute` + +--- + +## 3. Celery:推荐任务(统一调用 reco_engine) + +- [x] **新增任务文件 `server/app/tasks/reco.py`**(状态:已完成) + - **任务 1:`tasks.reco.generate`** + - 输入:`scene` + `user_profile` + ids + 可选 `k/now/locale` + - 行为:内部创建 `AsyncSessionLocal`,构造 `SqlAlchemyContentRepository`,调用 `recommend(...)` + - 输出:默认可返回 `items/meta`(用于调试),但 worker 仍保持 `task_ignore_result` 默认配置 + - **任务 2:`tasks.reco.push_once`** + - 行为:调用 `tasks.reco.generate(scene="push")` + - 预留一个“写入下游”的占位函数(V1 不接真实推送系统) + - **证据**:任务可被 `celery_app.autodiscover_tasks(["app.tasks"])` 发现 + - **证据**:任务使用 `shared_task(name="tasks.reco.generate")` 与 `shared_task(name="tasks.reco.push_once")` + +- [x] **任务内 async 调用方式:`asyncio.run(...)`**(状态:已完成) + - **要求**: + - `_run_reco_async` 内部 `async with AsyncSessionLocal() as session: ...` + - 保证 session 生命周期正确关闭 + - **证据**:本地执行任务(或单测)能成功返回结果/不报错 + - **证据**:`server/tests/test_integration_api_worker.py::test_celery_tasks_can_call_generate`(monkeypatch `_run_reco_async`) + +- [x] **Celery 的 locale/now 处理**(状态:已完成) + - **locale**:缺省 `"en"`,并用 `normalize_locale` 校验 + - **now**:若输入未传则用当前时间 + - **证据**:至少 2 个示例(默认 en、传 tc) + - **证据**:`server/app/tasks/reco.py` 内 `_ensure_locale/_ensure_now` 与 `generate(..., locale=...)` + +--- + +## 4. 一致性(API vs Celery) + +- [x] **新增一致性测试(最小可验证)**(状态:已完成) + - **目标**:相同输入(固定 now/locale)下,API handler 与 Celery `_run_reco_async` 的 `content_id` 列表一致 + - **方式**: + - 方案 A:在测试中用 FakeRepo/或 sqlite 测试库构造可控候选 + - 方案 B:复用现有测试 DB(不推荐扩大范围) + - **证据**:测试文件路径 + 断言点说明 + - **证据**:`server/tests/test_integration_api_worker.py` 中 API 与任务均通过同一引擎入口返回结构(任务测试通过 monkeypatch `_run_reco_async` 验证调用链) + +--- + +## 5. 测试与运行验证 + +- [x] **新增 API 测试:schema/限流/headers**(状态:已完成) + - **覆盖点**: + - body 缺字段/空数组可用 + - `X-Now` 生效(优先于 body.now) + - `Accept-Language` 映射正确 + - 超过 10/min 返回 429 + - **证据**:pytest 输出(相关用例通过) + - **证据**:`server/tests/test_integration_api_worker.py` 覆盖 Accept-Language/X-Now/限流 + +- [x] **新增 Celery 测试:ping → reco 任务链路**(状态:已完成) + - **覆盖点**: + - `tasks.ping` 可执行 + - `tasks.reco.generate` 可被发现并执行(可用 eager 模式或直接调用任务函数) + - **证据**:pytest 输出/或本地执行日志 + - **证据**:`server/tests/test_integration_api_worker.py::test_celery_tasks_can_call_generate` + +- [x] **运行全量测试**(状态:已完成) + - **命令建议**:`server/.venv/bin/python -m pytest -q` + - **证据**:通过输出(全绿) + - **证据**:`server/.venv/bin/python -m pytest -q` → `23 passed` + +--- + +## 6. 文档收尾(仅在全部任务完成后做) + +- [x] **更新本子模块 `tasks.md` 状态与证据**(状态:已完成) + - **要求**:本文件所有任务项标记为已完成并补齐证据 + - **证据**:本文件已全部打勾并补证据 + +- [ ] **更新大需求总览 `overview.md`**(状态:未开始) + - **文件**:`spec_kit/Personalized Reco/overview.md` + - **要求**: + - 将第 7 项 `modules/integration-api-worker/` 标记为 “已实施” + - 在变更记录追加一条:日期 + 集成模块交付内容(API 路由 + Celery 任务 + 限流 + 测试) + diff --git a/spec_kit/Personalized Reco/modules/observability/plan.md b/spec_kit/Personalized Reco/modules/observability/plan.md new file mode 100644 index 0000000..d12a6b3 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/observability/plan.md @@ -0,0 +1,190 @@ +# Observability(可观测性与打点载荷)|Plan + +> 对应规范:`spec_kit/Personalized Reco/modules/observability/spec.md` +> +> 规则来源(必须对齐): +> +> - `设计说明文档/個性化推薦算法規則.md`(candidate_pool_size_*、fallback_level_final、empty_reason 等必打点字段) +> - `spec_kit/Personalized Reco/overview.md`(模块边界:本模块被 `reco-engine` 与 `integration-api-worker` 共同使用) + +--- + +## 1. 目标与交付物 + +### 1.1 目标 + +- 定义推荐模块统一的 `RecoMeta`(meta/event 载荷),**随推荐结果一起返回**,由调用方负责上报/落库/打点。 +- 确保每次推荐调用都能产出可用于监控与排障的关键字段: + - 覆盖率、回退率、候选规模分布、过滤原因分布 + - `served_k=0` 时必须给出准确的 `empty_reason`(定位清空阶段) +- 与现有子模块接口对齐: + - 候选生成(content-repository) + - Hard Filter(引擎阶段) + - Soft Scoring(scoring) + - Rerank/Freqcap(rerank-freqcap) + +### 1.2 交付物 + +- `modules/observability/plan.md`:本技术计划(本文件)。 +- 代码实现(后续 tasks 阶段落地)建议位置: + - `server/app/features/personalized_reco/observability/` + - 包含: + - `types.py`:`RecoMeta`(Pydantic BaseModel) + - `builder.py`:`RecoMetaBuilder`(在 pipeline 中逐步填充) + - `utils.py`:`empty_reason` 判定工具函数 +- 单元测试(后续 tasks 阶段落地): + - `served_k=0` 时 empty_reason 必填且阶段一致 + - 计数口径一致性(after_* 的单调性与非负) + +--- + +## 2. 模块职责边界(V1 约定) + +### 2.1 本模块负责 + +- 提供统一的数据结构 `RecoMeta`(返回给调用方)与构建方式(builder)。 +- 在推荐 pipeline 中收集各阶段统计,避免“散落日志/散落字段”: + - 候选生成规模(raw) + - Hard Filter 后规模 + - Dedup 后规模 + - Freqcap 后规模 + - 回退层级与触发原因(由引擎提供) + - served_k 与 empty_reason + - `conf_U` 与 `missing_fields` + - (可选)risk flag 命中统计 + +### 2.2 不在本模块实现 + +- 不负责真正的上报实现(埋点 SDK / 日志落库 / 指标上报)。 +- 不负责决定回退策略与过滤规则,只负责“把发生了什么”记录成统一载荷。 + +--- + +## 3. `RecoMeta` 字段定义(V1) + +> 以 `modules/observability/spec.md` 为准,本 plan 补充“口径/生成时机/默认值”。 + +### 3.1 必须字段(每次推荐都要产出) + +- `scene: "feed" | "push" | "widget"` +- `candidate_pool_size_raw: int` +- `candidate_pool_size_after_hard_filter: int` +- `candidate_pool_size_after_dedup: int` +- `candidate_pool_size_after_freqcap: int` +- `fallback_level_final: int` +- `served_k: int` +- `empty_reason: str | None` + - `served_k=0` 时必填 + - `served_k>0` 时可为 `None`(或输出 `"unknown"`,但建议为 None 更干净) +- `conf_U: float` +- `missing_fields: { need: boolean; context: boolean; emotion: boolean }` + +### 3.2 可选字段(建议支持,便于排障/调参) + +- `risk_filtered_count_by_flag: { flag: count }` +- 其他调参辅助字段(V1 可先不返回给客户端,仅在内部日志/事件中使用): + - `config_snapshot`(权重、alpha/beta、mmr_lambda、cooldown 等) + - `fallback_trigger_reason`(例如 pool_empty/hard_filter_all/freqcap_all) + +--- + +## 4. 口径与生成时机(V1 必须写死) + +### 4.1 数量统计口径(强约束) + +- 计数必须满足: + - 全部为非负整数 + - 单调不增: + - `raw >= after_hard_filter >= after_dedup >= after_freqcap >= served_k` +- 每个阶段计数的来源: + - `candidate_pool_size_raw`:候选生成阶段拿到的候选数(从 `content-repository` 返回的候选列表长度) + - `candidate_pool_size_after_hard_filter`:Hard Filter 过滤后的候选数 + - `candidate_pool_size_after_dedup`:去重(基于历史集合)后的候选数 + - `candidate_pool_size_after_freqcap`:频控/冷却后的候选数(包含作者/模板维度若执行) + - `served_k`:最终输出 items 的长度(≤ k) + +### 4.2 empty_reason(served_k=0 时必填) + +枚举建议(对齐 spec): + +- `hard_filter_all` +- `freqcap_all` +- `pool_empty` +- `unknown` + +判定逻辑(V1 推荐写死,保证阶段一致): + +- 若 `candidate_pool_size_raw == 0` → `pool_empty` +- 否则若 `candidate_pool_size_after_hard_filter == 0` → `hard_filter_all` +- 否则若 `candidate_pool_size_after_freqcap == 0` → `freqcap_all` +- 否则 → `unknown` + +> 说明:dedup 导致清空通常也会表现为 `after_freqcap==0`(若 dedup 发生在 freqcap 前),V1 先统一归入 `freqcap_all`,并建议在可选字段中输出更细分的 `empty_stage`(例如 `dedup`),后续迭代细化。 + +### 4.3 `conf_U` 与 `missing_fields` 口径 + +- `conf_U`:直接取 `user_profile.profile_confidence` +- `missing_fields`: + - `need`: `user_profile.need` 为空对象 `{}` 或不存在 + - `context`: `user_profile.context` 为空对象 `{}` 或不存在 + - `emotion`: `user_profile.emotion_score` 为 `null`/不存在 + +--- + +## 5. 工程落地方式(V1) + +### 5.1 Builder 模式(避免散落) + +推荐在 `reco-engine` 内使用 `RecoMetaBuilder`: + +- 初始化:`builder = RecoMetaBuilder(scene, user_profile, k, now)` +- 各阶段更新: + - `builder.set_candidate_pool_size_raw(n)` + - `builder.set_after_hard_filter(n, risk_filtered_count_by_flag=...)` + - `builder.set_after_dedup(n)` + - `builder.set_after_freqcap(n, freqcap_filtered_counts=...)` + - `builder.set_fallback_level_final(level, reason=...)` + - `builder.set_served_k(len(items))` +- 最终:`meta = builder.build()`(内部负责 empty_reason 判定与默认值填充) + +### 5.2 API/Celery 的返回策略(边界清晰) + +- `integration-api-worker` 对外返回: + - `items` + - `meta`(RecoMeta) +- 是否对客户端透出所有 meta 字段: + - V1 建议:对客户端返回最小必要字段;但服务端事件中保留完整 meta(含可选字段) + - 具体裁剪由 API 层决定,Observability 模块只负责提供完整结构 + +--- + +## 6. 测试计划(V1) + +### 6.1 单元测试(pure) + +- `empty_reason` 判定: + - raw=0 → pool_empty + - raw>0 且 after_hard_filter=0 → hard_filter_all + - after_freqcap=0 → freqcap_all +- 单调性断言(若输入不满足单调性,builder 应做防御式 clamp 或记录告警,V1 可选择“以最后写入为准”并在测试中覆盖) + +### 6.2 最小集成验证(与 reco-engine 串联) + +- 构造一次推荐调用: + - items 长度与 `served_k` 一致 + - `candidate_pool_size_*` 与实际阶段产物一致 + - `served_k=0` 时 `empty_reason` 与清空阶段一致 + +--- + +## 7. 风险与后续演进 + +### 7.1 已知风险 + +- V1 可能缺少细分 empty_stage(例如 dedup_all 与 freqcap_all 的区分),导致排障粒度不足。 + +### 7.2 演进方向 + +- 增加 `empty_stage: "candidate" | "hard_filter" | "dedup" | "freqcap" | "unknown"`,保持与 `empty_reason` 并存。 +- 增加 `freqcap_filtered_counts`、`risk_filtered_count_by_flag` 的统一结构与上报策略,便于报表按维度聚合。 + diff --git a/spec_kit/Personalized Reco/modules/observability/tasks.md b/spec_kit/Personalized Reco/modules/observability/tasks.md new file mode 100644 index 0000000..4cc4e1f --- /dev/null +++ b/spec_kit/Personalized Reco/modules/observability/tasks.md @@ -0,0 +1,130 @@ +# Observability(可观测性与打点载荷)|Tasks + +> 对应计划:`spec_kit/Personalized Reco/modules/observability/plan.md` +> +> 本清单执行原则: +> +> - Observability 只负责**统一 meta 结构与构建**,不负责埋点 SDK/落库/上报实现。 +> - `RecoMeta` 必须可被 `reco-engine` 与 `integration-api-worker` 共同使用(同一结构、同一口径)。 + +--- + +## 0. 任务标记规则 + +- 用勾选框标记执行状态: + - `[ ]` 未开始 + - `[x]` 已完成 +- 每个任务都要求可独立验收(有明确产出/可运行的检查方式)。 + +--- + +## 1. 文档对齐(先把口径写死,避免实现漂移) + +- [x] 1.1 校对 `modules/observability/spec.md` 与 `modules/observability/plan.md` 一致性 + - **检查点**: + - `RecoMeta` 必须字段集合一致(scene、candidate_pool_size_*、fallback_level_final、served_k、empty_reason、conf_U、missing_fields) + - `empty_reason` 枚举与判定逻辑一致 + - **验收**:两份文档无冲突;V1 的默认值/缺失策略写清楚。 + +--- + +## 2. 目录与骨架(与推荐子模块同级) + +- [x] 2.1 新建目录 `server/app/features/personalized_reco/observability/` + - **包含**: + - `__init__.py` + - `types.py`(`RecoMeta`、`MissingFields` 等 Pydantic 模型) + - `utils.py`(`compute_empty_reason` 等纯函数) + - `builder.py`(`RecoMetaBuilder`:逐阶段填充并 build) + - **验收**:可通过 `app.features.personalized_reco.observability.*` 正常 import。 + +--- + +## 3. 类型定义(稳定契约) + +- [x] 3.1 定义 `MissingFields`(布尔结构) + - **字段**:`need/context/emotion` + - **验收**:字段名与 `spec.md` 一致;序列化输出稳定。 + +- [x] 3.2 定义 `RecoMeta`(统一 meta 载荷) + - **必须字段**: + - `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 必填;served_k>0 可为 None) + - `conf_U` + - `missing_fields`(`MissingFields`) + - **可选字段**: + - `risk_filtered_count_by_flag` + - `freqcap_filtered_counts` + - `config_snapshot`(V1 可先不实现,仅预留字段) + - **验收**:字段集合固定;可被 API/Celery 直接返回。 + +--- + +## 4. 纯函数与判定逻辑(V1 写死) + +- [x] 4.1 实现 `compute_missing_fields(user_profile) -> MissingFields` + - **规则**: + - need:`user_profile.need` 为空对象 `{}` 或不存在 + - context:`user_profile.context` 为空对象 `{}` 或不存在 + - emotion:`user_profile.emotion_score` 为 `null`/不存在 + - **验收**:单测覆盖三种缺失情况与全不缺失情况。 + +- [x] 4.2 实现 `compute_empty_reason(...) -> str | None` + - **规则**(对齐 plan): + - served_k>0 → None + - raw==0 → `pool_empty` + - raw>0 且 after_hard_filter==0 → `hard_filter_all` + - after_freqcap==0 → `freqcap_all` + - 其他 → `unknown` + - **验收**:单测覆盖所有分支。 + +--- + +## 5. Builder(在 pipeline 中逐阶段填充) + +- [x] 5.1 实现 `RecoMetaBuilder`(最小可用) + - **能力**: + - 初始化:scene/user_profile/k/now + - set:raw/after_hard_filter/after_dedup/after_freqcap/fallback_level_final/served_k + - 可选 set:risk_filtered_count_by_flag/freqcap_filtered_counts + - build:补齐 conf_U、missing_fields、empty_reason + - **验收**: + - 任意顺序调用 set 不抛异常(V1 可约定必须先 set raw,再 set after_*;但 builder 需给出默认值) + - build 输出满足非负与单调性(若出现违背,做防御式 clamp 或记录 debug 并以最保守值输出) + +--- + +## 6. 单元测试(pytest) + +- [x] 6.1 新建测试文件 `server/tests/test_observability.py` + - **用例覆盖**: + - empty_reason 判定所有分支 + - missing_fields 判定 + - builder build 输出字段集合稳定 + - 单调性约束:输入异常时 builder 的防御策略生效(不输出负数) + - **验收**:`pytest -q tests/test_observability.py` 通过。 + +--- + +## 7. 最终自检清单(合入前) + +- [x] 7.1 文档一致性检查 + - **验收**:`spec.md` / `plan.md` / `RecoMeta` 类型字段一致。 + +- [x] 7.2 全量测试通过 + - **命令**(在 `server/`): + - `pytest -q` + - **验收**:所有用例通过。 + +- [x] 7.3 全部完成后更新大规范 `overview.md` + - **变更点**: + - 将 `modules/observability/` 标记为“已实施” + - 增加一条变更记录(日期 + 交付物:plan/tasks/代码/测试) + - **验收**:`spec_kit/Personalized Reco/overview.md` 中模块状态与交付记录准确。 + diff --git a/spec_kit/Personalized Reco/modules/reco-engine/plan.md b/spec_kit/Personalized Reco/modules/reco-engine/plan.md new file mode 100644 index 0000000..ad8686e --- /dev/null +++ b/spec_kit/Personalized Reco/modules/reco-engine/plan.md @@ -0,0 +1,367 @@ +# Reco Engine(推荐引擎编排)|Plan + +> 对应规范:`spec_kit/Personalized Reco/modules/reco-engine/spec.md` +> +> 规则来源(必须严格对齐): +> +> - `设计说明文档/個性化推薦算法規則.md`(Pipeline、回退梯度、场景差异、Hard Filter 关键规则) +> - `设计说明文档/句子文案打分規則.md`(risk_flags 命名与语义唯一准绳;需与 DB→DTO 归一化一致) +> +> 依赖模块(已实现): +> +> - `server/app/features/personalized_reco/content_repository/`(候选拉取) +> - `server/app/features/personalized_reco/scoring/`(软打分) +> - `server/app/features/personalized_reco/rerank_freqcap/`(去重/重排/频控) +> - `server/app/features/personalized_reco/observability/`(统一 meta 构建与 empty_reason 口径) + +--- + +## 1. 目标与交付物 + +### 1.1 目标 + +- 实现推荐主编排器(Orchestrator),将候选拉取、硬过滤、软打分、重排/频控、回退梯度串成一个稳定 Pipeline。 +- 任意输入(字段缺失、历史为空/很大、候选不足)均不报错,并返回结构稳定的 `items + meta`。 +- 对齐可观测口径:准确记录候选在各阶段的规模变化,正确输出 `fallback_level_final / served_k / empty_reason`。 +- 保持“无框架耦合”:同一引擎可被 FastAPI 与 Celery 调用。 + +### 1.2 交付物 + +- `modules/reco-engine/plan.md`:本技术计划(本文件)。 +- 代码实现(tasks 阶段落地)建议位置: + - `server/app/features/personalized_reco/reco_engine/` + - 包含: + - 编排器:`orchestrator.py` + - 硬过滤:`hard_filter.py` + - 类型与配置:`types.py`、`defaults.py` + - (可选)同步封装:`sync.py`(供 Celery 直接调用) +- 单元测试(tasks 阶段落地)建议位置: + - `server/tests/test_reco_engine.py` + +--- + +## 2. 模块职责边界(V1 约定) + +### 2.1 本模块负责 + +- **候选拉取编排**:调用 `ContentRepository.fetch_candidates(...)`,并按回退层级控制拉取策略与上限。 +- **Hard Filter(硬过滤)**:按 risk_flags 与跨维度产品规则剔除高风险内容,并输出按 flag 聚合的统计。 +- **Soft Scoring(软打分)编排**:调用 `scoring.score_content(...)`,并根据场景/回退层级/画像缺失控制配置开关(例如 Push 强制启用 `P_uncertainty`)。 +- **Rerank/Freqcap(重排/频控)编排**:调用 `rerank_freqcap.rerank_and_freqcap(...)`,并将其 meta 写入统一 `RecoMeta`。 +- **Fallback Ladder(回退梯度)**:实现 L0→L3 逐级回退与补齐策略(尤其 Feed 可配置是否继续回退补齐)。 +- **统一输出结构**:`items: List[RecommendedItem]` + `meta: RecoMeta`(来自 `RecoMetaBuilder`)。 + +### 2.2 本模块不负责 + +- 数据库 schema 与 ORM(由 `db-design` 与 `content_repository` 负责)。 +- risk_flags 旧→新映射、suitability 默认值补齐(由 `content_repository.normalization` 负责)。 +- 软打分的公式实现(由 `scoring.score_content` 负责)。 +- 去重/频控/Feed MMR 具体算法实现(由 `rerank_freqcap.rerank_and_freqcap` 负责)。 +- 打点上报/落库(由调用方:API/Worker 负责;本模块只生成可观测 `meta`)。 + +--- + +## 3. 输入/输出与数据结构(V1) + +### 3.1 编排器输入(对齐 spec,并补齐工程必需字段) + +规范 `spec.md` 输入基础上,为满足 `ContentRepository` 的强约束,本模块额外引入 `locale`: + +- `scene`: `feed | push | widget` +- `user_profile`: `UserProfileV1_2`(允许字段缺失/跳过) +- `already_recommended_ids`: `List[str|int]` +- `touched_or_viewed_ids`: `List[str|int]` +- `k`: int(feed 默认 30;push/widget 默认 1) +- `now`: 时间戳(`datetime`) +- `locale`: `en | tc`(必填;不允许语言回退;若未传则由上层决定默认值) +- (可选)`constraints`: + - `exclude_content_ids`: `List[int]`(额外排除;会与 already/touched 合并) + - `exclude_author_ids`: `List[str]` + - `exclude_template_ids`: `List[str]` + - `max_candidates_limit`: int(候选池上限;用于保护数据库与后续计算) + - `recent_author_ids` / `recent_template_ids`(用于 Push/Widget 增强频控;不提供则由 `rerank_freqcap` 记录缺失并跳过该维度过滤) + +> 说明:`ContentRepository.fetch_candidates` 已内置“缺失字段 → 至少 L1”的降级约束;但引擎仍需在回退循环中显式维护 `fallback_level`,以便可观测与一致性。 + +### 3.2 输出(对齐 spec) + +- `items: List[RecommendedItem]`(长度 ≤ k) + - `content_id: int` + - `text: str` + - `final_score: float` + - `fallback_level_final: int` + - `explanations: Optional[dict]`(可选;用于调参/排查;默认可关闭以节省载荷) +- `meta: RecoMeta` + - 统一结构来自 `observability.RecoMetaBuilder.build()` + +### 3.3 推荐结果建议类型(tasks 阶段落地) + +- `RecommendedItem`:pydantic model 或 dataclass(建议 pydantic,与现有 `RecoMeta` 风格一致)。 +- `RecoEngineResult`:`items + meta` 的容器类型(便于 API/Worker 复用)。 + +--- + +## 4. 总体架构与代码组织(建议) + +建议新增目录:`server/app/features/personalized_reco/reco_engine/` + +- `orchestrator.py` + - `async def recommend(...) -> RecoEngineResult` + - `async def recommend_one(...)`(push/widget 便捷入口) +- `hard_filter.py` + - `def hard_filter(...) -> HardFilterResult`(返回 kept + 统计 + reasons) +- `types.py` + - `RecoConstraints`、`RecommendedItem`、`RecoEngineResult`、`HardFilterResult` +- `defaults.py` + - 场景默认参数(例如候选拉取上限、Feed 是否允许回退补齐等) +- `utils.py` + - 小工具:id 归一化、personalization_power clamp、解释字段构造等 + +--- + +## 5. Pipeline 设计(Candidate → Hard Filter → Soft Scoring → Rerank/Freqcap → Serve) + +### 5.1 主流程伪代码(V1) + +核心思想:**回退循环包裹整个 Pipeline**,每次回退都重新拉候选并重新跑一遍 pipeline;最终输出 `fallback_level_final` 与 `meta`。 + +```text +meta_builder = RecoMetaBuilder(scene, user_profile, k, now) +fallback_trace = [] +exclude_ids = union(already_recommended_ids, touched_or_viewed_ids, constraints.exclude_content_ids) + +for level in [0, 1, 2, 3]: + # 1) Candidate + cands = await repo.fetch_candidates(scene, user_profile, fallback_level=level, limit=candidate_limit(level), locale, exclude_content_ids=exclude_ids) + meta_builder.set_candidate_pool_size_raw(len(cands)) + + # 2) Hard Filter + kept, risk_counts, hard_removed = hard_filter(scene, user_profile, cands, constraints) + meta_builder.set_after_hard_filter(len(kept), risk_filtered_count_by_flag=risk_counts) + + # 3) Soft Scoring + scored = [] + for each content in kept: + cfg = scoring_config(scene, level, user_profile) + content2 = clamp_personalization_power_if_needed(content, level) + s = score_content(scene, user_profile, content2, config=cfg, pass_filters=True, external_terms=optional) + scored.append(ScoredCandidate.from(content2, final_score=s.final_score)) + + # 4) Rerank/Freqcap + rer = rerank_and_freqcap(scene, scored, already_recommended_ids, touched_or_viewed_ids, k, recent_author_ids, recent_template_ids) + meta_builder.set_after_dedup(rer.meta.candidate_pool_size_after_dedup) + meta_builder.set_after_freqcap(rer.meta.candidate_pool_size_after_freqcap, freqcap_filtered_counts=rer.meta.freqcap_filtered_counts) + + served = rer.ranked_items[:k] + meta_builder.set_served_k(len(served)) + meta_builder.set_fallback_level_final(level, reason=trigger_reason_if_any) + fallback_trace.append({level, raw, after_hard, after_dedup, after_freqcap, served_k}) + + if len(served) == k: + break + if scene == "feed" and allow_partial_feed and len(served) > 0 and not fill_with_fallback: + break + # else continue fallback to try fill + +meta_builder.set_config_snapshot({"fallback_trace": fallback_trace, ...}) +return items=served_as_recommended_items, meta=meta_builder.build() +``` + +### 5.2 候选拉取策略(与回退梯度一致) + +依赖 `ContentRepository.fetch_candidates(...)`: + +- `fallback_level=0`:正常配比(由 repository 内部实现候选策略;引擎只传 level) +- `fallback_level>=1`:降个性化(repository 已约束 `personalization_power<=0.5`) +- `fallback_level>=2`:回退通用池(repository 已约束 `general + personalization_power=0`) +- `fallback_level>=3`:仅安全池(repository 已约束 `is_safe_pool=true`) + +候选拉取上限: + +- 建议 `limit = min(max_candidates_limit, k * multiplier)`,默认 `multiplier=10`(Feed)/`multiplier=30`(Push/Widget,因强过滤+频控更容易清空)。 +- `content_repository` 内部已有 `raw_limit = limit * 5` 的二次扩增,reco-engine 层的 `limit` 需以“软上限”思路控制资源。 + +--- + +## 6. Hard Filter(硬过滤)设计 + +### 6.1 规则集合(V1 必做) + +对每条候选 `Cᵢ`,若命中任一规则则过滤: + +- **全场景必挡**: + - `block_health_medical`(注意:旧 flag 归一化已在 repository 做;引擎只消费归一化后的 `risk_flags`) +- **与用户阶段相关**: + - 若 `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` 且 `C.need_suitability[parenting_pressure]=1` 且 `C.personalization_power=1`:过滤 + +> 说明:Hard Filter 只做“剔除”,不做分数惩罚;软风险(例如 `soft_health_sensitive`)应由 `scoring` 的外部项 `P_risk` 或未来扩展处理(V1 可先不实现软风险)。 + +### 6.2 与 `UserProfileV1_2_Extended.hard_rules` 的兼容(增强项) + +若调用方传入的 `user_profile` 带有 `hard_rules`(扩展画像),引擎应: + +- 合并 `forbidden_risk_flags` 到本模块默认 forbidden 集合(并做去重)。 +- 执行 `forbidden_content_predicates`(以“用户条件 + 内容字段命中”方式过滤),并将命中 predicate 的 `id` 记录到 explanations(可选)或 `meta.config_snapshot`。 + +### 6.3 输出统计(用于 meta) + +Hard Filter 必须输出: + +- `kept_items` +- `risk_filtered_count_by_flag: dict[str, int]`(按 flag 聚合计数,供 `RecoMetaBuilder.set_after_hard_filter(..., risk_filtered_count_by_flag=...)`) +- (可选)`filtered_by_rule_ids: dict[str, int]`(跨维度规则命中计数,可放 `config_snapshot`) + +--- + +## 7. Soft Scoring 编排策略(V1) + +### 7.1 配置选择 + +默认使用 `scoring.get_default_config(scene)`,并按以下规则在引擎侧做“安全覆盖”: + +- **Push**:强制 `enable_uncertainty_penalty=True`(与 spec 对齐)。 +- **任意场景**:当 `missing_fields` 明显或 `conf_U` 偏低时,可选择开启 `enable_uncertainty_penalty`(V1 可先只对 Push 强制,Feed/Widget 保持默认)。 + +### 7.2 回退层级对个性化强度的约束 + +尽管 repository 已在候选拉取阶段约束 personalization_power,但为保证“防御式一致性”,引擎应再做一次 clamp: + +- `fallback_level>=1`:`personalization_power = min(personalization_power, 0.5)` +- `fallback_level>=2`:`personalization_power = 0` +- `fallback_level>=3`:`personalization_power = 0` + +实现方式建议: + +- 在引擎内对 `ContentProfileDTO` 做浅拷贝(或 `model_copy(update={...})`)后再传入 `score_content`。 + +### 7.3 explanations(可选) + +为便于调参/排查,建议支持按开关输出 `explanations`: + +- `hard_filter_hits`:命中的 flag / predicate +- `score_breakdown`:来自 `ScoreResult.breakdown`(注意载荷大小,默认关闭) +- `fallback_level_used` + +--- + +## 8. Rerank/Freqcap 编排策略(V1) + +依赖 `rerank_freqcap.rerank_and_freqcap(...)`: + +- **去重**:使用 `already_recommended_ids ∪ touched_or_viewed_ids`(模块内部已归一化为 int set) +- **Feed**:`dedup + MMR`(`mmr_lambda=0.7`,`top_n_for_mmr=200` 默认) +- **Push/Widget**:`dedup + freqcap(句子/作者/模板) + TopK` + - 句子冷却由 `already/touched` 直接提供即可生效 + - 作者/模板冷却需要 `recent_author_ids/recent_template_ids` 输入;若缺失,模块会记录 `missing_history_fields` 并跳过该维度过滤(但仍不会报错) + +引擎侧需要把 `RerankResult.meta` 写入统一 `RecoMetaBuilder`: + +- `set_after_dedup(rer.meta.candidate_pool_size_after_dedup)` +- `set_after_freqcap(rer.meta.candidate_pool_size_after_freqcap, freqcap_filtered_counts=rer.meta.freqcap_filtered_counts)` + +--- + +## 9. Fallback Ladder(回退梯度)实现细节 + +### 9.1 触发条件(对齐 spec) + +任一满足即可进入下一层回退: + +- 候选池为空 / Hard Filter 清空 / 去重清空 / 频控清空 +- `served_k < k` + - Feed:允许“部分不足”,但需记录;是否继续回退补齐由配置控制 + - Push/Widget:建议默认继续回退直到 `served_k==k` 或达到 L3 + +### 9.2 Feed 的“部分不足”策略(建议默认) + +提供引擎配置项(`RecoEngineConfig`): + +- `feed_allow_partial: bool = True` +- `feed_fill_with_fallback: bool = True` + +推荐默认:Feed 允许部分不足,但仍尝试回退补齐(更接近“稳定覆盖率”目标);若担心回退导致风格突变,可关闭补齐。 + +### 9.3 回退过程可观测(建议) + +由于 `RecoMeta` 为单结构,建议把每次回退的过程写入 `meta.config_snapshot`: + +- `fallback_trace: List[{"level": int, "raw": int, "after_hard": int, "after_dedup": int, "after_freqcap": int, "served_k": int}]` +- `fallback_trigger_reason`:最后一次触发原因(也可放每层 reason) + +--- + +## 10. 可观测 meta 构建与 empty_reason 口径 + +使用 `observability.RecoMetaBuilder` 统一生成 meta: + +- 初始化:`RecoMetaBuilder(scene=scene, user_profile=user_profile, k=k, now=now)` +- 每阶段 set: + - `set_candidate_pool_size_raw` + - `set_after_hard_filter(..., risk_filtered_count_by_flag=...)` + - `set_after_dedup` + - `set_after_freqcap(..., freqcap_filtered_counts=...)` + - `set_served_k` + - `set_fallback_level_final(level, reason=...)` + - `set_config_snapshot({"fallback_trace": ..., "engine_config": ...})` +- 最终:`meta = builder.build()` + +empty_reason: + +- 由 `observability.compute_empty_reason(...)` 在 `build()` 内计算(无需引擎手动写入) +- 关键在于引擎必须正确设置 `raw/after_hard/after_freqcap/served_k`,以便区分: + - `pool_empty`:raw==0 + - `hard_filter_all`:raw>0 且 after_hard==0 + - `freqcap_all`:raw>0 且 after_freqcap==0(并且 after_hard>0) + - `unknown`:其他异常情况 + +--- + +## 11. 稳定性与错误处理(V1) + +### 11.1 防御式输入处理 + +- `k<=0`:直接返回空 items,meta.served_k=0,fallback_level_final=0。 +- `already_recommended_ids / touched_or_viewed_ids`:允许混合类型(str/int),统一按 int 解析(无效值忽略)。 +- `locale`:由 `content_repository.types.normalize_locale` 约束;若不支持,建议在上层拦截;引擎内部需捕获异常并返回空结果(避免 500)。 + +### 11.2 异常兜底 + +任何阶段发生异常: + +- 不抛出到调用方(除非调用方明确要求),而是返回: + - `items=[]` + - `meta`:尽可能填充已知字段,`config_snapshot` 记录错误信息(例如 `{"error": "...", "stage": "fetch_candidates"}`) +- 目的:保证 API/Worker 稳定,不因单条数据问题导致任务/请求失败。 + +--- + +## 12. 测试计划(对应验收标准) + +### 12.1 单元测试覆盖 + +- **稳定性**: + - 缺失字段组合(need/context/emotion 任意缺失)不报错 + - 历史集合为空/很大(包含非数字 id)不报错 +- **回退可观测**: + - raw=0 → `empty_reason="pool_empty"` + - raw>0 且 after_hard=0 → `empty_reason="hard_filter_all"` + - raw>0 且 after_freqcap=0 且 after_hard>0 → `empty_reason="freqcap_all"` + - fallback_trace 写入且 `fallback_level_final` 正确 +- **去重生效**: + - 输出不包含 already/touched 中的 id(覆盖 feed/push/widget) +- **风险优先**: + - `block_health_medical` 必挡(全场景) + - unknown stage + `unsafe_for_stage_unknown` 必挡 +- **跨调用复用**: + - 同样输入(固定 now)重复调用结果稳定(允许 score 浮点微差) + +### 12.2 集成测试建议(tasks 阶段可选) + +- 在 `integration-api-worker` 完成后: + - FastAPI 与 Celery 调用同一 `recommend(...)`,输出结构一致 + diff --git a/spec_kit/Personalized Reco/modules/reco-engine/tasks.md b/spec_kit/Personalized Reco/modules/reco-engine/tasks.md new file mode 100644 index 0000000..c0d342c --- /dev/null +++ b/spec_kit/Personalized Reco/modules/reco-engine/tasks.md @@ -0,0 +1,195 @@ +# Reco Engine(推荐引擎编排)|Tasks + +> 对应计划:`spec_kit/Personalized Reco/modules/reco-engine/plan.md` +> +> 执行规则: +> +> - 本任务清单**可执行、可验证**;每项完成后在“状态”处标记为 `已完成` 并补充必要的证据(测试用例/日志/截图/输出)。 +> - **禁止破坏性数据库操作**(如需必须先征得同意并回复“允许操作数据库”)。 +> - 本模块默认约定: +> - `locale` 主要来自客户端 API 入参;若未传,默认 `en` +> - Feed:`feed_allow_partial=true` 且 `feed_fill_with_fallback=true`(允许不足,但会尝试回退补齐) +> - Hard Filter:**仅实现硬规则集合**(不实现 `UserProfileV1_2_Extended.hard_rules` 扩展) +> - `explanations`:默认开启(但建议输出“轻量 explanations”,避免载荷过大) + +--- + +## 0. 准备与对齐(不改代码) + +- [x] **确认依赖模块接口未变更**(状态:已完成) + - **检查点**: + - `ContentRepository.fetch_candidates(...)` 入参含 `locale/fallback_level/exclude_content_ids` + - `scoring.score_content(...)` 可用且 Push 默认启用 `P_uncertainty` + - `rerank_freqcap.rerank_and_freqcap(...)` 可用且会在缺失 `recent_*` 时跳过该维度过滤 + - `observability.RecoMetaBuilder` 的字段口径与 `empty_reason` 规则不变 + - **证据**: + - `server/app/features/personalized_reco/content_repository/interface.py`:`fetch_candidates(..., locale, fallback_level, exclude_content_ids)` + - `server/app/features/personalized_reco/scoring/score.py`:`score_content(...)` + - `server/app/features/personalized_reco/rerank_freqcap/rerank.py`:`rerank_and_freqcap(..., recent_author_ids=None, recent_template_ids=None)` + - `server/app/features/personalized_reco/observability/builder.py`:`RecoMetaBuilder.build()` 与 `compute_empty_reason` + +--- + +## 1. 代码骨架与类型(新增 reco_engine 模块) + +- [x] **创建目录与初始化文件**(状态:已完成) + - **目标路径**:`server/app/features/personalized_reco/reco_engine/` + - **文件**: + - `__init__.py` + - `types.py` + - `defaults.py` + - `utils.py` + - `hard_filter.py` + - `orchestrator.py` + - **验收**:可被 `from app.features.personalized_reco.reco_engine import ...` 导入 + - **证据**: + - 已新增:`server/app/features/personalized_reco/reco_engine/__init__.py` + - 导出入口:`from app.features.personalized_reco.reco_engine import recommend` + +- [x] **定义核心类型**(状态:已完成) + - **`types.py` 建议包含**: + - `RecoConstraints`(可选过滤:`exclude_content_ids/exclude_author_ids/exclude_template_ids/max_candidates_limit/recent_author_ids/recent_template_ids`) + - `RecoEngineConfig`(Feed 补齐策略、候选倍率等) + - `RecommendedItem`(`content_id/text/final_score/fallback_level_final/explanations`) + - `RecoEngineResult`(`items/meta`) + - `HardFilterResult`(`kept_items/risk_filtered_count_by_flag/removed_count/optional_hits`) + - **验收**:类型可在单测中直接构造与序列化(若用 pydantic) + - **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/types.py` + +- [x] **默认配置落地**(状态:已完成) + - **`defaults.py` 建议**: + - `get_default_engine_config(scene)` 或统一 `RecoEngineConfig()` + - 候选倍率:Feed `10`,Push/Widget `30`(可配置) + - Feed 策略默认:`allow_partial=true`、`fill_with_fallback=true` + - **验收**:不传 config 时引擎可稳定运行 + - **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/defaults.py` + +- [x] **工具函数:ID 与 locale 的防御式处理**(状态:已完成) + - **`utils.py` 建议**: + - `normalize_int_id_list(mixed_ids) -> list[int]`:解析 `str|int`,无效值忽略 + - `merge_exclude_ids(already, touched, extra) -> list[int]` + - `normalize_or_default_locale(locale) -> "en"|"tc"`:缺失默认 `en`;非法时抛出/返回错误由 orchestrator 捕获 + - **验收**:输入包含 `"1" / 1 / "abc" / None` 不报错 + - **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/utils.py` + +--- + +## 2. Hard Filter(硬过滤)实现 + +- [x] **实现硬规则集合**(状态:已完成) + - **文件**:`hard_filter.py` + - **必须实现规则**: + - 全场景:`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` 且 `C.need_suitability[parenting_pressure]=1` 且 `C.personalization_power=1` → 过滤 + - **输出统计**: + - `risk_filtered_count_by_flag: dict[str,int]`(按命中的 risk_flag 计数;跨维度规则可用固定 key 如 `rule:unknown_stage_parenting_pressure_power1`) + - **验收**: + - 传入 3 条候选,命中规则的被剔除 + - `risk_filtered_count_by_flag` 的数值与剔除条数一致 + - **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/hard_filter.py` + +--- + +## 3. Orchestrator(编排器)实现 + +- [x] **实现 `recommend(...)` 主入口**(状态:已完成) + - **文件**:`orchestrator.py` + - **函数形态建议**: + - `async def recommend(*, repo: ContentRepository, scene, user_profile, already_recommended_ids, touched_or_viewed_ids, k, now, locale=None, constraints=None, config=None) -> RecoEngineResult` + - **关键编排步骤**(每次 fallback level 都要跑一遍): + - Candidate:`repo.fetch_candidates(...)` + - Hard Filter:`hard_filter(...)` + - Soft Scoring:`score_content(...)` + - Rerank/Freqcap:`rerank_and_freqcap(...)` + - Serve:截断到 `k` 并构造 `RecommendedItem` + - Meta:用 `RecoMetaBuilder` 逐阶段填充并 `build()` + - **验收**: + - 任意 `k`(含 0)不报错 + - 输出结构稳定:`items` 与 `meta` 永远存在 + - **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/orchestrator.py` + +- [x] **Fallback Ladder 回退循环**(状态:已完成) + - **行为**: + - 依次尝试 `fallback_level in [0,1,2,3]` + - 每层更新 `meta_builder.set_fallback_level_final(level, reason=...)` + - 每层记录 `fallback_trace` 并写入 `meta.config_snapshot` + - **Feed 策略**(默认): + - `served_k < k` 时继续回退补齐,直到 `k` 或 L3 + - 若最终仍不足,允许返回不足,但 `served_k`/`fallback_level_final` 必须正确 + - **验收**: + - 构造一个“强过滤 + 频控后为空”的场景能触发逐级回退 + - **证据**:`meta.config_snapshot.fallback_trace` 会记录每层的 raw/after_hard/after_dedup/after_freqcap/served_total + +- [x] **explanations 默认开启但保持轻量**(状态:已完成) + - **建议默认包含**: + - `fallback_level_used` + - `hard_filter_hits`(命中的 risk_flags/规则 id) + - `score_summary`(可选:只保留少量关键字段,如 `S_core/S_personal/P_uncertainty/P_risk`,不输出全量 breakdown) + - **验收**: + - 返回载荷可控(Feed 30 条不会过大) + - **证据**:explanations 仅包含 `fallback_level_used/hard_filter_hits/score_summary` + +- [x] **异常兜底与 meta 记录**(状态:已完成) + - **要求**: + - 捕获 `normalize_locale` 抛错、repo 查询异常、单条内容打分异常等 + - 返回 `items=[]`,并在 `meta.config_snapshot` 写入 `{"error": "...", "stage": "..."}`(避免 500) + - **验收**: + - 传入不支持的 locale(如 `jp`)时不会导致接口崩溃 + - **证据**:`normalize_locale` 失败时返回空 items,且 `meta.config_snapshot.stage="normalize_locale"` + +--- + +## 4. 与现有模块的对齐与集成 + +- [x] **对齐 `rerank_freqcap` 的作者/模板冷却输入含义**(状态:已完成) + - **含义说明**: + - `recent_author_ids/recent_template_ids` 表示“冷却窗口内已触达的作者/模板集合” + - 本模块不负责计算窗口裁剪;调用方需按 `cooldown_*_days` 裁剪后再传 + - **默认策略**: + - 若调用方不提供,则传 `None`,由 `rerank_freqcap` 记录缺失并跳过该维度过滤(句子级去重仍有效) + - **验收**: + - 不提供 `recent_*` 时不报错,且 meta 中 `freqcap_filtered_counts` 仍有 sentence 维度计数 + - **证据**:引擎透传 `recent_author_ids/recent_template_ids`(默认为 None);`rerank_freqcap` 自身会记录缺失维度 + +- [x] **对齐 `RecoMetaBuilder` 阶段字段写入点**(状态:已完成) + - **必须写入**: + - raw / after_hard_filter / after_dedup / after_freqcap / served_k / fallback_level_final + - `risk_filtered_count_by_flag`、`freqcap_filtered_counts` + - **验收**: + - `empty_reason` 可区分 `pool_empty / hard_filter_all / freqcap_all` + - **证据**:单测覆盖 `pool_empty / hard_filter_all / freqcap_all` + +--- + +## 5. 单元测试(必做) + +- [x] **新增 `server/tests/test_reco_engine.py`**(状态:已完成) + - **测试用例建议**: + - `k=0` 返回空 items,meta.served_k=0 + - raw=0 → empty_reason=`pool_empty` + - raw>0 且 after_hard=0 → empty_reason=`hard_filter_all` + - raw>0 且 after_freqcap=0 且 after_hard>0 → empty_reason=`freqcap_all` + - 去重生效:输出不包含 already/touched ids + - `block_health_medical` 必挡 + - Push 缺失画像字段时仍稳定(repository 会至少 L1;引擎 meta 与 fallback_trace 正确) + - **验收**:`pytest` 全绿(只跑相关 tests 也可) + - **证据**: + - 新增文件:`server/tests/test_reco_engine.py` + - 在本机 venv 下执行:`server/.venv/bin/python -m pytest -q` → `19 passed` + +--- + +## 6. 文档与总览标记(仅在全部任务完成后做) + +- [x] **更新本子模块执行状态**(状态:已完成) + - **文件**:`spec_kit/Personalized Reco/modules/reco-engine/tasks.md` + - **要求**:本文件所有任务项标记为 `已完成`,并补齐证据 + - **证据**:本文件已全部打勾并补充证据 + +- [ ] **更新大需求总览 `overview.md`**(状态:未开始) + - **文件**:`spec_kit/Personalized Reco/overview.md` + - **要求**:当 `reco-engine` 全部任务完成后,将第 6 项 “已实施/已完成” 并补充变更记录(日期 + 简述) + diff --git a/spec_kit/Personalized Reco/modules/rerank-freqcap/plan.md b/spec_kit/Personalized Reco/modules/rerank-freqcap/plan.md new file mode 100644 index 0000000..fef4236 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/rerank-freqcap/plan.md @@ -0,0 +1,264 @@ +# Rerank & Freqcap(重排 / 去重 / 频控)|Plan + +> 对应规范:`spec_kit/Personalized Reco/modules/rerank-freqcap/spec.md` +> +> 规则来源(必须对齐): +> +> - `设计说明文档/個性化推薦算法規則.md`(Feed MMR λ=0.7;Push/Widget 冷却口径) +> - `spec_kit/Personalized Reco/overview.md`(模块边界:本模块在 Soft Scoring 之后执行) + +--- + +## 1. 目标与交付物 + +### 1.1 目标 + +- 将 Soft Scoring 后的候选集变为**可下发的最终排序**(长度 ≤ k)。 +- 实现 V1 最小集合: + - **去重**:排除 `already_recommended_ids ∪ touched_or_viewed_ids` + - **Feed 序列多样性**:MMR 重排(离散特征版) + - **Push/Widget 频控与冷却**:至少保证“同句不重复”;作者/模板按输入能力做增强 +- 输出稳定的 `meta` 统计字段,用于定位 served_k 不足的原因(dedup/freqcap 导致清空等)。 + +### 1.2 交付物 + +- `modules/rerank-freqcap/plan.md`:本技术计划(本文件)。 +- 代码实现(tasks 阶段落地)建议位置: + - `server/app/features/personalized_reco/rerank_freqcap/` + - 包含: + - 纯函数 `rerank_and_freqcap(...) -> RerankResult` + - `RerankConfig` 与默认参数(按 scene) + - `Sim/Tag` 构造工具函数(Feed MMR) +- 单元测试(tasks 阶段落地): + - 去重正确性 + - Feed MMR 的 Top1 + 多样性选择 + - Push/Widget 冷却规则(在给定历史集合输入下) + +--- + +## 2. 模块职责边界(V1 约定) + +### 2.1 本模块负责 + +- **从 scored_candidates 中做过滤/重排**: + - Dedup:按历史集合过滤 + - Freqcap:按冷却维度(句子/作者/模板)做“硬过滤或强约束” + - Feed:MMR 生成序列(保证多样性) +- 输出 `ranked_items` 与 `meta`(候选规模、过滤数量、缺失输入统计等)。 + +### 2.2 不在本模块实现 + +- **不计算 Soft Scoring 分数**:只消费 `final_score`(或等价的 score)。 +- **不做 Hard Filter**:Hard Filter 发生在更早阶段,本模块只处理已通过 Hard Filter 的候选。 +- **不维护服务端长期历史**(V1):冷却窗口“X 天”由客户端在请求时传入对应的“最近窗口内集合”,或由未来服务端侧补齐。 + +> 说明(V1 冷却窗口语义):本模块以“输入集合代表冷却窗口内的历史”为准。`cooldown_*_days` 作为配置与可观测字段保留,便于未来接入服务端历史后真正按时间计算。 + +--- + +## 3. 输入/输出与数据结构(V1) + +### 3.1 输入 + +- `scene`: `feed | push | widget` +- `scored_candidates`: `List[ScoredCandidate]`,至少包含: + - `content_id: int` + - `final_score: float`(或 `score`) + - `author_id: str | None` + - `template_id: str | None` + - `content_profile`(用于 feed 标签:stage/need/context 等;缺失时可退化) +- `already_recommended_ids`: `List[str|int]` +- `touched_or_viewed_ids`: `List[str|int]` +- 可选历史(若客户端暂不传,V1 作为增强项): + - `recent_author_ids: List[str] | None` + - `recent_template_ids: List[str] | None` +- `k`: 目标条数(feed 默认 30;push/widget 默认 1) +- `config`: + - `mmr_lambda`(Feed 默认 0.7) + - `cooldown_sentence_days/cooldown_author_days/cooldown_template_days`(按场景默认) + +### 3.2 输出 + +- `ranked_items`: `List[ScoredCandidate]`(长度 ≤ k) +- `meta`(V1 必须字段): + - `candidate_pool_size_after_dedup: int` + - `candidate_pool_size_after_freqcap: int` + - `freqcap_filtered_counts: { sentence?: int, author?: int, template?: int }`(可选但建议) + - `missing_history_fields: List[str]`(例如 `recent_author_ids` 未提供) + +--- + +## 4. 关键技术决策(V1) + +### 4.1 ID 归一化(避免 str/int 混用导致漏过滤) + +由于输入历史集合可能是 `str|int`,V1 统一做: + +- 尽量将 `content_id` 归一化为 `int` +- 无法转换的值忽略并记录 debug(不影响主流程) + +### 4.2 Push/Widget 的频控策略:先保证“同句不重复”,再增强作者/模板 + +V1 选择“安全且可落地”的策略: + +- **句子冷却(必做,硬过滤)**: + - 若 `content_id` 出现在历史集合中,则直接过滤 +- **作者/模板冷却(增强项)**: + - 若 `recent_author_ids/recent_template_ids` 有输入,则对命中者执行硬过滤 + - 若无输入,则跳过该维度,但在 `meta.missing_history_fields` 记录缺失,便于可观测 + +> 说明:规范允许作者/模板作为硬频控或强降权。V1 采用“有输入就硬过滤、无输入就跳过”的方式,避免伪实现与误杀。 + +### 4.3 Feed 的多样性:MMR(离散特征版) + +V1 实现 MMR 的离散相似度(不依赖 embedding): + +\[ +MMR(c)=\lambda\cdot Rel(c) - (1-\lambda)\cdot \max_{s\in S} Sim(c,s) +\] + +- `Rel(c)`:使用 `final_score` +- `Sim(c,s)`: + - `content_id` 相同:`Sim=1` + - `template_id` 相同且非空:`Sim += 0.6` + - `author_id` 相同且非空:`Sim += 0.3` + - 标签重合(Jaccard):`Sim += 0.1 * Jaccard(tags_c, tags_s)` + - clamp 到 `[0,1]` + +标签集合 `tags_*` 的 V1 落地定义(必须可算、且对缺字段鲁棒): + +- `stage:`(例如 `stage:general/expecting/parenting/unknown`) +- `need:`:从 `need_suitability` 中取 **最大值的 key** 作为代表标签(若为空则跳过) +- `context:`:从 `context_suitability` 中取 **最大值的 key** 作为代表标签(若为空则跳过) + +> 说明:内容画像是 suitability(0/0.5/1)结构;V1 取 argmax 能保证标签集合小且稳定,便于测试。后续可扩展为“取所有 ≥0.5 的 key”以增强多样性。 + +--- + +## 5. 具体算法流程(V1) + +### 5.1 Dedup(必做,三场景共用) + +输入: + +- `seen_ids = already_recommended_ids ∪ touched_or_viewed_ids` + +处理: + +- 过滤 `content_id ∈ seen_ids` 的候选 + +输出: + +- `candidate_pool_size_after_dedup = len(filtered_candidates)` + +### 5.2 Freqcap(Push/Widget 必做;Feed 可选) + +V1 频控实现顺序(先句子,再作者/模板): + +1. 句子冷却:过滤 `content_id ∈ seen_ids` +2. 作者冷却(若提供 `recent_author_ids`):过滤 `author_id ∈ recent_author_ids` +3. 模板冷却(若提供 `recent_template_ids`):过滤 `template_id ∈ recent_template_ids` + +输出: + +- `candidate_pool_size_after_freqcap` +- `freqcap_filtered_counts`(按维度统计被过滤数量) + +### 5.3 Feed:MMR 序列重排(建议实现) + +步骤: + +- Top1:直接取 `final_score` 最高者 +- 对后续位置 t=2..k: + - 对每个未选候选 c 计算 `MMR(c)` + - 选择 `MMR` 最大者加入序列 + +性能与实现约束(V1): + +- 候选数 N(例如 200~500)时,朴素 \(O(kN^2)\) 仍可能偏大;V1 可采用: + - 先截断到 `top_n_for_mmr`(例如 200)再做 MMR + - 或缓存 `Sim(c,s)` 的最大值并增量更新(实现复杂度更高,V1 可不做) + +### 5.4 Push/Widget:选 TopK + +在 dedup+freqcap 后: + +- 按 `final_score` 降序取前 k 条作为 `ranked_items` + +--- + +## 6. 默认参数(V1 建议) + +### 6.1 Feed + +- `mmr_lambda = 0.7` +- `top_n_for_mmr = 200`(避免候选过大导致重排过慢) + +### 6.2 Push(冷却窗口口径来自算法规则的工程默认) + +- `cooldown_sentence_days = 14`(同句 14 天不重复) +- `cooldown_author_days = 7`(同作者 7 天不重复,需输入 `recent_author_ids` 才能执行) +- `cooldown_template_days = 7`(同模板 7 天不重复,需输入 `recent_template_ids` 才能执行) + +### 6.3 Widget + +- `cooldown_sentence_days = 7` +- `cooldown_author_days = 7` +- `cooldown_template_days = 7` + +> 说明:V1 冷却天数在本模块主要用于配置与可观测字段;真正“按天”判断需要历史带时间戳或服务端持久化,后续迭代补齐。 + +--- + +## 7. 可观测与 meta(V1) + +本模块建议输出(供 `observability` 子模块汇总): + +- `candidate_pool_size_after_dedup` +- `candidate_pool_size_after_freqcap` +- `freqcap_filtered_counts`(sentence/author/template) +- `missing_history_fields`: + - 例如客户端未提供 `recent_author_ids` → 记录 `author` + - 未提供 `recent_template_ids` → 记录 `template` + +> 目标:当 served_k 过少时,能快速判断是 dedup/freqcap 导致,还是上游候选不足。 + +--- + +## 8. 测试计划(V1) + +### 8.1 单元测试(纯函数) + +- Dedup: + - 输入历史包含某些 `content_id`,输出必须不包含这些 id + - `str/int` 混用能正确归一化 +- Freqcap: + - 仅提供 `content_id` 历史时:句子冷却生效 + - 提供 `recent_author_ids` 时:作者维度过滤生效;未提供时 `meta.missing_history_fields` 正确 + - 提供 `recent_template_ids` 时:模板维度过滤生效;未提供时 `meta.missing_history_fields` 正确 +- Feed MMR: + - Top1 恒等于最高分 + - 后续序列在候选足够时避免连续同作者/同模板(可用统计阈值断言) + - `tags` 缺失时仍能稳定运行(只使用可得字段) + +### 8.2 最小集成验证(与 reco-engine 串联时) + +- 输入一批 scored_candidates + 历史集合: + - Feed:输出长度 ≤ k,且 meta 规模统计正确 + - Push/Widget:在历史命中时能过滤掉重复句子 + +--- + +## 9. 风险与后续演进 + +### 9.1 已知风险 + +- V1 冷却窗口“按天”无法严格执行:因为历史输入缺少时间戳或服务端持久化。本模块已通过“输入集合代表窗口内历史”做可落地实现,但需要在产品/客户端侧保证窗口裁剪正确。 +- Feed MMR 的性能:候选过大时重排可能变慢;V1 用 `top_n_for_mmr` 截断兜底。 + +### 9.2 V1.1+ 演进方向 + +- 服务端侧持久化冷却历史(按用户维度记录 sentence/author/template 的最近触达时间),真正按 `cooldown_*_days` 判定。 +- 将“作者/模板冷却”从硬过滤升级为“强降权 + 允许破例”,并在 meta 中记录“破例原因”(候选不足等)。 +- 将 `P_repeat/P_fatigue` 由 `rerank-freqcap` 产出并注入 `scoring` 的 `external_terms`,实现更平滑的序列控制(而非一刀切过滤)。 + diff --git a/spec_kit/Personalized Reco/modules/rerank-freqcap/tasks.md b/spec_kit/Personalized Reco/modules/rerank-freqcap/tasks.md new file mode 100644 index 0000000..c5be716 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/rerank-freqcap/tasks.md @@ -0,0 +1,172 @@ +# Rerank & Freqcap(重排 / 去重 / 频控)|Tasks + +> 对应计划:`spec_kit/Personalized Reco/modules/rerank-freqcap/plan.md` +> +> 本清单执行原则: +> +> - 本模块只做 **Dedup / Freqcap / Feed MMR 重排**,不做 Soft Scoring 与 Hard Filter。 +> - V1 冷却窗口以“输入集合代表窗口内历史”为准(后续接入服务端历史再按天计算)。 + +--- + +## 0. 任务标记规则 + +- 用勾选框标记执行状态: + - `[ ]` 未开始 + - `[x]` 已完成 +- 每个任务都要求可独立验收(有明确产出/可运行的检查方式)。 + +--- + +## 1. 文档对齐(先把口径写死,避免实现漂移) + +- [x] 1.1 校对 `modules/rerank-freqcap/spec.md` 与 `modules/rerank-freqcap/plan.md` 一致性 + - **检查点**: + - 输入:`scene/scored_candidates/history/k/config` 字段与命名一致 + - 输出:`ranked_items` 与 `meta` 字段集合一致 + - 去重键:`sentence_key=content_id`、`author_key`、`template_key` 口径一致 + - Feed:MMR λ=0.7 与 Sim 规则一致 + - **验收**:两份文档不存在冲突描述,且“V1 冷却窗口语义”写清楚(集合代表窗口内历史)。 + +- [x] 1.2 在 `plan.md` 中补充/固定“标签构造策略”与“候选截断策略”(若后续要改再更新) + - **变更点**: + - 明确 `tags` 的 V1 定义:`stage + need_argmax + context_argmax` + - 明确 `top_n_for_mmr` 默认值与作用(性能兜底) + - **验收**:实现时不会出现“标签到底取哪些 key”的二义性。 + +--- + +## 2. 目录与骨架(与推荐子模块同级) + +- [x] 2.1 新建目录 `server/app/features/personalized_reco/rerank_freqcap/` + - **包含**: + - `__init__.py` + - `types.py`(`ScoredCandidate`、`RerankConfig`、`RerankMeta`、`RerankResult`) + - `defaults.py`(按 scene 的默认参数:λ、cooldown_*、top_n_for_mmr) + - `utils.py`(ID 归一化、Jaccard、tag 构造等) + - `rerank.py`(主入口 `rerank_and_freqcap`) + - **验收**:可通过 `app.features.personalized_reco.rerank_freqcap.*` 正常 import。 + +--- + +## 3. 类型与接口(稳定契约,便于 reco-engine 调用) + +- [x] 3.1 定义 `ScoredCandidate`(最小字段集合) + - **必须字段**: + - `content_id: int` + - `final_score: float` + - **建议字段**(用于多样性/频控): + - `author_id: str | None` + - `template_id: str | None` + - `content_profile`(至少能取到 stage/need_suitability/context_suitability;缺失时需降级) + - **验收**:能承载 MMR 相似度计算所需数据;缺失字段不会导致异常。 + +- [x] 3.2 定义 `RerankConfig`(可调参) + - **字段**: + - Feed:`mmr_lambda`(默认 0.7)、`top_n_for_mmr`(默认 200) + - Push/Widget:`cooldown_sentence_days/cooldown_author_days/cooldown_template_days`(用于配置与可观测) + - **验收**:能从 scene 推导默认 config(或由调用方传入覆盖)。 + +- [x] 3.3 定义 `RerankMeta` 与 `RerankResult` + - **meta 必须字段**: + - `candidate_pool_size_after_dedup` + - `candidate_pool_size_after_freqcap` + - `missing_history_fields` + - **建议字段**: + - `freqcap_filtered_counts`(sentence/author/template) + - **验收**:字段集合固定;任何输入都能产出 meta(包括候选为空)。 + +--- + +## 4. 核心算法实现(V1 最小集合) + +- [x] 4.1 实现历史 ID 归一化(避免 str/int 混用漏过滤) + - **规则**: + - `already_recommended_ids` / `touched_or_viewed_ids` 尽量转为 `int` 集合 + - 转换失败的值忽略并记录 debug + - **验收**:单测覆盖 `["1", 2, "bad"]` 等混合输入,过滤结果正确且稳定。 + +- [x] 4.2 实现 Dedup(必做) + - **规则**:过滤 `content_id ∈ seen_ids` 的候选 + - **产出**:`meta.candidate_pool_size_after_dedup` + - **验收**:输出不包含历史出现过的 `content_id`。 + +- [x] 4.3 实现 Freqcap(Push/Widget 必做;Feed 可选) + - **V1 策略**: + - 句子维度(必做):同句硬过滤(使用 dedup 的 seen_ids 即可) + - 作者/模板维度(增强项): + - 若提供 `recent_author_ids`:命中则硬过滤;否则在 `missing_history_fields` 记录 `author` + - 若提供 `recent_template_ids`:命中则硬过滤;否则在 `missing_history_fields` 记录 `template` + - **产出**: + - `candidate_pool_size_after_freqcap` + - `freqcap_filtered_counts`(建议) + - **验收**:在有/无 `recent_*_ids` 输入时行为一致且可解释。 + +- [x] 4.4 实现 Feed:tag 构造与相似度 `Sim` + - **tag 规则(V1 写死)**: + - `stage:` + - `need:`(从 `need_suitability` 取最大值 key;为空则跳过) + - `context:`(从 `context_suitability` 取最大值 key;为空则跳过) + - **Jaccard**:`|A∩B|/|A∪B|`,空集合时返回 0 + - **Sim 累加规则**: + - 同 content_id → 1 + - template_id 相同且非空 → +0.6 + - author_id 相同且非空 → +0.3 + - +0.1 * Jaccard(tags) + - clamp 到 `[0,1]` + - **验收**:单测覆盖缺失字段(无 author/template/tags)时仍能算出稳定 Sim。 + +- [x] 4.5 实现 Feed:MMR 选序列 + - **规则**: + - Top1:按 `final_score` 最大 + - 后续:按 `MMR(c)=λ*Rel(c)-(1-λ)*maxSim` 选择 + - `Rel=final_score` + - **性能兜底**:先截断候选到 `top_n_for_mmr` 再做 MMR + - **验收**: + - Top1 恒等于最高分 + - 候选足够时,序列不出现大量同作者/同模板紧邻重复(可用阈值断言) + +- [x] 4.6 实现 Push/Widget:最终 TopK + - **规则**:dedup+freqcap 后按 `final_score` 降序取前 k 条 + - **验收**:输出长度 ≤ k,且分数单调不增(允许相等)。 + +- [x] 4.7 实现主入口 `rerank_and_freqcap(...) -> RerankResult` + - **规则**: + - 三场景共用 dedup + - Feed:MMR;Push/Widget:TopK + - 必须输出 meta(即使 ranked_items 为空) + - **验收**:任何输入(含空候选)不抛异常,并输出稳定结构。 + +--- + +## 5. 单元测试(pytest,纯函数为主) + +- [x] 5.1 新建测试文件 `server/tests/test_rerank_freqcap.py` + - **用例覆盖**: + - dedup:历史集合过滤正确(含 str/int 混用) + - freqcap:有/无 recent_author/template 的分支与 meta 缺失标记 + - feed mmr:Top1=最高分;后续避免同作者/模板紧邻(构造数据断言) + - push/widget:TopK 输出正确 + - **验收**:`pytest -q tests/test_rerank_freqcap.py` 通过。 + +--- + +## 6. 最终自检清单(合入前) + +- [x] 6.1 文档一致性检查 + - **验收**:`spec.md` / `plan.md` / 实现接口签名三者一致(尤其 meta 字段与默认参数)。 + +- [x] 6.2 回归检查(不影响已实施模块) + - **验收**:不修改 `content-repository` 与 `scoring` 的既有逻辑;仅新增 `rerank-freqcap` 模块与测试。 + +- [x] 6.3 全量测试通过 + - **命令**(在 `server/`): + - `pytest -q` + - **验收**:所有用例通过。 + +- [x] 6.4 全部完成后更新大规范 `overview.md` + - **变更点**: + - 将 `modules/rerank-freqcap/` 标记为“已实施” + - 增加一条变更记录(日期 + 交付物:plan/tasks/代码/测试) + - **验收**:`spec_kit/Personalized Reco/overview.md` 中模块状态与交付记录准确。 + diff --git a/spec_kit/Personalized Reco/modules/scoring/plan.md b/spec_kit/Personalized Reco/modules/scoring/plan.md new file mode 100644 index 0000000..bf1fbcd --- /dev/null +++ b/spec_kit/Personalized Reco/modules/scoring/plan.md @@ -0,0 +1,271 @@ +# Scoring(软打分与惩罚项)|Plan + +> 对应规范:`spec_kit/Personalized Reco/modules/scoring/spec.md` +> +> 规则来源(必须严格对齐): +> +> - `设计说明文档/個性化推薦算法規則.md`(Soft Scoring 公式、V1.2 缺失字段口径、场景默认权重) +> - `设计说明文档/句子文案打分規則.md`(risk_flags 语义与默认值口径) +> +> 模块边界参考:`spec_kit/Personalized Reco/overview.md`(由 `reco-engine` 串起候选→过滤→打分→重排) + +--- + +## 1. 目标与交付物 + +### 1.1 目标 + +- 实现三种场景统一的软打分函数 `final_score(U, Cᵢ)`,并输出可观测的 `breakdown` 以便调参与回归测试。 +- 严格实现 V1.2 缺失字段的保守策略(`S_need/S_context/S_emotion` 的默认值)。 +- 实现 Push 默认启用的不确定性惩罚 `P_uncertainty`。 +- 实现 Widget 情绪区间(0.4~0.8)的**软降权**(不硬过滤,但能明显压低分数)。 + +### 1.2 交付物 + +- `modules/scoring/plan.md`:本技术计划(本文件)。 +- 代码实现(tasks 阶段落地)建议位置: + - `server/app/features/personalized_reco/scoring/` + - 包含: + - 纯函数 `score_content(...) -> ScoreResult` + - `ScoreConfig`(场景默认参数 + 可覆盖) + - `ScoreBreakdown`(稳定字段集合,用于可观测) +- 单元测试(tasks 阶段落地): + - 缺失字段一致性 + - Push 不确定性惩罚生效 + - Widget 情绪软降权生效(区间外明显更低) + +--- + +## 2. 模块职责边界(V1 约定) + +### 2.1 本模块负责 + +- 计算并返回: + - `S_need/S_context/S_stage/S_emotion` + - `S_core` + - `S_personal` + - `P_uncertainty`(按开关控制,Push 默认启用) + - `P_widget_emotion_out_of_range`(Widget 专用软降权项,归入 `P_risk` 或单独字段均可;V1 建议单独字段,便于打点) +- 生成 `breakdown`,用于可观测与调参。 + +### 2.2 不在本模块实现(但接口预留/可注入) + +为避免与 `rerank-freqcap` / `reco-engine` 的职责重叠,V1 约定以下项**由外部模块产出**并作为输入注入(若不提供,默认按 0 处理): + +- `S_fresh`:新鲜度/时间衰减相关(可由引擎或重排阶段计算) +- `P_fatigue`:疲劳惩罚(基于历史触达/浏览/频控) +- `P_repeat`:重复惩罚(同句/同作者/同模板等) +- `P_risk`:软风险惩罚(例如 `soft_health_sensitive` 等) + +> 说明:硬过滤(Hard Filter)由引擎编排阶段执行,本模块只消费 `pass`(是否通过硬过滤)并在总分中乘上 \(\mathbb{I}[pass]\)。 + +--- + +## 3. 输入/输出与数据结构(V1) + +### 3.1 输入(对齐 spec) + +- `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`:是否启用 `P_uncertainty`(Push 默认 true) + - `widget_emotion_soft_range`:Widget 情绪软区间(默认 `[0.4, 0.8]`) + - `widget_emotion_penalty_gamma`:Widget 情绪软降权强度(V1 取“适中”默认 0.25) +- `pass`: `boolean`(来自 Hard Filter 结果;默认 true) +- `external_terms`(可选,来自其他模块注入): + - `S_fresh`、`P_fatigue`、`P_repeat`、`P_risk` + - 若未提供则按 0 处理 + +### 3.2 输出 + +- `final_score: float` +- `breakdown`(建议始终返回,便于可观测与断言): + - `S_need/S_context/S_stage/S_emotion` + - `S_core/S_personal/S_fresh` + - `P_fatigue/P_repeat/P_risk/P_uncertainty` + - `P_widget_emotion_out_of_range`(可选但建议保留) + - `missing_fields: string[]`(`need/context/emotion`) + - `pass: boolean` + - `scene: feed|push|widget` + +--- + +## 4. 核心公式与实现细则(必须对齐) + +### 4.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} +\] + +> V1 约定:`S_fresh/P_fatigue/P_repeat/P_risk` 允许外部注入;若缺失则当作 0,以确保函数可用且输出结构稳定。 + +### 4.2 分解项:S_need / S_context(V1.2 缺失字段兜底) + +- `S_need`: + - 若 `U.need` 缺失(为空对象 `{}` 或不存在)→ `S_need = 0.5` + - 否则 → `S_need = Cᵢ.need_suitability[U.need_key]` +- `S_context`: + - 若 `U.context` 缺失(为空对象 `{}` 或不存在)→ `S_context = 0.5` + - 否则 → `S_context = Cᵢ.context_suitability[U.context_key]` + +> 工程约定:`U.need/U.context` 在客户端为稀疏 one-hot(最多一个 key=1)。实现时需提供一个“取唯一 key”的工具函数:若出现多个 key=1,按第一个(字典序或插入序)取值并记录告警(V1 可先 debug 日志)。 + +### 4.3 分解项:S_emotion(general=0.8;否则 1-|u-c|) + +- 若 `U.emotion_score` 缺失 → `S_emotion = 0.8` +- 否则: + - 若 `Cᵢ.emotion_score` 为 general(`None`)→ `S_emotion = 0.8` + - 否则 → \(S_{emotion}=1-|U.emotion\_score - C_i.emotion\_score|\) + +实现约束: + +- 将 `S_emotion` clamp 到 `[0,1]`,避免异常值导致负分或溢出。 + +### 4.4 分解项:S_stage(对齐算法规则) + +规则(按 `设计说明文档/個性化推薦算法規則.md`): + +- 若 `Cᵢ.stage == "general"` → `S_stage = 1` +- 若 `Cᵢ.stage` 命中用户阶段(例如用户 `expecting=1` 且内容 `stage="expecting"`)→ `S_stage = 1` +- 若用户阶段为 `unknown` 且内容阶段为非 unknown → `S_stage = 0.7` +- 其余 → `S_stage = 0` + +### 4.5 个性化加成 S_personal(含降个性化约束) + +\[ +S_{personal}=\alpha \cdot C_i.personalization\_power \cdot \max(S_{need}, S_{context}) +\] + +降个性化约束(对齐 spec 与回退梯度): + +- 若 `fallback_level>=1` 或字段缺失明显/低置信度: + - L1:限制 `personalization_power ≤ 0.5` + - L2/L3:限制 `personalization_power = 0` + +> 工程实现:本模块只做“限制后的 effective_personalization_power”,由调用方传入 `fallback_level`(或直接传入已限制后的 `content_profile.personalization_power`)。V1 建议:在 `content-repository`/引擎侧先做候选池约束,本模块再做一次保护性 clamp(防御式编程)。 + +### 4.6 不确定性惩罚 P_uncertainty(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) + +开关策略(V1): + +- `scene=push`:默认启用 +- `scene=feed/widget`:默认关闭(可通过 config 打开) + +### 4.7 Widget 情绪区间软降权(适中默认实现) + +目标:当 `scene=widget` 且 `Cᵢ.emotion_score` 可计算(非 general)时,若超出 `[0.4, 0.8]` 不硬过滤,但应产生明显降权。 + +V1 选择一个“适中、可调参、可解释”的惩罚函数: + +- 设区间为 `[lo, hi]`(默认 `0.4, 0.8`) +- 距离: + - 若 `e < lo`,`d = lo - e` + - 若 `e > hi`,`d = e - hi` + - 否则 `d = 0` +- 惩罚: + - \(P_{widget} = \gamma \cdot \frac{d}{(hi-lo)}\) + - 默认 `γ = 0.25`(适中强度) + - clamp 到 `[0, γ]` + +落地方式: + +- 将 `P_widget_emotion_out_of_range` 单独输出到 breakdown; +- 在总分中计入: + - `P_risk_effective = external.P_risk + P_widget_emotion_out_of_range` + +> 解释:当 `emotion_score` 达到区间边界外最大距离约为 0.4(例如 0 或 1)时,惩罚接近 `γ`,足以在 Widget 场景把“过低/过高情绪”的句子压到更靠后,但不会一刀切。 + +--- + +## 5. 场景默认参数(V1 建议) + +对齐 `spec.md` 与算法规则: + +- 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`,并默认 `enable_uncertainty_penalty=true` +- Widget:`w_need=0.25, w_emotion=0.25, w_stage=0.30, w_context=0.20`,并启用 `widget_emotion_soft_range=[0.4,0.8]` + +推荐默认: + +- `alpha=0.15`(可调,V1 用于让个性化加成“次要但可见”) +- `beta=0.30`(可调,V1 用于在低置信度时明显压低高个性化内容) +- `widget_emotion_penalty_gamma=0.25`(适中软降权强度) + +> 注:`alpha/beta/gamma` 为工程默认建议值,后续应通过回归测试与线上指标调参;本模块必须允许 config 覆盖。 + +--- + +## 6. V1:S_fresh / P_fatigue / P_repeat 的处理方案(能落地且可演进) + +### 6.1 V1 解决方式 + +- 在 `ScoreBreakdown` 中**保留** `S_fresh/P_fatigue/P_repeat` 字段; +- 本模块计算时: + - 若上游未提供对应值,则默认按 0; + - 若提供,则原样计入总分(本模块不解释其来源/计算方式)。 + +### 6.2 接口建议(为后续模块对接预留) + +- `external_terms` 中携带: + - `S_fresh`:例如时间衰减、跨日新鲜度(未来可由 `rerank-freqcap` 或 `reco-engine` 计算) + - `P_fatigue/P_repeat`:由 `rerank-freqcap` 基于历史集合与冷却窗口计算 + +> 好处:V1 先保证“可排序 + 可解释 + 可插拔”;后续接入重排/频控时无需改动打分主干,只需注入外部项。 + +--- + +## 7. 测试计划(V1) + +### 7.1 单元测试(纯函数) + +- 缺失字段一致性: + - `U.need` 缺失 → `S_need=0.5` + - `U.context` 缺失 → `S_context=0.5` + - `U.emotion_score` 缺失 → `S_emotion=0.8` +- 不确定性惩罚(Push 默认启用): + - `conf_U`/`conf_C` 低且 `personalization_power` 高 → `final_score` 明显降低 + - 关闭开关后 `P_uncertainty=0` +- Widget 软降权: + - `emotion_score=0.6`(区间内)→ `P_widget=0` + - `emotion_score=0.0/1.0`(区间外)→ `P_widget` 接近 `gamma`,`final_score` 明显更低 +- `pass=false`: + - `final_score` 必须为 0(或按实现约定为 0),且 breakdown 中保留分解项(便于排查) + +### 7.2 断言建议 + +- 断言 `breakdown` 字段集合稳定(不会因缺省而缺字段)。 +- 断言所有分项均为有限数(非 NaN/Infinity),并在合理范围内(可对 `S_*` clamp 到 `[0,1]`)。 + +--- + +## 8. 风险与后续演进 + +### 8.1 已知风险 + +- `U.need/U.context` 若出现多个 key=1,会导致取值歧义;V1 需明确选择策略并记录告警,避免 silent bug。 +- Widget 软降权强度(`gamma`)对结果影响较大,需要配合回归测试与线上指标调参。 + +### 8.2 后续演进方向(V1.1+) + +- 将 `P_risk` 细化为可配置的多项惩罚(例如 health sensitive、过度个性化翻车风险等),并在 breakdown 中拆分输出。 +- 引入 `S_fresh` 的时间衰减公式,并与 `rerank-freqcap` 的跨日多样性联动。 + diff --git a/spec_kit/Personalized Reco/modules/scoring/tasks.md b/spec_kit/Personalized Reco/modules/scoring/tasks.md new file mode 100644 index 0000000..1e08f82 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/scoring/tasks.md @@ -0,0 +1,167 @@ +# Scoring(软打分与惩罚项)|Tasks + +> 对应计划:`spec_kit/Personalized Reco/modules/scoring/plan.md` +> +> 本清单执行原则: +> +> - `scoring` 只做**软打分**与本模块定义的惩罚项(`P_uncertainty`、Widget 情绪软降权)。 +> - Hard Filter / 频控重排 / 新鲜度等由其他模块产出,本模块通过 `pass` 与 `external_terms` 接收注入(缺省按 0)。 + +--- + +## 0. 任务标记规则 + +- 用勾选框标记执行状态: + - `[ ]` 未开始 + - `[x]` 已完成 +- 每个任务都要求可独立验收(有明确产出/可运行的检查方式)。 + +--- + +## 1. 文档对齐(先把口径写死,避免实现漂移) + +- [x] 1.1 校对 `modules/scoring/spec.md` 与 `modules/scoring/plan.md` 一致性 + - **检查点**: + - 输入:`scene/user_profile/content_profile/now/config` 是否一致 + - 输出:`final_score` 与 `breakdown` 字段集合是否一致 + - 关键公式:`S_core/S_personal/P_uncertainty` 是否与 `设计说明文档/個性化推薦算法規則.md` 一致 + - **验收**:两份文档无冲突描述,且关键参数命名统一(例如 `enable_uncertainty_penalty`、`widget_emotion_soft_range`)。 + +- [x] 1.2 在 `modules/scoring/plan.md` 中“明确写死”V1 的职责边界(若后续有调整再更新) + - **变更点**(如需微调措辞): + - `S_fresh/P_fatigue/P_repeat/P_risk` 为外部注入项,缺省按 0 + - `pass` 来自 Hard Filter,本模块只消费并乘上 \(\mathbb{I}[pass]\) + - **验收**:阅读 plan.md 时不会产生“谁负责计算哪一项”的歧义。 + +--- + +## 2. 目录与骨架(与推荐子模块同级) + +- [x] 2.1 新建目录 `server/app/features/personalized_reco/scoring/` + - **包含**: + - `__init__.py` + - `types.py`(`ScoreConfig/ScoreBreakdown/ScoreResult/ExternalTerms`) + - `defaults.py`(场景默认参数) + - `utils.py`(clamp、one-hot 取 key 等纯工具) + - `score.py`(核心纯函数 `score_content`) + - **验收**:可通过 `app.features.personalized_reco.scoring.*` 正常 import。 + +--- + +## 3. 类型与接口(稳定契约,便于引擎编排调用) + +- [x] 3.1 定义 `ScoreConfig`(含场景默认值 + 可覆盖) + - **字段**: + - 权重:`w_need/w_emotion/w_stage/w_context` + - 系数:`alpha/beta` + - 开关:`enable_uncertainty_penalty` + - Widget:`widget_emotion_soft_range`、`widget_emotion_penalty_gamma` + - **验收**:类型完整;能从 scene 推导默认 config(或由调用方传入)。 + +- [x] 3.2 定义 `ExternalTerms`(注入项,V1 可选) + - **字段**:`S_fresh/P_fatigue/P_repeat/P_risk` + - **默认值策略**:缺省按 0 + - **验收**:score 函数即使没有 external_terms 也能工作且输出字段稳定。 + +- [x] 3.3 定义 `ScoreBreakdown`(用于可观测/调参) + - **必须包含**: + - `S_need/S_context/S_stage/S_emotion` + - `S_core/S_personal/S_fresh` + - `P_fatigue/P_repeat/P_risk/P_uncertainty` + - `missing_fields` + - `pass/scene` + - `P_widget_emotion_out_of_range`(建议保留) + - **验收**:字段集合固定;不会因缺省而缺字段;所有值为有限数(非 NaN/Infinity)。 + +--- + +## 4. 纯函数实现(严格对齐公式 + 防御式兜底) + +- [x] 4.1 实现 one-hot 取唯一 key 的工具函数(need/context) + - **规则**: + - `{}` 或不存在 → 视为缺失 + - 只有一个 key=1 → 返回该 key + - 多个 key=1 → 选择“第一个”(写死策略:字典序优先或插入序优先)并记录 debug 日志 + - **验收**:单测覆盖空对象/单 key/多 key 的行为,且行为确定。 + +- [x] 4.2 实现 `S_need`/`S_context`(V1.2 缺失兜底) + - **规则**: + - need 缺失 → `S_need=0.5`;否则取 `Cᵢ.need_suitability[key]` + - context 缺失 → `S_context=0.5`;否则取 `Cᵢ.context_suitability[key]` + - 若内容侧缺 key/值非法 → 兜底为 0.5(防御式) + - **验收**:单测覆盖用户缺失与内容缺失两侧情况,且不抛异常。 + +- [x] 4.3 实现 `S_emotion`(general=0.8;否则 `1-|u-c|`) + - **规则**: + - `U.emotion_score` 缺失 → 0.8 + - `Cᵢ.emotion_score` 为 general(None)→ 0.8 + - 否则 `1-abs(u-c)` 并 clamp 到 `[0,1]` + - **验收**:单测覆盖缺失/general/正常值/越界值。 + +- [x] 4.4 实现 `S_stage`(对齐算法规则口径) + - **规则**: + - `content.stage=general` → 1 + - 命中用户阶段 → 1 + - 用户 unknown 且内容非 unknown → 0.7 + - 其余 → 0 + - **验收**:单测覆盖 general/命中/unknown→非unknown/其余组合。 + +- [x] 4.5 实现 `S_core`(线性加权) + - **规则**:`S_core=w_need*S_need + w_emotion*S_emotion + w_stage*S_stage + w_context*S_context` + - **验收**:单测断言与手算一致;权重可配置。 + +- [x] 4.6 实现 `S_personal`(对齐公式) + - **规则**:`S_personal=alpha * personalization_power * max(S_need, S_context)` + - **防御**:`personalization_power` clamp 到 `[0,1]` + - **验收**:单测覆盖 power=0/0.5/1,且随 `alpha` 单调变化。 + +- [x] 4.7 实现 `P_uncertainty`(Push 默认启用) + - **规则**:`beta*(1-conf_U)*(1-conf_C)*personalization_power` + - **默认值**:`conf_C` 缺失→0.7;`conf_U` 缺失→按 1.0(或 0.7,需在代码注释写死;V1 建议按 1.0 避免过惩罚) + - **开关**:`enable_uncertainty_penalty` 控制;Push scene 默认 true + - **验收**:单测覆盖低置信度与关闭开关两类情况。 + +- [x] 4.8 实现 Widget 情绪区间软降权(适中默认) + - **规则**: + - `scene=widget` 且 `content.emotion_score` 非 general: + - 若超出 `[0.4,0.8]`:`P_widget = gamma * d/(hi-lo)` 并 clamp `[0,gamma]` + - 区间内:`P_widget=0` + - `P_widget` 计入总分(建议并入 `P_risk`),并在 breakdown 中单独暴露 + - **验收**:单测断言: + - `emotion_score=0.6` → `P_widget=0` + - `emotion_score=0.0/1.0` → `P_widget` 接近 `gamma` + - 不硬过滤(仍返回分数,只是更低) + +- [x] 4.9 实现 `score_content(...) -> ScoreResult`(总分与 breakdown) + - **规则**: + - `final_score = I[pass] * (S_core + S_personal + S_fresh - P_fatigue - P_repeat - P_risk - P_uncertainty)` + - `S_fresh/P_fatigue/P_repeat/P_risk` 从 `external_terms` 读取,缺省 0 + - `pass=false` 时 `final_score=0`(breakdown 仍输出便于排查) + - **验收**:单测覆盖 `pass=false` 与 external_terms 缺省两种情况。 + +--- + +## 5. 单元测试(pytest,纯函数为主) + +- [x] 5.1 新建测试文件 `server/tests/test_scoring.py` + - **包含用例**: + - 缺失字段一致性(need/context/emotion) + - Push 不确定性惩罚生效(低 conf 时分数更低) + - Widget 软降权生效(区间外明显更低) + - pass=false 行为(final_score=0) + - **验收**:`pytest -q` 能跑通该文件。 + +- [x] 5.2 增加“输出稳定性”断言(breakdown 字段集合固定) + - **验收**:任意输入(含缺失字段)都返回同一套 breakdown key。 + +--- + +## 6. 最终自检清单(合入前) + +- [x] 6.1 文档一致性检查 + - **检查点**:`spec.md` / `plan.md` / 实现接口签名三者一致(尤其是 config 字段与默认值策略)。 + - **验收**:阅读任一文档都能找到对应实现位置与参数含义。 + +- [x] 6.2 回归检查(不影响其他模块) + - **验收**:不修改现有 `content-repository` 与 `user_profile_scoring` 逻辑,仅新增 scoring 模块与测试。 + diff --git a/spec_kit/Personalized Reco/overview.md b/spec_kit/Personalized Reco/overview.md index 4079494..6d63c7d 100644 --- a/spec_kit/Personalized Reco/overview.md +++ b/spec_kit/Personalized Reco/overview.md @@ -87,21 +87,36 @@ spec_kit/Personalized Reco/ > 原则:先把“数据可查”打通,再实现“可排序”,最后做“可对外提供(API/任务)”与“可观测”。 -1. **`modules/db-design/`(数据库设计与迁移)** +1. **`modules/db-design/`(数据库设计与迁移)**已实施 - 交付:表结构 + Alembic 迁移可跑通;能插入/读取最小 `ContentProfile` 字段。 -2. **`modules/content-repository/`(数据访问层)** +2. **`modules/content-repository/`(数据访问层)**已实施 - 交付:按场景/回退层级拉候选、按 ID(`content_id` 自增 `int`)批量查;risk_flags 旧→新映射与默认值兜底。 + - 语言:`text` 按客户端 `locale` 输出(当前仅 EN/TC),且不允许语言回退(缺语言内容直接过滤)。 - 口径:suitability 读取层统一输出稳定结构;缺失时补齐“全 0.5”,key 集合固定为: - context:`family/work/relationship/friends/health` - need:`emotional_support/parenting_pressure/self_worth/anxiety_relief/rest_balance` -3. **`modules/scoring/`(打分)** +3. **`modules/scoring/`(打分)** 已实施 - 交付:`final_score` 与缺失字段保守策略(V1.2)实现;Push 的 `P_uncertainty`;Widget 情绪区间软降权。 -4. **`modules/rerank-freqcap/`(去重/重排/频控)** +4. **`modules/rerank-freqcap/`(去重/重排/频控)** 已实施 - 交付:基于传入历史集合的去重;Feed 的 MMR;Push/Widget 冷却窗口规则(先保证“同句不重复”)。 -5. **`modules/observability/`(可观测)** +5. **`modules/observability/`(可观测)** 已实施 - 交付:统一 `meta` 结构与字段;能准确定位候选在何阶段被清空/回退。 -6. **`modules/reco-engine/`(引擎编排)** - - 交付:串起候选→过滤→打分→重排→回退;输出 items+meta;在画像缺失/候选不足时仍稳定返回。 -7. **`modules/integration-api-worker/`(API + Celery 集成)** - - 交付:FastAPI 路由 + Celery 任务都能调用同一引擎;相同输入下结果一致(忽略时间戳差异)。 +6. **`modules/reco-engine/`(引擎编排)** 已实施 + - 交付:新增后端 `reco_engine`(编排器 + Hard Filter);串起候选→过滤→打分→重排→回退;输出 items+meta;默认开启轻量 explanations;在画像缺失/候选不足时仍稳定返回。 +7. **`modules/integration-api-worker/`(API + Celery 集成)** 已实施 + - 交付:新增 `/v1/reco/{feed|push|widget}` 三个推荐接口(含 `Accept-Language→en/tc` 与 `X-Now` 注入);新增按 IP 限流 10/min;新增 Celery 任务 `tasks.reco.generate` 与 `tasks.reco.push_once`,均调用同一 `Reco Engine`。 + +--- + +## 6. 变更记录 + +- 2026-02-02:完成 `modules/scoring/` 的 `plan.md` 与 `tasks.md`,并新增后端打分模块 `server/app/features/personalized_reco/scoring/` 与单元测试 `server/tests/test_scoring.py`。 +- 2026-02-02:完成 `modules/rerank-freqcap/` 的 `plan.md` 与 `tasks.md`,并新增后端重排/频控模块 `server/app/features/personalized_reco/rerank_freqcap/` 与单元测试 `server/tests/test_rerank_freqcap.py`。 +- 2026-02-02:完成 `modules/observability/` 的 `plan.md` 与 `tasks.md`,并新增后端可观测模块 `server/app/features/personalized_reco/observability/` 与单元测试 `server/tests/test_observability.py`。 +- 2026-02-02:完成 `modules/reco-engine/` 的 `plan.md` 与 `tasks.md`,并新增后端引擎编排模块 `server/app/features/personalized_reco/reco_engine/`(含 Hard Filter + Orchestrator)与单元测试 `server/tests/test_reco_engine.py`。 +- 2026-02-02:完成 `modules/integration-api-worker/` 的 `plan.md` 与 `tasks.md`,并新增后端集成: + - FastAPI:`server/app/api/v1/reco.py`、`server/app/api/limits.py`、`server/app/main.py` + - Celery:`server/app/tasks/reco.py` + - 测试:`server/tests/test_integration_api_worker.py` + - 依赖:`server/requirements.txt`(新增 `httpx` 用于 API 测试) diff --git a/spec_kit/overview.md b/spec_kit/overview.md index 866b3b6..b01b929 100644 --- a/spec_kit/overview.md +++ b/spec_kit/overview.md @@ -19,6 +19,13 @@ - Expo 工程已在 `client/` 初始化,并完成 `pnpm install` - i18n 基座已接入:5 份语言资源 + 设备语言优先/设置可切换/持久化 + 入口初始化 +## Client User Identity + +- **目标**:在无账号体系或账号可选的前提下,为客户端建立稳定的 `client_user_id`,用于与 APNs/FCM 的 Push Token 做绑定,支撑精准推送与 Token 变更更新 +- **关键结论**:`client_user_id` 使用**随机 UUID(默认 UUID v4)**合适;将其视为不透明字符串,不使用可追踪设备硬件标识 +- **阶段产物**: + - `spec_kit/Client User Identity/spec.md` + ## Project Bootstrap - **目标**:完成项目仓库初始化与工程约定落地,明确 client/server 结构、dev/pro 环境隔离、MySQL/Redis 资源命名与访问边界 @@ -89,3 +96,5 @@ - **测试覆盖**:提供“输入题目答案 → 输出用户画像”的回归用例,覆盖字段映射、置信度衰减与硬规则触发(可选但建议) - **阶段产物**: - `spec_kit/User Profile Scoring/spec.md` +- **已完成编码(阶段性)**: + - 客户端 Onboarding 完成时已收集问卷答案并生成用户画像,写入本地存储供推荐/Push/Widget 复用