Compare commits

...

5 Commits

Author SHA1 Message Date
qzl 69b34bd723 feat: 添加起卦教程首次访问追踪和Agent时间上下文
- 后端 ProfileSettingsV1 添加 DivinationTutorialSettings 字段
- 前端三个起卦页面添加首次访问检测,自动弹出教程
- 教程展示后更新 settings 标记,避免重复弹出
- 使用本地状态管理避免并发更新覆盖问题
- Agent 系统提示添加时间上下文信息
2026-04-15 18:56:41 +08:00
qzl 55eeab43df feat(divination): 为解卦结果的断卦要点添加复制按钮 2026-04-15 18:23:01 +08:00
qzl edafb4dc50 chore: record journal 2026-04-15 18:19:25 +08:00
qzl 0bb7d77a3f chore(task): archive 04-15-session-deletion-anonymization 2026-04-15 18:19:20 +08:00
qzl c2b726e7bd feat(agent): session deletion anonymization for iOS compliance
Replace soft-delete with anonymize + hard-delete to meet iOS App Store
data retention requirements. Non-PII fields are preserved in
anonymous_session_snapshots for analytics.

- Add anonymous_session_snapshots table and ORM model
- Implement anonymizer to extract non-PII fields before deletion
- Remove points_ledger.biz_id FK constraint (snapshot-style reference)
- Preserve transaction history while allowing session deletion
- Add 14 unit tests + 1 integration test
2026-04-15 18:18:39 +08:00
31 changed files with 1569 additions and 36 deletions
@@ -0,0 +1,330 @@
# PRD: Divination Tutorial First-Visit Tracking
## Background
用户首次进入起卦相关页面时,应该自动弹出教程引导。当前实现已有教程弹窗逻辑(用户可主动点击按钮触发),但缺少"首次访问自动弹出"的能力。
需要区分三个页面的首次访问状态:
1. **起卦方式选择页面** (divination_screen.dart) - 显示手动起卦教程弹窗 (DivinationGuideDialog)
2. **自动起卦页面** (auto_divination_screen.dart) - 显示 Onboarding 步骤引导
3. **手动起卦页面** (manual_divination_screen.dart) - 显示 Onboarding 步骤引导
## Goal
1. 在用户 profile 的 settings 中添加字段,记录三个页面的教程是否已展示
2. 前端在进入对应页面时检查 settings,首次访问自动弹出教程
3. 教程展示后,前端调用后端 API 更新 settings 标记为已展示
4. 老用户(settings 中没有这些字段)默认视为已看过教程,不弹出
## Scope
- **In scope**:
- 后端: ProfileSettingsV1 schema 添加 `divination_tutorial` 字段
- 后端: 更新 API 端点支持新字段
- 前端: ProfileSettingsV1 model 添加对应字段
- 前端: 三个起卦页面的首次访问检测和教程自动弹出逻辑
- **Out of scope**:
- 新增教程内容(复用现有教程逻辑)
- 用户主动触发教程的按钮(已存在,保持不变)
## Current Architecture
### 前端现有教程逻辑
| 页面 | 文件路径 | 教程触发方式 |
|------|----------|-------------|
| 起卦方式选择 | `apps/lib/features/divination/presentation/screens/divination_screen.dart` | `DivinationGuideDialog` 弹窗,`_showGuide()` 函数 |
| 自动起卦 | `apps/lib/features/divination/presentation/screens/auto_divination_screen.dart` | `Onboarding` 组件,`_showGuide()` 函数 |
| 手动起卦 | `apps/lib/features/divination/presentation/screens/manual_divination_screen.dart` | `Onboarding` 组件,`_showGuide()` 函数 |
### 后端 Settings 结构
**文件**: `backend/src/schemas/shared/user.py`
```python
class ProfileSettingsV1(BaseModel):
version: Literal[1] = 1
preferences: PreferenceSettings = Field(default_factory=PreferenceSettings)
privacy: dict[str, object] = Field(default_factory=dict)
notification: NotificationSettings = Field(default_factory=NotificationSettings)
```
### 前端 Settings Model
**文件**: `apps/lib/features/settings/data/models/profile_settings.dart`
```dart
class ProfileSettingsV1 {
final int version;
final String displayName;
final String bio;
final String? avatarPath;
final String? avatarUrl;
final PreferenceSettings preferences;
final Map<String, Object?> privacy;
final NotificationSettings notification;
}
```
### 更新 Settings API
- **端点**: `PATCH /users/me/settings`
- **Service**: `backend/src/v1/users/service.py``update_settings()`
- **前端 API**: `apps/lib/features/settings/data/apis/profile_api.dart``updateSettings()`
## Technical Design
### 1. 后端 Schema 变更
**新增 `DivinationTutorialSettings`**:
```python
class DivinationTutorialSettings(BaseModel):
"""起卦教程首次访问状态"""
divination_entry_shown: bool = False # 起卦方式选择页教程已展示
auto_divination_shown: bool = False # 自动起卦页教程已展示
manual_divination_shown: bool = False # 手动起卦页教程已展示
```
**修改 `ProfileSettingsV1`**:
```python
class ProfileSettingsV1(BaseModel):
version: Literal[1] = 1
preferences: PreferenceSettings = Field(default_factory=PreferenceSettings)
privacy: dict[str, object] = Field(default_factory=dict)
notification: NotificationSettings = Field(default_factory=NotificationSettings)
divination_tutorial: DivinationTutorialSettings = Field(default_factory=DivinationTutorialSettings)
```
### 2. 前端 Model 变更
**新增 `DivinationTutorialSettings`**:
```dart
class DivinationTutorialSettings {
const DivinationTutorialSettings({
this.divinationEntryShown = false,
this.autoDivinationShown = false,
this.manualDivinationShown = false,
});
final bool divinationEntryShown;
final bool autoDivinationShown;
final bool manualDivinationShown;
DivinationTutorialSettings copyWith({...});
}
```
**修改 `ProfileSettingsV1`**:
```dart
class ProfileSettingsV1 {
// ... existing fields ...
final DivinationTutorialSettings divinationTutorial;
ProfileSettingsV1 copyWith({
// ... existing params ...
DivinationTutorialSettings? divinationTutorial,
});
}
```
### 3. 前端页面逻辑变更
**起卦方式选择页面** (`divination_screen.dart`):
```dart
// 在 initState 或 didChangeDependencies 中检查
void _checkFirstVisit() {
final settings = // 从 widget 或 context 获取 profileSettings
if (!settings.divinationTutorial.divinationEntryShown) {
// 延迟显示,确保页面渲染完成
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _showGuide(context, l10n);
});
// 调用 API 更新 settings
_markTutorialShown('divination_entry');
}
}
Future<void> _markTutorialShown(String tutorialKey) async {
// 调用 onProfileSettingsChanged 更新 settings
}
```
**自动起卦页面** (`auto_divination_screen.dart`):
```dart
void _checkFirstVisit() {
final settings = // 获取 profileSettings
if (!settings.divinationTutorial.autoDivinationShown) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _showGuide();
});
_markTutorialShown('auto_divination');
}
}
```
**手动起卦页面** (`manual_divination_screen.dart`):
```dart
void _checkFirstVisit() {
final settings = // 获取 profileSettings
if (!settings.divinationTutorial.manualDivinationShown) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _showGuide();
});
_markTutorialShown('manual_divination');
}
}
```
### 4. Settings 传递方式(已确认)
**采用构造函数参数传入方式**(选项 A)。
**调用链分析**:
```
EryaoApp (app.dart)
└── HomeScreen
├── profileSettings: ProfileSettingsV1
├── onProfileSettingsChanged: Future<void> Function(ProfileSettingsV1)
└── _onStartDivination()
└── DivinationScreen <-- 需要添加 profileSettings 和 onProfileSettingsChanged
├── _onStart() → AutoDivinationScreen <-- 需要添加
└── _onStart() → ManualDivinationScreen <-- 需要添加
```
**需要修改的文件**:
| 文件 | 修改内容 |
|------|----------|
| `home_screen.dart:155-166` | `_onStartDivination()` 传入 `profileSettings``onProfileSettingsChanged` |
| `divination_screen.dart` | 构造函数添加 `profileSettings``onProfileSettingsChanged`;添加首次访问检测 |
| `divination_screen.dart:164-176` | `_onStart()` 传入参数到 `ManualDivinationScreen``AutoDivinationScreen` |
| `auto_divination_screen.dart` | 构造函数添加参数;添加首次访问检测 |
| `manual_divination_screen.dart` | 构造函数添加参数;添加首次访问检测 |
### 5. 教程关闭回调处理
**Onboarding 组件** (用于自动/手动起卦页面):
- `Onboarding` 组件有 `onEnd` 回调参数
-`onEnd` 中调用 `_markTutorialShown()`
**DivinationGuideDialog** (用于起卦方式选择页面):
- 这是一个 `Dialog`,关闭时 `Navigator.pop()` 被调用
- 需要在 `showDialog``.then()` 中处理关闭回调
- 或者在弹窗显示后立即标记(因为用户已经看到了)
**推荐方案**: 在弹窗显示后立即标记,无需等待关闭
- 用户看到教程即视为"已展示"
- 避免因用户快速关闭导致 settings 未更新
### 6. 老用户兼容
- 后端新字段默认值为 `false`
- 数据库中现有用户 settings 没有 `divination_tutorial` 字段
- 前端解析时:
- 如果字段不存在,**视为已看过教程**(不弹出)
- 实现方式: JSON 解析时提供默认值 `true`,或检查字段是否存在
**前端兼容逻辑**:
```dart
factory DivinationTutorialSettings.fromJson(Map<String, dynamic>? json) {
if (json == null) {
// 老用户没有这个字段,默认全部 true(已看过)
return const DivinationTutorialSettings(
divinationEntryShown: true,
autoDivinationShown: true,
manualDivinationShown: true,
);
}
return DivinationTutorialSettings(
divinationEntryShown: json['divination_entry_shown'] ?? true,
autoDivinationShown: json['auto_divination_shown'] ?? true,
manualDivinationShown: json['manual_divination_shown'] ?? true,
);
}
```
### 6. API 更新流程
用户首次看到教程后:
1. 前端调用 `_markTutorialShown('auto_divination')`
2. 构建 `ProfileSettingsV1` 的 copyWith 更新对应字段
3. 调用 `onProfileSettingsChanged(updatedSettings)`
4. 后端保存新 settings
**注意**: 只更新一个字段时,需要保留其他字段不变。
### 7. Onboarding 组件 onEnd 回调
查看 `auto_divination_screen.dart:145-148`:
```dart
return Onboarding(
key: _onboardingKey,
steps: steps,
onChanged: _onGuideStepChanged,
// 需要添加: onEnd: _markTutorialShown,
);
```
`manual_divination_screen.dart` 同理。
## Implementation Checklist
### 后端
- [ ] `backend/src/schemas/shared/user.py` 添加 `DivinationTutorialSettings` 和修改 `ProfileSettingsV1`
- [ ] 确认 API 端点无需修改(使用现有的 PATCH + 完整 settings 替换)
- [ ] 单元测试: 新 schema 的序列化/反序列化
### 前端
- [ ] `apps/lib/features/settings/data/models/profile_settings.dart` 添加 `DivinationTutorialSettings`
- [ ] `apps/lib/features/settings/data/apis/profile_api.dart` 更新 `_toSettings()` 解析逻辑
- [ ] 确认三个页面如何获取 `ProfileSettingsV1`(需要调查)
- [ ] `divination_screen.dart` 添加首次访问检测
- [ ] `auto_divination_screen.dart` 添加首次访问检测
- [ ] `manual_divination_screen.dart` 添加首次访问检测
- [ ] 测试老用户兼容性(settings 无此字段时不弹出)
### 测试场景
1. **新用户首次访问**: 进入页面 → 自动弹出教程 → settings 更新为 shown
2. **新用户再次访问**: 进入页面 → 不弹出教程
3. **老用户首次访问**: settings 无此字段 → 不弹出教程(视为已看过)
4. **用户主动触发**: 点击教程按钮 → 正常弹出(无论是否首次)
## Risks & Mitigations
| 风险 | 缓解措施 |
|------|---------|
| 前端解析 settings 时字段缺失 | 使用 `?? true` 默认值,老用户视为已看过 |
| 教程弹出时机过早导致 UI 闪烁 | 使用 `addPostFrameCallback` 延迟到下一帧 |
| API 调用失败导致 settings 未更新 | 不影响用户体验,下次访问仍会弹出(符合预期) |
| 并发更新 settings 可能覆盖其他字段 | 前端使用 copyWith 保留其他字段 |
## Open Questions
1. ~~**Settings 传递方式**~~: 已确认使用构造函数参数传入。
2. ~~**教程关闭回调**~~: 建议在弹窗显示后立即标记,无需等待关闭。
## File Changes Summary
| 文件 | 类型 | 修改内容 |
|------|------|----------|
| `backend/src/schemas/shared/user.py` | 后端 | 添加 `DivinationTutorialSettings` 和修改 `ProfileSettingsV1` |
| `apps/lib/features/settings/data/models/profile_settings.dart` | 前端 | 添加 `DivinationTutorialSettings` 和修改 `ProfileSettingsV1` |
| `apps/lib/features/settings/data/apis/profile_api.dart` | 前端 | 更新 `_toSettings()` 解析新字段 |
| `apps/lib/features/home/presentation/screens/home_screen.dart` | 前端 | `_onStartDivination()` 传入 `profileSettings``onProfileSettingsChanged` |
| `apps/lib/features/divination/presentation/screens/divination_screen.dart` | 前端 | 添加构造函数参数 + 首次访问检测 + `_markTutorialShown()` |
| `apps/lib/features/divination/presentation/screens/auto_divination_screen.dart` | 前端 | 添加构造函数参数 + 首次访问检测 + `_markTutorialShown()` |
| `apps/lib/features/divination/presentation/screens/manual_divination_screen.dart` | 前端 | 添加构造函数参数 + 首次访问检测 + `_markTutorialShown()` |
@@ -0,0 +1,44 @@
{
"id": "divination-tutorial-first-visit",
"name": "divination-tutorial-first-visit",
"title": "divination-tutorial-first-visit",
"description": "",
"status": "planning",
"dev_type": null,
"scope": null,
"priority": "P2",
"creator": "zl-q",
"assignee": "zl-q",
"createdAt": "2026-04-15",
"completedAt": null,
"branch": null,
"base_branch": "dev",
"worktree_path": null,
"current_phase": 0,
"next_action": [
{
"phase": 1,
"action": "implement"
},
{
"phase": 2,
"action": "check"
},
{
"phase": 3,
"action": "finish"
},
{
"phase": 4,
"action": "create-pr"
}
],
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -51,17 +51,29 @@ Replace the current soft-delete flow with an **anonymize-then-hard-delete** stra
| Table | Column | Content |
|-------|--------|---------|
| sessions | session_type | 'chat' / 'automation' |
| sessions | session_type | 'chat' |
| sessions | status | pending/running/completed/failed |
| sessions | total_tokens | Usage metric |
| sessions | total_cost | Usage metric |
| sessions | message_count | Counter |
| sessions | message_count | Counter (used for follow-up ratio analysis) |
| sessions | created_at / last_activity_at | Timestamps |
| messages | role | user/assistant/system/tool |
| messages | model_code | LLM model identifier |
| messages | tool_name | Tool name (divination type) |
| messages | metadata->agent_output->divination_derived->questionType | Question category (career/love/wealth/health) |
| messages | input_tokens / output_tokens / cost / latency_ms | Usage and performance metrics |
| messages | tool_name | Divination tool name |
| messages | latency_ms | Response latency |
| messages->metadata | agent_output.sign_level | Sign level (上上签/中上签/中下签/下下签) |
| messages->metadata | agent_output.keywords | Key insights from reading |
| messages->metadata | agent_output.divination_derived.questionType | Question category (career/love/wealth/health) |
| messages->metadata | agent_output.divination_derived.guaName | Hexagram name |
| messages->metadata | agent_output.divination_derived.guaNameHant | Hexagram name (Traditional Chinese) |
| messages->metadata | agent_output.divination_derived.targetGuaName | Target hexagram name (if changing lines exist) |
| messages->metadata | agent_output.divination_derived.hasChangingYao | Whether session has changing lines |
**Analytics Requirements:**
1. **Question type distribution**: Count by `question_type`
2. **Follow-up ratio**: `message_count > 2` indicates follow-up questions
3. **LLM performance comparison**: Group by `model_code`, analyze `status`, `total_latency_ms`, `total_tokens`
4. **Hexagram accuracy analysis**: Distribution of `sign_level`, `gua_name`, `has_changing_yao`
## Technical Design
@@ -71,18 +83,34 @@ Replace the current soft-delete flow with an **anonymize-then-hard-delete** stra
CREATE TABLE anonymous_session_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
anonymous_id UUID NOT NULL, -- Random UUID, no link to real user
session_type VARCHAR(20) NOT NULL, -- 'chat' / 'automation'
question_type VARCHAR(50), -- Career/love/wealth/health etc. (from metadata)
tool_name VARCHAR(100), -- Divination tool used
-- Session metadata
session_type VARCHAR(20) NOT NULL, -- 'chat'
message_count INTEGER, -- Used for follow-up ratio analysis
status VARCHAR(20), -- Session final status
-- Question & divination
question_type VARCHAR(50), -- Career/love/wealth/health etc.
tool_name VARCHAR(100), -- Divination tool name
-- Hexagram details (for accuracy analysis)
gua_name VARCHAR(50), -- Hexagram name
gua_name_hant VARCHAR(50), -- Hexagram name (Traditional Chinese)
target_gua_name VARCHAR(50), -- Target hexagram (if changing lines exist)
has_changing_yao BOOLEAN, -- Whether session has changing lines
sign_level VARCHAR(20), -- 上上签/中上签/中下签/下下签
keywords TEXT[], -- Key insights from reading
-- Model & usage metrics
model_code VARCHAR(50), -- LLM model used
total_tokens INTEGER, -- Token usage
total_cost NUMERIC, -- Cost metric
message_count INTEGER, -- Message count
status VARCHAR(20), -- Session final status
total_latency_ms INTEGER, -- Aggregated latency
anonymized_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL, -- Original session creation time (date only precision)
last_activity_at TIMESTAMPTZ -- Original last activity (date only precision)
-- Timestamps (day precision to prevent re-identification)
created_at TIMESTAMPTZ NOT NULL, -- Original session creation time
last_activity_at TIMESTAMPTZ, -- Original last activity
anonymized_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- RLS: service role only, no user access
@@ -95,8 +123,9 @@ CREATE POLICY "Service role can manage anonymous snapshots"
Design notes:
- `anonymous_id` is a randomly generated UUID with **no mapping** back to the original user
- Timestamps are stored with **date-only precision** (day granularity) to prevent re-identification via time correlation
- `question_type` is the only content-derived field retained - it's a category label (career/love/wealth/health), not the actual question text
- No `user_id`, no session content, no AI responses - only aggregate metrics
- `session_type` only supports 'chat' (AUTOMATION is legacy from reused database schema, not used in this project)
- All structued non-PII fields are retained for flexible future analysis (principle: "complete retention, filter on analysis")
- No `user_id`, no question text, no AI response text - only structured/aggregate metrics
- RLS ensures no user (even authenticated) can access this table, only service_role
### 2. Anonymization Service
@@ -120,9 +149,12 @@ class SessionAnonymizer:
```
Key anonymization rules:
- **Strip entirely**: `user_id`, `title`, `state_snapshot`, `content` (all message content), `user_message_attachments`, full `agent_output` / `tool_agent_output`
- **Retain as-is**: `session_type`, `status`, `total_tokens`, `total_cost`, `message_count`, `model_code`, `tool_name`
- **Transform**: timestamps truncated to day precision; `questionType` extracted from metadata as category label only
- **Strip entirely**: `user_id`, `title`, `state_snapshot`, `content` (all message content), `question` (user's original text), `answer` (AI response text), `user_message_attachments`, raw `agent_output` / `tool_agent_output` objects
- **Retain structured fields**:
- Session: `session_type`, `status`, `total_tokens`, `total_cost`, `message_count`
- Divination: `question_type`, `tool_name`, `gua_name`, `gua_name_hant`, `target_gua_name`, `has_changing_yao`, `sign_level`, `keywords`
- Model: `model_code`
- **Transform**: timestamps truncated to day precision
- **Aggregate**: sum `latency_ms` across all messages into `total_latency_ms`
### 3. Modified Deletion Flow
@@ -3,14 +3,14 @@
"name": "session-deletion-anonymization",
"title": "Session deletion anonymization for iOS compliance",
"description": "Implement iOS-compliant data anonymization for divination session deletion: desensitize PII, retain anonymized usage data, hard-delete original records",
"status": "planning",
"status": "completed",
"dev_type": null,
"scope": null,
"priority": "P1",
"creator": "zl-q",
"assignee": "zl-q",
"createdAt": "2026-04-15",
"completedAt": null,
"completedAt": "2026-04-15",
"branch": null,
"base_branch": "dev",
"worktree_path": null,
+3 -2
View File
@@ -8,7 +8,7 @@
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 7
- **Total Sessions**: 8
- **Last Active**: 2026-04-15
<!-- @@@/auto:current-status -->
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~413 | Active |
| `journal-1.md` | ~445 | Active |
<!-- @@@/auto:active-documents -->
---
@@ -29,6 +29,7 @@
<!-- @@@auto:session-history -->
| # | Date | Title | Commits |
|---|------|-------|---------|
| 8 | 2026-04-15 | Session deletion anonymization for iOS compliance | `c2b726e` |
| 7 | 2026-04-15 | 六爻算法修复 + Prompt架构重构 + i18n输出规则 | `9598d16`, `be68681` |
| 6 | 2026-04-13 | 修复追问链路与上限判定 | - |
| 5 | 2026-04-13 | feat: 邀请码显示功能 - 后端API + 前端对接 | - |
+32
View File
@@ -411,3 +411,35 @@
### Next Steps
- None - task complete
## Session 8: Session deletion anonymization for iOS compliance
**Date**: 2026-04-15
**Task**: Session deletion anonymization for iOS compliance
### Summary
Replace soft-delete with anonymize + hard-delete. Add anonymous_session_snapshots table for analytics. Remove points_ledger.biz_id FK constraint for snapshot-style reference.
### Main Changes
### Git Commits
| Hash | Message |
|------|---------|
| `c2b726e` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
@@ -18,6 +18,7 @@ import '../../../../shared/widgets/divination/yao_line_row.dart';
import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../settings/data/models/profile_settings.dart';
import '../../data/models/divination_backend_models.dart';
import '../../data/apis/divination_api.dart';
import '../../data/models/divination_params.dart';
@@ -33,12 +34,17 @@ class AutoDivinationScreen extends StatefulWidget {
required this.runService,
this.divinationApi,
required this.onCompleted,
required this.profileSettings,
required this.onProfileSettingsChanged,
});
final DivinationParams params;
final DivinationRunService runService;
final DivinationApi? divinationApi;
final Future<void> Function(DivinationResultData result) onCompleted;
final ProfileSettingsV1 profileSettings;
final Future<void> Function(ProfileSettingsV1 settings)
onProfileSettingsChanged;
@override
State<AutoDivinationScreen> createState() => _AutoDivinationScreenState();
@@ -64,6 +70,8 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
DateTime _lastShake = DateTime.fromMillisecondsSinceEpoch(0);
bool _spinLocked = false;
bool _submitting = false;
bool _tutorialChecked = false;
late DivinationTutorialSettings _localTutorialSettings;
final GlobalKey<OnboardingState> _onboardingKey =
GlobalKey<OnboardingState>();
@@ -89,6 +97,37 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
duration: const Duration(milliseconds: 500),
)..repeat(reverse: true);
_listenShake();
_localTutorialSettings = widget.profileSettings.divinationTutorial;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_tutorialChecked) {
_tutorialChecked = true;
_checkFirstVisit();
}
}
void _checkFirstVisit() {
if (!_localTutorialSettings.autoDivinationShown) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_showGuide();
});
}
}
Future<void> _markTutorialShown() async {
setState(() {
_localTutorialSettings = _localTutorialSettings.copyWith(
autoDivinationShown: true,
);
});
final updated = widget.profileSettings.copyWith(
divinationTutorial: _localTutorialSettings,
);
await widget.onProfileSettingsChanged(updated);
}
@override
@@ -176,6 +215,7 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
key: _onboardingKey,
steps: steps,
onChanged: _onGuideStepChanged,
onEnd: (_) => _markTutorialShown(),
child: SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(AppSpacing.xl),
@@ -547,6 +547,7 @@ class _FocusPointsCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
final languageCode = Localizations.localeOf(context).languageCode;
final title = languageCode == 'en' ? 'Focus Points' : '断卦要点';
if (points.isEmpty) {
@@ -564,12 +565,33 @@ class _FocusPointsCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
Row(
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
const Spacer(),
TextButton(
onPressed: () {
final content = points
.asMap()
.entries
.map((e) => '${e.key + 1}. ${e.value}')
.join('\n');
Clipboard.setData(ClipboardData(text: content));
Toast.show(
context,
l10n.toastContentCopiedWithTitle(title),
type: ToastType.success,
);
},
child: Text(l10n.resultCopy),
),
],
),
const SizedBox(height: AppSpacing.sm),
...List<Widget>.generate(points.length, (index) {
@@ -10,6 +10,7 @@ import '../../../../shared/widgets/gua_icon.dart';
import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../settings/data/models/profile_settings.dart';
import '../../data/apis/divination_api.dart';
import '../../data/models/divination_params.dart';
import '../../data/models/divination_result.dart';
@@ -26,6 +27,8 @@ class DivinationScreen extends StatefulWidget {
this.runServiceOverride,
this.divinationApiOverride,
this.allowVibration = true,
required this.profileSettings,
required this.onProfileSettingsChanged,
});
final SessionStore sessionStore;
@@ -34,6 +37,9 @@ class DivinationScreen extends StatefulWidget {
final DivinationRunService? runServiceOverride;
final DivinationApi? divinationApiOverride;
final bool allowVibration;
final ProfileSettingsV1 profileSettings;
final Future<void> Function(ProfileSettingsV1 settings)
onProfileSettingsChanged;
@override
State<DivinationScreen> createState() => _DivinationScreenState();
@@ -44,6 +50,8 @@ class _DivinationScreenState extends State<DivinationScreen> {
final TextEditingController _questionController = TextEditingController();
late final DivinationApi _divinationApi;
late final DivinationRunService _runService;
bool _tutorialChecked = false;
late DivinationTutorialSettings _localTutorialSettings;
@override
void initState() {
@@ -65,6 +73,47 @@ class _DivinationScreenState extends State<DivinationScreen> {
userId: widget.userId,
);
_questionController.addListener(_syncQuestion);
_localTutorialSettings = widget.profileSettings.divinationTutorial;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_tutorialChecked) {
_tutorialChecked = true;
_checkFirstVisit();
}
}
void _checkFirstVisit() {
if (!_localTutorialSettings.divinationEntryShown) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final l10n = AppLocalizations.of(context)!;
_showGuide(context, l10n);
_markTutorialShown('divination_entry');
});
}
}
Future<void> _markTutorialShown(String key) async {
setState(() {
_localTutorialSettings = _localTutorialSettings.copyWith(
divinationEntryShown: key == 'divination_entry'
? true
: _localTutorialSettings.divinationEntryShown,
autoDivinationShown: key == 'auto_divination'
? true
: _localTutorialSettings.autoDivinationShown,
manualDivinationShown: key == 'manual_divination'
? true
: _localTutorialSettings.manualDivinationShown,
);
});
final updated = widget.profileSettings.copyWith(
divinationTutorial: _localTutorialSettings,
);
await widget.onProfileSettingsChanged(updated);
}
@override
@@ -161,6 +210,10 @@ class _DivinationScreenState extends State<DivinationScreen> {
return;
}
final updatedSettings = widget.profileSettings.copyWith(
divinationTutorial: _localTutorialSettings,
);
if (_params.method == DivinationMethod.manual) {
final nextParams = _params.copyWith(divinationTime: DateTime.now());
Navigator.of(context).push(
@@ -170,6 +223,8 @@ class _DivinationScreenState extends State<DivinationScreen> {
runService: _runService,
divinationApi: _divinationApi,
onCompleted: widget.onCompleted,
profileSettings: updatedSettings,
onProfileSettingsChanged: widget.onProfileSettingsChanged,
),
),
);
@@ -187,6 +242,8 @@ class _DivinationScreenState extends State<DivinationScreen> {
runService: _runService,
divinationApi: _divinationApi,
onCompleted: widget.onCompleted,
profileSettings: updatedSettings,
onProfileSettingsChanged: widget.onProfileSettingsChanged,
),
),
);
@@ -15,6 +15,7 @@ import '../../../../shared/widgets/divination/yao_line_row.dart';
import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../settings/data/models/profile_settings.dart';
import '../../data/models/divination_backend_models.dart';
import '../../data/apis/divination_api.dart';
import '../../data/models/divination_params.dart';
@@ -30,12 +31,17 @@ class ManualDivinationScreen extends StatefulWidget {
required this.runService,
this.divinationApi,
required this.onCompleted,
required this.profileSettings,
required this.onProfileSettingsChanged,
});
final DivinationParams params;
final DivinationRunService runService;
final DivinationApi? divinationApi;
final Future<void> Function(DivinationResultData result) onCompleted;
final ProfileSettingsV1 profileSettings;
final Future<void> Function(ProfileSettingsV1 settings)
onProfileSettingsChanged;
@override
State<ManualDivinationScreen> createState() => _ManualDivinationScreenState();
@@ -47,6 +53,8 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
final List<YaoType?> _selectedYaos = List<YaoType?>.filled(6, null);
late final AnimationController _blinkController;
bool _submitting = false;
bool _tutorialChecked = false;
late DivinationTutorialSettings _localTutorialSettings;
final GlobalKey<OnboardingState> _onboardingKey =
GlobalKey<OnboardingState>();
final ScrollController _scrollController = ScrollController();
@@ -66,6 +74,37 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
vsync: this,
duration: const Duration(milliseconds: 500),
)..repeat(reverse: true);
_localTutorialSettings = widget.profileSettings.divinationTutorial;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_tutorialChecked) {
_tutorialChecked = true;
_checkFirstVisit();
}
}
void _checkFirstVisit() {
if (!_localTutorialSettings.manualDivinationShown) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_showGuide();
});
}
}
Future<void> _markTutorialShown() async {
setState(() {
_localTutorialSettings = _localTutorialSettings.copyWith(
manualDivinationShown: true,
);
});
final updated = widget.profileSettings.copyWith(
divinationTutorial: _localTutorialSettings,
);
await widget.onProfileSettingsChanged(updated);
}
@override
@@ -146,6 +185,7 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
key: _onboardingKey,
steps: guideSteps,
onChanged: _onGuideStepChanged,
onEnd: (_) => _markTutorialShown(),
child: SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(AppSpacing.xl),
@@ -127,6 +127,8 @@ class _HomeScreenState extends State<HomeScreen> {
allowVibration: widget.profileSettings.notification.allowVibration,
notificationBloc: widget.notificationBloc,
notificationRepository: widget.notificationRepository,
profileSettings: widget.profileSettings,
onProfileSettingsChanged: widget.onProfileSettingsChanged,
),
_ProfileTab(
account: widget.account,
@@ -160,6 +162,8 @@ class _HomeScreenState extends State<HomeScreen> {
userId: widget.account,
onCompleted: widget.onDivinationCompleted,
allowVibration: widget.profileSettings.notification.allowVibration,
profileSettings: widget.profileSettings,
onProfileSettingsChanged: widget.onProfileSettingsChanged,
),
),
);
@@ -177,6 +181,8 @@ class _HomeTab extends StatelessWidget {
required this.allowVibration,
required this.notificationBloc,
required this.notificationRepository,
required this.profileSettings,
required this.onProfileSettingsChanged,
});
final List<DivinationResultData> historyItems;
@@ -189,6 +195,9 @@ class _HomeTab extends StatelessWidget {
final bool allowVibration;
final NotificationBloc notificationBloc;
final NotificationRepository notificationRepository;
final ProfileSettingsV1 profileSettings;
final Future<void> Function(ProfileSettingsV1 settings)
onProfileSettingsChanged;
@override
Widget build(BuildContext context) {
@@ -304,6 +313,9 @@ class _HomeTab extends StatelessWidget {
userId: userId,
onCompleted: onDivinationCompleted,
allowVibration: allowVibration,
profileSettings: profileSettings,
onProfileSettingsChanged:
onProfileSettingsChanged,
),
),
);
@@ -51,6 +51,14 @@ class ProfileApi {
'allow_notifications': settings.notification.allowNotifications,
'allow_vibration': settings.notification.allowVibration,
},
'divination_tutorial': {
'divination_entry_shown':
settings.divinationTutorial.divinationEntryShown,
'auto_divination_shown':
settings.divinationTutorial.autoDivinationShown,
'manual_divination_shown':
settings.divinationTutorial.manualDivinationShown,
},
},
};
final json = await _apiClient.rawDio.patch<Map<String, dynamic>>(
@@ -119,6 +127,23 @@ class ProfileApi {
)
: const NotificationSettings();
final divinationTutorialRaw = settingsRaw is Map<String, dynamic>
? settingsRaw['divination_tutorial']
: null;
final divinationTutorial = divinationTutorialRaw is Map<String, dynamic>
? DivinationTutorialSettings(
divinationEntryShown:
(divinationTutorialRaw['divination_entry_shown'] as bool?) ??
true,
autoDivinationShown:
(divinationTutorialRaw['auto_divination_shown'] as bool?) ??
true,
manualDivinationShown:
(divinationTutorialRaw['manual_divination_shown'] as bool?) ??
true,
)
: const DivinationTutorialSettings();
return ProfileSettingsV1(
displayName: (json['display_name'] as String?) ?? '',
bio: (json['bio'] as String?) ?? '',
@@ -130,6 +155,7 @@ class ProfileApi {
const <String, dynamic>{})
: const <String, dynamic>{},
notification: notification,
divinationTutorial: divinationTutorial,
);
}
}
@@ -59,6 +59,31 @@ class NotificationSettings {
}
}
class DivinationTutorialSettings {
const DivinationTutorialSettings({
this.divinationEntryShown = true,
this.autoDivinationShown = true,
this.manualDivinationShown = true,
});
final bool divinationEntryShown;
final bool autoDivinationShown;
final bool manualDivinationShown;
DivinationTutorialSettings copyWith({
bool? divinationEntryShown,
bool? autoDivinationShown,
bool? manualDivinationShown,
}) {
return DivinationTutorialSettings(
divinationEntryShown: divinationEntryShown ?? this.divinationEntryShown,
autoDivinationShown: autoDivinationShown ?? this.autoDivinationShown,
manualDivinationShown:
manualDivinationShown ?? this.manualDivinationShown,
);
}
}
class ProfileSettingsV1 {
const ProfileSettingsV1({
this.version = 1,
@@ -69,6 +94,7 @@ class ProfileSettingsV1 {
this.preferences = const PreferenceSettings(),
this.privacy = const <String, Object?>{},
this.notification = const NotificationSettings(),
this.divinationTutorial = const DivinationTutorialSettings(),
});
final int version;
@@ -79,6 +105,7 @@ class ProfileSettingsV1 {
final PreferenceSettings preferences;
final Map<String, Object?> privacy;
final NotificationSettings notification;
final DivinationTutorialSettings divinationTutorial;
ProfileSettingsV1 copyWith({
int? version,
@@ -89,6 +116,7 @@ class ProfileSettingsV1 {
PreferenceSettings? preferences,
Map<String, Object?>? privacy,
NotificationSettings? notification,
DivinationTutorialSettings? divinationTutorial,
}) {
return ProfileSettingsV1(
version: version ?? this.version,
@@ -99,6 +127,7 @@ class ProfileSettingsV1 {
preferences: preferences ?? this.preferences,
privacy: privacy ?? this.privacy,
notification: notification ?? this.notification,
divinationTutorial: divinationTutorial ?? this.divinationTutorial,
);
}
@@ -11,6 +11,7 @@ import 'package:meeyao_qianwen/features/divination/data/services/divination_run_
import 'package:meeyao_qianwen/features/divination/presentation/screens/auto_divination_screen.dart';
import 'package:meeyao_qianwen/features/divination/presentation/screens/divination_screen.dart';
import 'package:meeyao_qianwen/features/divination/presentation/screens/manual_divination_screen.dart';
import 'package:meeyao_qianwen/features/settings/data/models/profile_settings.dart';
import 'package:meeyao_qianwen/l10n/app_localizations.dart';
void main() {
@@ -18,6 +19,9 @@ void main() {
api: DivinationApi(apiClient: ApiClient(baseUrl: 'http://localhost:5775')),
);
final sessionStore = SessionStore(LocalKvStore());
final profileSettings = ProfileSettingsV1.defaultsForLocale(
const Locale('zh'),
);
testWidgets('divination screen navigates to auto screen', (tester) async {
await tester.pumpWidget(
@@ -36,6 +40,8 @@ void main() {
userId: 'user_test',
onCompleted: (_) async {},
runServiceOverride: runService,
profileSettings: profileSettings,
onProfileSettingsChanged: (_) async {},
),
),
);
@@ -77,6 +83,8 @@ void main() {
params: params,
runService: runService,
onCompleted: (_) async {},
profileSettings: profileSettings,
onProfileSettingsChanged: (_) async {},
),
),
);
@@ -108,6 +116,8 @@ void main() {
userId: 'user_test',
onCompleted: (_) async {},
runServiceOverride: runService,
profileSettings: profileSettings,
onProfileSettingsChanged: (_) async {},
),
),
);
@@ -7,6 +7,7 @@ import 'package:meeyao_qianwen/features/divination/data/apis/divination_api.dart
import 'package:meeyao_qianwen/features/divination/data/models/divination_params.dart';
import 'package:meeyao_qianwen/features/divination/data/services/divination_run_service.dart';
import 'package:meeyao_qianwen/features/divination/presentation/screens/manual_divination_screen.dart';
import 'package:meeyao_qianwen/features/settings/data/models/profile_settings.dart';
import 'package:meeyao_qianwen/l10n/app_localizations.dart';
void main() {
@@ -24,6 +25,9 @@ void main() {
apiClient: ApiClient(baseUrl: 'http://localhost:5775'),
),
);
final profileSettings = ProfileSettingsV1.defaultsForLocale(
const Locale('zh'),
);
await tester.pumpWidget(
MaterialApp(
@@ -39,6 +43,8 @@ void main() {
params: params,
runService: runService,
onCompleted: (_) async {},
profileSettings: profileSettings,
onProfileSettingsChanged: (_) async {},
),
),
);
@@ -0,0 +1,111 @@
"""add anonymous_session_snapshots table for iOS compliance
Revision ID: 20260415_0001
Revises: 20260413_0004
Create Date: 2026-04-15 00:10:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260415_0001"
down_revision: Union[str, Sequence[str], None] = "20260413_0004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"anonymous_session_snapshots",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("anonymous_id", sa.UUID(), nullable=False),
sa.Column("session_type", sa.String(length=20), nullable=False),
sa.Column("message_count", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=True),
sa.Column("question_type", sa.String(length=50), nullable=True),
sa.Column("tool_name", sa.String(length=100), nullable=True),
sa.Column("gua_name", sa.String(length=50), nullable=True),
sa.Column("gua_name_hant", sa.String(length=50), nullable=True),
sa.Column("target_gua_name", sa.String(length=50), nullable=True),
sa.Column("has_changing_yao", sa.Boolean(), nullable=True),
sa.Column("sign_level", sa.String(length=20), nullable=True),
sa.Column("keywords", postgresql.ARRAY(sa.Text()), nullable=True),
sa.Column("model_code", sa.String(length=50), nullable=True),
sa.Column("total_tokens", sa.Integer(), nullable=True),
sa.Column("total_cost", sa.Numeric(12, 6), nullable=True),
sa.Column("total_latency_ms", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
),
sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"anonymized_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_anonymous_session_snapshots_anonymous_id",
"anonymous_session_snapshots",
["anonymous_id"],
unique=False,
)
op.create_index(
"ix_anonymous_session_snapshots_created_at",
"anonymous_session_snapshots",
["created_at"],
unique=False,
)
op.create_index(
"ix_anonymous_session_snapshots_question_type",
"anonymous_session_snapshots",
["question_type"],
unique=False,
)
_enable_service_role_only_rls("anonymous_session_snapshots")
def downgrade() -> None:
_drop_rls("anonymous_session_snapshots")
op.drop_index(
"ix_anonymous_session_snapshots_question_type",
table_name="anonymous_session_snapshots",
)
op.drop_index(
"ix_anonymous_session_snapshots_created_at",
table_name="anonymous_session_snapshots",
)
op.drop_index(
"ix_anonymous_session_snapshots_anonymous_id",
table_name="anonymous_session_snapshots",
)
op.drop_table("anonymous_session_snapshots")
def _enable_service_role_only_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(
f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")
op.execute(
f"CREATE POLICY service_role_all_{table_name} ON {table_name} FOR ALL TO service_role USING (true) WITH CHECK (true)"
)
def _drop_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(
f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"DROP POLICY IF EXISTS service_role_all_{table_name} ON {table_name}")
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
@@ -0,0 +1,40 @@
"""drop points_ledger.biz_id foreign key for snapshot-style reference
Revision ID: 20260415_0002
Revises: 20260415_0001
Create Date: 2026-04-15 10:00:00
points_ledger.biz_id stores a snapshot reference to sessions.id for audit purposes.
This allows sessions to be deleted while preserving the biz_id value in points_ledger
for user-facing transaction history.
The FK constraint is removed because:
1. Users need to see their points transaction history even after session deletion
2. Session deletion (anonymization for iOS compliance) should not cascade delete
points_ledger records
3. biz_id becomes a "snapshot" reference - the value is kept but no FK enforcement
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260415_0002"
down_revision: Union[str, Sequence[str], None] = "20260415_0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_constraint("points_ledger_biz_id_fkey", "points_ledger", type_="foreignkey")
def downgrade() -> None:
op.create_foreign_key(
"points_ledger_biz_id_fkey",
"points_ledger",
"sessions",
["biz_id"],
["id"],
ondelete="SET NULL",
)
@@ -2,6 +2,8 @@ from __future__ import annotations
from typing import Any, Sequence
from datetime import datetime, timezone
from ag_ui.core.types import Tool
from core.agentscope.prompts.agent_prompt import build_agent_prompt
from core.agentscope.prompts.sections import wrap_section
@@ -48,14 +50,31 @@ def _build_output_rules(*, ai_language: str) -> str:
return wrap_section("output", "\n".join(rules))
def _build_time_context(*, now_utc: datetime | None) -> str:
if now_utc is None:
now_utc = datetime.now(timezone.utc)
if now_utc.tzinfo is None:
now_utc = now_utc.replace(tzinfo=timezone.utc)
else:
now_utc = now_utc.astimezone(timezone.utc)
return (
"[Time Context]\n"
f"- current_time_utc: {now_utc.isoformat()}\n"
f"- This helps you understand the current time (distinct from divination_time in user input)."
)
def build_system_prompt(
*,
agent_type: AgentType,
ai_language: str,
llm_config: SystemAgentLLMConfig | None = None,
tools: Sequence[Tool | dict[str, Any]] | None = None,
now_utc: datetime | None = None,
) -> str:
sections: list[str | None] = [
_build_time_context(now_utc=now_utc),
_build_safety_section(),
build_agent_prompt(
agent_type=agent_type,
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import contextlib
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable
from ag_ui.core.types import RunAgentInput
@@ -275,6 +276,7 @@ class AgentScopeRunner:
ai_language=ai_language,
llm_config=stage_config.llm_config,
tools=None,
now_utc=datetime.now(timezone.utc),
)
_, worker_payload_raw = await finalize_json_response(
+2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from .agent_chat_message import AgentChatMessage
from .agent_chat_session import AgentChatSession
from .anonymous_session_snapshot import AnonymousSessionSnapshot
from .auth_user import AuthUser
from .invite_code import InviteCode
from .llm import Llm
@@ -18,6 +19,7 @@ from .user_points import UserPoints
__all__ = [
"AgentChatMessage",
"AgentChatSession",
"AnonymousSessionSnapshot",
"AuthUser",
"InviteCode",
"Llm",
@@ -0,0 +1,46 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
import uuid
from sqlalchemy import Boolean, DateTime, Integer, Numeric, String, Text
from sqlalchemy.dialects.postgresql import ARRAY, UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base
__all__ = ["AnonymousSessionSnapshot"]
class AnonymousSessionSnapshot(Base):
__tablename__: str = "anonymous_session_snapshots"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
anonymous_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
session_type: Mapped[str] = mapped_column(String(20), nullable=False)
message_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
status: Mapped[str | None] = mapped_column(String(20), nullable=True)
question_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
tool_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
gua_name: Mapped[str | None] = mapped_column(String(50), nullable=True)
gua_name_hant: Mapped[str | None] = mapped_column(String(50), nullable=True)
target_gua_name: Mapped[str | None] = mapped_column(String(50), nullable=True)
has_changing_yao: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
sign_level: Mapped[str | None] = mapped_column(String(20), nullable=True)
keywords: Mapped[list[str] | None] = mapped_column(ARRAY(Text()), nullable=True)
model_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
total_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 6), nullable=True)
total_latency_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
last_activity_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
anonymized_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
+9
View File
@@ -46,11 +46,20 @@ class NotificationSettings(BaseModel):
allow_vibration: bool = True
class DivinationTutorialSettings(BaseModel):
divination_entry_shown: bool = False
auto_divination_shown: bool = False
manual_divination_shown: bool = False
class ProfileSettingsV1(BaseModel):
version: Literal[1] = 1
preferences: PreferenceSettings = Field(default_factory=PreferenceSettings)
privacy: dict[str, object] = Field(default_factory=dict)
notification: NotificationSettings = Field(default_factory=NotificationSettings)
divination_tutorial: DivinationTutorialSettings = Field(
default_factory=DivinationTutorialSettings
)
ProfileSettingsUnion = ProfileSettingsV1
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from models.agent_chat_message import AgentChatMessage
from models.agent_chat_session import AgentChatSession
from models.anonymous_session_snapshot import AnonymousSessionSnapshot
from core.logging import get_logger
logger = get_logger(__name__)
def _truncate_to_day(dt: datetime) -> datetime:
return dt.replace(hour=0, minute=0, second=0, microsecond=0)
def _extract_derived_fields(
messages: list[AgentChatMessage],
) -> dict[str, Any]:
for message in messages:
metadata_raw = message.metadata_json
if not isinstance(metadata_raw, dict):
continue
agent_output = metadata_raw.get("agent_output")
if not isinstance(agent_output, dict):
continue
derived = agent_output.get("divination_derived")
if isinstance(derived, dict) and derived:
return derived
return {}
def _extract_sign_level(
messages: list[AgentChatMessage],
) -> str | None:
for message in messages:
metadata_raw = message.metadata_json
if not isinstance(metadata_raw, dict):
continue
agent_output = metadata_raw.get("agent_output")
if not isinstance(agent_output, dict):
continue
sign_level = agent_output.get("sign_level")
if isinstance(sign_level, str) and sign_level:
return sign_level
return None
def _extract_keywords(
messages: list[AgentChatMessage],
) -> list[str] | None:
for message in messages:
metadata_raw = message.metadata_json
if not isinstance(metadata_raw, dict):
continue
agent_output = metadata_raw.get("agent_output")
if not isinstance(agent_output, dict):
continue
keywords = agent_output.get("keywords")
if isinstance(keywords, list) and keywords:
return keywords
return None
def _extract_question_type(
messages: list[AgentChatMessage],
) -> str | None:
derived = _extract_derived_fields(messages)
if not derived:
for message in messages:
metadata_raw = message.metadata_json
if not isinstance(metadata_raw, dict):
continue
agent_output = metadata_raw.get("agent_output")
if not isinstance(agent_output, dict):
continue
question_type = agent_output.get("questionType")
if isinstance(question_type, str) and question_type:
return question_type
return None
question_type = derived.get("questionType")
if isinstance(question_type, str) and question_type:
return question_type
return None
def _extract_model_code(
messages: list[AgentChatMessage],
) -> str | None:
for message in messages:
if message.model_code:
return message.model_code
return None
def _extract_tool_name(
messages: list[AgentChatMessage],
) -> str | None:
for message in messages:
if message.tool_name:
return message.tool_name
return None
def _aggregate_latency(
messages: list[AgentChatMessage],
) -> int | None:
total = 0
found = False
for message in messages:
if message.latency_ms is not None:
total += message.latency_ms
found = True
return total if found else None
def anonymize(
session: AgentChatSession,
messages: list[AgentChatMessage],
) -> AnonymousSessionSnapshot:
derived = _extract_derived_fields(messages)
gua_name = derived.get("guaName") if derived else None
gua_name_hant = derived.get("guaNameHant") if derived else None
target_gua_name = derived.get("targetGuaName") if derived else None
has_changing_yao = derived.get("hasChangingYao") if derived else None
created_at = _truncate_to_day(session.created_at)
last_activity_at = (
_truncate_to_day(session.last_activity_at) if session.last_activity_at else None
)
return AnonymousSessionSnapshot(
id=uuid4(),
anonymous_id=uuid4(),
session_type=session.session_type.value
if hasattr(session.session_type, "value")
else str(session.session_type),
message_count=session.message_count,
status=session.status.value
if hasattr(session.status, "value")
else str(session.status),
question_type=_extract_question_type(messages),
tool_name=_extract_tool_name(messages),
gua_name=gua_name if isinstance(gua_name, str) else None,
gua_name_hant=gua_name_hant if isinstance(gua_name_hant, str) else None,
target_gua_name=target_gua_name if isinstance(target_gua_name, str) else None,
has_changing_yao=has_changing_yao
if isinstance(has_changing_yao, bool)
else None,
sign_level=_extract_sign_level(messages),
keywords=_extract_keywords(messages),
model_code=_extract_model_code(messages),
total_tokens=session.total_tokens,
total_cost=session.total_cost,
total_latency_ms=_aggregate_latency(messages),
created_at=created_at,
last_activity_at=last_activity_at,
anonymized_at=datetime.now(timezone.utc),
)
+45 -5
View File
@@ -5,7 +5,7 @@ from decimal import Decimal
from typing import Any, Protocol
from uuid import UUID, uuid4
from sqlalchemy import Select, func, select
from sqlalchemy import Select, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from core.http.errors import ApiProblemError
@@ -17,6 +17,7 @@ from schemas.domain.chat_message import (
AgentChatMessage as AgentChatMessageSchema,
AgentChatMessageMetadata,
)
from v1.agent.anonymizer import anonymize
class ToolResultPayloadStorage(Protocol):
@@ -96,7 +97,7 @@ class AgentRepository:
async def rollback(self) -> None:
await self._session.rollback()
async def delete_session(self, *, session_id: str) -> None:
async def delete_session(self, *, session_id: str) -> list[dict[str, str]]:
try:
session_uuid = UUID(session_id)
except ValueError as exc:
@@ -112,11 +113,50 @@ class AgentRepository:
)
session = (await self._session.execute(stmt)).scalar_one_or_none()
if session is None:
return
return []
if session.deleted_at is not None:
return
session.deleted_at = datetime.now(timezone.utc)
return []
messages_stmt = (
select(AgentChatMessage)
.where(AgentChatMessage.session_id == session_uuid)
.order_by(AgentChatMessage.seq)
)
messages = list((await self._session.execute(messages_stmt)).scalars().all())
attachment_paths = self._collect_attachment_paths(messages)
snapshot = anonymize(session=session, messages=messages)
self._session.add(snapshot)
await self._session.flush()
stmt_delete_messages = delete(AgentChatMessage).where(
AgentChatMessage.session_id == session_uuid
)
await self._session.execute(stmt_delete_messages)
stmt_delete_session = delete(AgentChatSession).where(
AgentChatSession.id == session_uuid
)
await self._session.execute(stmt_delete_session)
await self._session.flush()
return attachment_paths
@staticmethod
def _collect_attachment_paths(
messages: list[AgentChatMessage],
) -> list[dict[str, str]]:
paths: list[dict[str, str]] = []
for message in messages:
metadata_raw = message.metadata_json
if not isinstance(metadata_raw, dict):
continue
attachments_raw = metadata_raw.get("user_message_attachments")
if not isinstance(attachments_raw, list):
continue
for attachment in attachments_raw:
if not isinstance(attachment, dict):
continue
bucket = attachment.get("bucket")
path = attachment.get("path")
if isinstance(bucket, str) and isinstance(path, str):
paths.append({"bucket": bucket, "path": path})
return paths
async def persist_user_message(
self,
+3 -1
View File
@@ -23,7 +23,7 @@ class AgentRepositoryLike(Protocol):
async def rollback(self) -> None: ...
async def delete_session(self, *, session_id: str) -> None: ...
async def delete_session(self, *, session_id: str) -> list[dict[str, str]]: ...
async def get_history_day(
self,
@@ -126,6 +126,8 @@ class AttachmentStorageLike(Protocol):
expires_in_seconds: int,
) -> str: ...
async def delete_prefix(self, *, bucket: str, prefix: str) -> int: ...
def parse_signed_url(self, url: str) -> tuple[str, str]: ...
+21 -1
View File
@@ -235,8 +235,28 @@ class AgentService:
return
raise
ensure_session_owner(owner_id=owner, current_user=current_user)
await self._repository.delete_session(session_id=thread_id)
attachment_paths = await self._repository.delete_session(session_id=thread_id)
await self._repository.commit()
await self._cleanup_attachments(attachment_paths)
async def _cleanup_attachments(
self, attachment_paths: list[dict[str, str]]
) -> None:
if not attachment_paths or self._attachment_storage is None:
return
for attachment in attachment_paths:
bucket = attachment.get("bucket")
path = attachment.get("path")
if not bucket or not path:
continue
try:
await self._attachment_storage.delete_prefix(bucket=bucket, prefix=path)
except Exception:
logger.warning(
"attachment_cleanup_failed",
bucket=bucket,
path=path,
)
async def _append_context_cache_user_message(
self,
@@ -0,0 +1,183 @@
from __future__ import annotations
import json
import time
import uuid
from typing import TypedDict
import httpx
import pytest
from sqlalchemy import select
from core.db.session import AsyncSessionLocal
from models.agent_chat_session import AgentChatSession
from models.agent_chat_message import AgentChatMessage
from models.anonymous_session_snapshot import AnonymousSessionSnapshot
class IdentityData(TypedDict):
email: str
code: str
async def _create_email_session(
client: httpx.AsyncClient,
*,
email: str,
code: str,
) -> dict[str, object]:
resp = await client.post(
"/api/v1/auth/email-session",
json={"email": email, "token": code},
)
resp.raise_for_status()
return resp.json()
async def _wait_terminal_event(
client: httpx.AsyncClient,
*,
access_token: str,
thread_id: str,
run_id: str,
timeout_s: int = 180,
) -> str:
headers = {"Authorization": f"Bearer {access_token}"}
params = {"runId": run_id, "idle_limit": 120}
started = time.time()
async with client.stream(
"GET",
f"/api/v1/agent/runs/{thread_id}/events",
headers=headers,
params=params,
) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if time.time() - started > timeout_s:
raise TimeoutError("SSE timed out")
if not line or not line.startswith("data: "):
continue
event = json.loads(line[6:])
event_type = event.get("type")
if event_type in {"RUN_FINISHED", "RUN_ERROR"}:
return str(event_type)
raise RuntimeError("No terminal SSE event")
def _build_run_payload(*, thread_id: str, run_id: str) -> dict[str, object]:
now = int(time.time() * 1000)
return {
"threadId": thread_id,
"runId": run_id,
"state": {},
"messages": [
{
"id": f"msg_{run_id}_user_0",
"role": "user",
"content": "今天事业运如何?",
}
],
"tools": [],
"context": [],
"forwardedProps": {
"runtime_mode": "chat",
"client_time": {
"device_timezone": "Asia/Shanghai",
"client_now_iso": "2026-04-15T12:00:00Z",
"client_epoch_ms": now,
},
"divinationPayload": {
"divinationMethod": "自动起卦",
"questionType": "事业",
"question": "今天事业运如何?",
"divinationTimeIso": "2026-04-15T12:00:00Z",
"yaoLines": ["少阳", "少阴", "老阳", "少阳", "老阴", "少阴"],
},
},
}
@pytest.mark.asyncio
async def test_session_delete_anonymizes_and_hard_deletes(
api_client: httpx.AsyncClient,
test_identity: IdentityData,
db_cleanup: list[str],
) -> None:
email = str(test_identity["email"]).strip().lower()
db_cleanup.append(email)
auth_resp = await _create_email_session(
api_client,
email=email,
code=str(test_identity["code"]),
)
user = auth_resp.get("user")
assert isinstance(user, dict)
access_token = str(auth_resp["access_token"])
headers = {"Authorization": f"Bearer {access_token}"}
thread_id = str(uuid.uuid4())
run_id = f"run_{int(time.time() * 1000)}"
enqueue = await api_client.post(
"/api/v1/agent/runs",
headers=headers,
json=_build_run_payload(thread_id=thread_id, run_id=run_id),
)
assert enqueue.status_code == 202
terminal = await _wait_terminal_event(
api_client,
access_token=access_token,
thread_id=thread_id,
run_id=run_id,
)
assert terminal in {"RUN_FINISHED", "RUN_ERROR"}
async with AsyncSessionLocal() as session:
session_result = await session.execute(
select(AgentChatSession).where(AgentChatSession.id == uuid.UUID(thread_id))
)
session_obj = session_result.scalar_one_or_none()
assert session_obj is not None, "Session should exist before deletion"
delete_resp = await api_client.delete(
f"/api/v1/agent/sessions/{thread_id}",
headers=headers,
)
assert delete_resp.status_code == 204
async with AsyncSessionLocal() as session:
session_result = await session.execute(
select(AgentChatSession).where(AgentChatSession.id == uuid.UUID(thread_id))
)
deleted_session = session_result.scalar_one_or_none()
assert deleted_session is None, (
"Session should be hard-deleted, not soft-deleted"
)
msg_result = await session.execute(
select(AgentChatMessage).where(
AgentChatMessage.session_id == uuid.UUID(thread_id)
)
)
remaining_messages = msg_result.scalars().all()
assert len(remaining_messages) == 0, (
"Messages should be hard-deleted along with session"
)
snapshot_result = await session.execute(
select(AnonymousSessionSnapshot).order_by(
AnonymousSessionSnapshot.anonymized_at.desc()
)
)
snapshots = snapshot_result.scalars().all()
assert len(snapshots) >= 1, "At least one anonymous snapshot should exist"
snapshot = snapshots[0]
assert snapshot.session_type == "chat"
assert snapshot.anonymous_id is not None
assert snapshot.id is not None
assert snapshot.anonymized_at is not None
+216
View File
@@ -0,0 +1,216 @@
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from uuid import uuid4
from schemas.enums import AgentChatMessageRole, AgentChatSessionStatus, SessionType
from models.agent_chat_message import AgentChatMessage
from models.agent_chat_session import AgentChatSession
from v1.agent.anonymizer import (
_aggregate_latency,
_extract_derived_fields,
_extract_keywords,
_extract_model_code,
_extract_question_type,
_extract_sign_level,
_extract_tool_name,
_truncate_to_day,
anonymize,
)
def _make_session(**overrides: object) -> AgentChatSession:
defaults: dict[str, object] = {
"id": uuid4(),
"user_id": uuid4(),
"session_type": SessionType.CHAT,
"status": AgentChatSessionStatus.COMPLETED,
"message_count": 3,
"total_tokens": 1500,
"total_cost": Decimal("0.05"),
"created_at": datetime(2026, 4, 15, 14, 32, 0, tzinfo=timezone.utc),
"last_activity_at": datetime(2026, 4, 15, 14, 45, 0, tzinfo=timezone.utc),
"job_id": None,
"title": "Will I get the job?",
"state_snapshot": None,
"updated_at": datetime(2026, 4, 15, 14, 45, 0, tzinfo=timezone.utc),
"deleted_at": None,
}
defaults.update(overrides)
return AgentChatSession(**defaults)
def _make_message(
*,
session_id: object | None = None,
role: AgentChatMessageRole = AgentChatMessageRole.ASSISTANT,
metadata_json: dict[str, object] | None = None,
model_code: str | None = None,
tool_name: str | None = None,
latency_ms: int | None = None,
) -> AgentChatMessage:
return AgentChatMessage(
id=uuid4(),
session_id=session_id or uuid4(),
seq=1,
role=role,
content="some content",
model_code=model_code,
tool_name=tool_name,
input_tokens=100,
output_tokens=200,
cost=Decimal("0.02"),
latency_ms=latency_ms,
visibility_mask=0,
metadata_json=metadata_json,
created_at=datetime(2026, 4, 15, 14, 33, 0, tzinfo=timezone.utc),
updated_at=datetime(2026, 4, 15, 14, 33, 0, tzinfo=timezone.utc),
deleted_at=None,
)
def test_truncate_to_day() -> None:
dt = datetime(2026, 4, 15, 14, 32, 45, 123456, tzinfo=timezone.utc)
result = _truncate_to_day(dt)
assert result == datetime(2026, 4, 15, 0, 0, 0, 0, tzinfo=timezone.utc)
def test_extract_derived_fields_found() -> None:
msg = _make_message(
metadata_json={
"agent_output": {
"divination_derived": {
"guaName": "",
"questionType": "career",
"hasChangingYao": True,
}
}
}
)
derived = _extract_derived_fields([msg])
assert derived.get("guaName") == ""
assert derived.get("questionType") == "career"
def test_extract_derived_fields_missing() -> None:
msg = _make_message(metadata_json={"run_id": "abc"})
derived = _extract_derived_fields([msg])
assert derived == {}
def test_extract_sign_level() -> None:
msg = _make_message(metadata_json={"agent_output": {"sign_level": "中上签"}})
assert _extract_sign_level([msg]) == "中上签"
def test_extract_sign_level_none() -> None:
msg = _make_message(metadata_json={"agent_output": {}})
assert _extract_sign_level([msg]) is None
def test_extract_keywords() -> None:
msg = _make_message(metadata_json={"agent_output": {"keywords": ["事业", "贵人"]}})
assert _extract_keywords([msg]) == ["事业", "贵人"]
def test_extract_question_type_from_derived() -> None:
msg = _make_message(
metadata_json={
"agent_output": {
"divination_derived": {"questionType": "career"},
}
}
)
assert _extract_question_type([msg]) == "career"
def test_extract_question_type_from_agent_output() -> None:
msg = _make_message(metadata_json={"agent_output": {"questionType": "love"}})
assert _extract_question_type([msg]) == "love"
def test_extract_model_code() -> None:
msg = _make_message(model_code="qwen3.5-flash")
assert _extract_model_code([msg]) == "qwen3.5-flash"
def test_extract_tool_name() -> None:
msg = _make_message(tool_name="liuyao")
assert _extract_tool_name([msg]) == "liuyao"
def test_aggregate_latency() -> None:
msg1 = _make_message(latency_ms=500)
msg2 = _make_message(latency_ms=300)
assert _aggregate_latency([msg1, msg2]) == 800
def test_aggregate_latency_none() -> None:
msg = _make_message(latency_ms=None)
assert _aggregate_latency([msg]) is None
def test_anonymize_full_snapshot() -> None:
session = _make_session()
msg = _make_message(
session_id=session.id,
role=AgentChatMessageRole.ASSISTANT,
model_code="qwen3.5-flash",
tool_name="liuyao",
latency_ms=1200,
metadata_json={
"agent_output": {
"sign_level": "上上签",
"keywords": ["事业", "贵人"],
"divination_derived": {
"questionType": "career",
"guaName": "",
"guaNameHant": "",
"targetGuaName": "",
"hasChangingYao": True,
},
}
},
)
user_msg = _make_message(
session_id=session.id,
role=AgentChatMessageRole.USER,
latency_ms=None,
)
snapshot = anonymize(session=session, messages=[msg, user_msg])
assert snapshot.session_type == "chat"
assert snapshot.message_count == 3
assert snapshot.status == "completed"
assert snapshot.question_type == "career"
assert snapshot.tool_name == "liuyao"
assert snapshot.model_code == "qwen3.5-flash"
assert snapshot.gua_name == ""
assert snapshot.gua_name_hant == ""
assert snapshot.target_gua_name == ""
assert snapshot.has_changing_yao is True
assert snapshot.sign_level == "上上签"
assert snapshot.keywords == ["事业", "贵人"]
assert snapshot.total_tokens == 1500
assert snapshot.total_cost == Decimal("0.05")
assert snapshot.total_latency_ms == 1200
assert snapshot.created_at == datetime(2026, 4, 15, 0, 0, 0, tzinfo=timezone.utc)
assert snapshot.last_activity_at == datetime(
2026, 4, 15, 0, 0, 0, tzinfo=timezone.utc
)
assert snapshot.anonymous_id is not None
assert snapshot.id is not None
def test_anonymize_no_metadata() -> None:
session = _make_session()
msg = _make_message(session_id=session.id, metadata_json=None)
snapshot = anonymize(session=session, messages=[msg])
assert snapshot.question_type is None
assert snapshot.gua_name is None
assert snapshot.sign_level is None
assert snapshot.keywords is None
assert snapshot.has_changing_yao is None