Compare commits
5 Commits
a244eaa666
...
69b34bd723
| Author | SHA1 | Date | |
|---|---|---|---|
| 69b34bd723 | |||
| 55eeab43df | |||
| edafb4dc50 | |||
| 0bb7d77a3f | |||
| c2b726e7bd |
@@ -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
-19
@@ -51,17 +51,29 @@ Replace the current soft-delete flow with an **anonymize-then-hard-delete** stra
|
|||||||
|
|
||||||
| Table | Column | Content |
|
| Table | Column | Content |
|
||||||
|-------|--------|---------|
|
|-------|--------|---------|
|
||||||
| sessions | session_type | 'chat' / 'automation' |
|
| sessions | session_type | 'chat' |
|
||||||
| sessions | status | pending/running/completed/failed |
|
| sessions | status | pending/running/completed/failed |
|
||||||
| sessions | total_tokens | Usage metric |
|
| sessions | total_tokens | Usage metric |
|
||||||
| sessions | total_cost | 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 |
|
| sessions | created_at / last_activity_at | Timestamps |
|
||||||
| messages | role | user/assistant/system/tool |
|
|
||||||
| messages | model_code | LLM model identifier |
|
| messages | model_code | LLM model identifier |
|
||||||
| messages | tool_name | Tool name (divination type) |
|
| messages | tool_name | Divination tool name |
|
||||||
| messages | metadata->agent_output->divination_derived->questionType | Question category (career/love/wealth/health) |
|
| messages | latency_ms | Response latency |
|
||||||
| messages | input_tokens / output_tokens / cost / latency_ms | Usage and performance metrics |
|
| 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
|
## Technical Design
|
||||||
|
|
||||||
@@ -71,18 +83,34 @@ Replace the current soft-delete flow with an **anonymize-then-hard-delete** stra
|
|||||||
CREATE TABLE anonymous_session_snapshots (
|
CREATE TABLE anonymous_session_snapshots (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
anonymous_id UUID NOT NULL, -- Random UUID, no link to real user
|
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)
|
-- Session metadata
|
||||||
tool_name VARCHAR(100), -- Divination tool used
|
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
|
model_code VARCHAR(50), -- LLM model used
|
||||||
total_tokens INTEGER, -- Token usage
|
total_tokens INTEGER, -- Token usage
|
||||||
total_cost NUMERIC, -- Cost metric
|
total_cost NUMERIC, -- Cost metric
|
||||||
message_count INTEGER, -- Message count
|
|
||||||
status VARCHAR(20), -- Session final status
|
|
||||||
total_latency_ms INTEGER, -- Aggregated latency
|
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)
|
-- Timestamps (day precision to prevent re-identification)
|
||||||
last_activity_at TIMESTAMPTZ -- Original last activity (date only precision)
|
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
|
-- RLS: service role only, no user access
|
||||||
@@ -95,8 +123,9 @@ CREATE POLICY "Service role can manage anonymous snapshots"
|
|||||||
Design notes:
|
Design notes:
|
||||||
- `anonymous_id` is a randomly generated UUID with **no mapping** back to the original user
|
- `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
|
- 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
|
- `session_type` only supports 'chat' (AUTOMATION is legacy from reused database schema, not used in this project)
|
||||||
- No `user_id`, no session content, no AI responses - only aggregate metrics
|
- 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
|
- RLS ensures no user (even authenticated) can access this table, only service_role
|
||||||
|
|
||||||
### 2. Anonymization Service
|
### 2. Anonymization Service
|
||||||
@@ -120,9 +149,12 @@ class SessionAnonymizer:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Key anonymization rules:
|
Key anonymization rules:
|
||||||
- **Strip entirely**: `user_id`, `title`, `state_snapshot`, `content` (all message content), `user_message_attachments`, full `agent_output` / `tool_agent_output`
|
- **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 as-is**: `session_type`, `status`, `total_tokens`, `total_cost`, `message_count`, `model_code`, `tool_name`
|
- **Retain structured fields**:
|
||||||
- **Transform**: timestamps truncated to day precision; `questionType` extracted from metadata as category label only
|
- 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`
|
- **Aggregate**: sum `latency_ms` across all messages into `total_latency_ms`
|
||||||
|
|
||||||
### 3. Modified Deletion Flow
|
### 3. Modified Deletion Flow
|
||||||
+2
-2
@@ -3,14 +3,14 @@
|
|||||||
"name": "session-deletion-anonymization",
|
"name": "session-deletion-anonymization",
|
||||||
"title": "Session deletion anonymization for iOS compliance",
|
"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",
|
"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,
|
"dev_type": null,
|
||||||
"scope": null,
|
"scope": null,
|
||||||
"priority": "P1",
|
"priority": "P1",
|
||||||
"creator": "zl-q",
|
"creator": "zl-q",
|
||||||
"assignee": "zl-q",
|
"assignee": "zl-q",
|
||||||
"createdAt": "2026-04-15",
|
"createdAt": "2026-04-15",
|
||||||
"completedAt": null,
|
"completedAt": "2026-04-15",
|
||||||
"branch": null,
|
"branch": null,
|
||||||
"base_branch": "dev",
|
"base_branch": "dev",
|
||||||
"worktree_path": null,
|
"worktree_path": null,
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<!-- @@@auto:current-status -->
|
<!-- @@@auto:current-status -->
|
||||||
- **Active File**: `journal-1.md`
|
- **Active File**: `journal-1.md`
|
||||||
- **Total Sessions**: 7
|
- **Total Sessions**: 8
|
||||||
- **Last Active**: 2026-04-15
|
- **Last Active**: 2026-04-15
|
||||||
<!-- @@@/auto:current-status -->
|
<!-- @@@/auto:current-status -->
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<!-- @@@auto:active-documents -->
|
<!-- @@@auto:active-documents -->
|
||||||
| File | Lines | Status |
|
| File | Lines | Status |
|
||||||
|------|-------|--------|
|
|------|-------|--------|
|
||||||
| `journal-1.md` | ~413 | Active |
|
| `journal-1.md` | ~445 | Active |
|
||||||
<!-- @@@/auto:active-documents -->
|
<!-- @@@/auto:active-documents -->
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
<!-- @@@auto:session-history -->
|
<!-- @@@auto:session-history -->
|
||||||
| # | Date | Title | Commits |
|
| # | Date | Title | Commits |
|
||||||
|---|------|-------|---------|
|
|---|------|-------|---------|
|
||||||
|
| 8 | 2026-04-15 | Session deletion anonymization for iOS compliance | `c2b726e` |
|
||||||
| 7 | 2026-04-15 | 六爻算法修复 + Prompt架构重构 + i18n输出规则 | `9598d16`, `be68681` |
|
| 7 | 2026-04-15 | 六爻算法修复 + Prompt架构重构 + i18n输出规则 | `9598d16`, `be68681` |
|
||||||
| 6 | 2026-04-13 | 修复追问链路与上限判定 | - |
|
| 6 | 2026-04-13 | 修复追问链路与上限判定 | - |
|
||||||
| 5 | 2026-04-13 | feat: 邀请码显示功能 - 后端API + 前端对接 | - |
|
| 5 | 2026-04-13 | feat: 邀请码显示功能 - 后端API + 前端对接 | - |
|
||||||
|
|||||||
@@ -411,3 +411,35 @@
|
|||||||
### Next Steps
|
### Next Steps
|
||||||
|
|
||||||
- None - task complete
|
- 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/date_time_picker/date_time_picker_bottom_sheet.dart';
|
||||||
import '../../../../shared/widgets/toast/toast.dart';
|
import '../../../../shared/widgets/toast/toast.dart';
|
||||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||||
|
import '../../../settings/data/models/profile_settings.dart';
|
||||||
import '../../data/models/divination_backend_models.dart';
|
import '../../data/models/divination_backend_models.dart';
|
||||||
import '../../data/apis/divination_api.dart';
|
import '../../data/apis/divination_api.dart';
|
||||||
import '../../data/models/divination_params.dart';
|
import '../../data/models/divination_params.dart';
|
||||||
@@ -33,12 +34,17 @@ class AutoDivinationScreen extends StatefulWidget {
|
|||||||
required this.runService,
|
required this.runService,
|
||||||
this.divinationApi,
|
this.divinationApi,
|
||||||
required this.onCompleted,
|
required this.onCompleted,
|
||||||
|
required this.profileSettings,
|
||||||
|
required this.onProfileSettingsChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final DivinationParams params;
|
final DivinationParams params;
|
||||||
final DivinationRunService runService;
|
final DivinationRunService runService;
|
||||||
final DivinationApi? divinationApi;
|
final DivinationApi? divinationApi;
|
||||||
final Future<void> Function(DivinationResultData result) onCompleted;
|
final Future<void> Function(DivinationResultData result) onCompleted;
|
||||||
|
final ProfileSettingsV1 profileSettings;
|
||||||
|
final Future<void> Function(ProfileSettingsV1 settings)
|
||||||
|
onProfileSettingsChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AutoDivinationScreen> createState() => _AutoDivinationScreenState();
|
State<AutoDivinationScreen> createState() => _AutoDivinationScreenState();
|
||||||
@@ -64,6 +70,8 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
|
|||||||
DateTime _lastShake = DateTime.fromMillisecondsSinceEpoch(0);
|
DateTime _lastShake = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
bool _spinLocked = false;
|
bool _spinLocked = false;
|
||||||
bool _submitting = false;
|
bool _submitting = false;
|
||||||
|
bool _tutorialChecked = false;
|
||||||
|
late DivinationTutorialSettings _localTutorialSettings;
|
||||||
|
|
||||||
final GlobalKey<OnboardingState> _onboardingKey =
|
final GlobalKey<OnboardingState> _onboardingKey =
|
||||||
GlobalKey<OnboardingState>();
|
GlobalKey<OnboardingState>();
|
||||||
@@ -89,6 +97,37 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
|
|||||||
duration: const Duration(milliseconds: 500),
|
duration: const Duration(milliseconds: 500),
|
||||||
)..repeat(reverse: true);
|
)..repeat(reverse: true);
|
||||||
_listenShake();
|
_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
|
@override
|
||||||
@@ -176,6 +215,7 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
|
|||||||
key: _onboardingKey,
|
key: _onboardingKey,
|
||||||
steps: steps,
|
steps: steps,
|
||||||
onChanged: _onGuideStepChanged,
|
onChanged: _onGuideStepChanged,
|
||||||
|
onEnd: (_) => _markTutorialShown(),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||||
|
|||||||
@@ -547,6 +547,7 @@ class _FocusPointsCard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = Theme.of(context).colorScheme;
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final languageCode = Localizations.localeOf(context).languageCode;
|
final languageCode = Localizations.localeOf(context).languageCode;
|
||||||
final title = languageCode == 'en' ? 'Focus Points' : '断卦要点';
|
final title = languageCode == 'en' ? 'Focus Points' : '断卦要点';
|
||||||
if (points.isEmpty) {
|
if (points.isEmpty) {
|
||||||
@@ -563,6 +564,8 @@ class _FocusPointsCard extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
@@ -571,6 +574,25 @@ class _FocusPointsCard extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.w700,
|
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),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
...List<Widget>.generate(points.length, (index) {
|
...List<Widget>.generate(points.length, (index) {
|
||||||
return Padding(
|
return Padding(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../../../../shared/widgets/gua_icon.dart';
|
|||||||
import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
|
import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
|
||||||
import '../../../../shared/widgets/toast/toast.dart';
|
import '../../../../shared/widgets/toast/toast.dart';
|
||||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||||
|
import '../../../settings/data/models/profile_settings.dart';
|
||||||
import '../../data/apis/divination_api.dart';
|
import '../../data/apis/divination_api.dart';
|
||||||
import '../../data/models/divination_params.dart';
|
import '../../data/models/divination_params.dart';
|
||||||
import '../../data/models/divination_result.dart';
|
import '../../data/models/divination_result.dart';
|
||||||
@@ -26,6 +27,8 @@ class DivinationScreen extends StatefulWidget {
|
|||||||
this.runServiceOverride,
|
this.runServiceOverride,
|
||||||
this.divinationApiOverride,
|
this.divinationApiOverride,
|
||||||
this.allowVibration = true,
|
this.allowVibration = true,
|
||||||
|
required this.profileSettings,
|
||||||
|
required this.onProfileSettingsChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final SessionStore sessionStore;
|
final SessionStore sessionStore;
|
||||||
@@ -34,6 +37,9 @@ class DivinationScreen extends StatefulWidget {
|
|||||||
final DivinationRunService? runServiceOverride;
|
final DivinationRunService? runServiceOverride;
|
||||||
final DivinationApi? divinationApiOverride;
|
final DivinationApi? divinationApiOverride;
|
||||||
final bool allowVibration;
|
final bool allowVibration;
|
||||||
|
final ProfileSettingsV1 profileSettings;
|
||||||
|
final Future<void> Function(ProfileSettingsV1 settings)
|
||||||
|
onProfileSettingsChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<DivinationScreen> createState() => _DivinationScreenState();
|
State<DivinationScreen> createState() => _DivinationScreenState();
|
||||||
@@ -44,6 +50,8 @@ class _DivinationScreenState extends State<DivinationScreen> {
|
|||||||
final TextEditingController _questionController = TextEditingController();
|
final TextEditingController _questionController = TextEditingController();
|
||||||
late final DivinationApi _divinationApi;
|
late final DivinationApi _divinationApi;
|
||||||
late final DivinationRunService _runService;
|
late final DivinationRunService _runService;
|
||||||
|
bool _tutorialChecked = false;
|
||||||
|
late DivinationTutorialSettings _localTutorialSettings;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -65,6 +73,47 @@ class _DivinationScreenState extends State<DivinationScreen> {
|
|||||||
userId: widget.userId,
|
userId: widget.userId,
|
||||||
);
|
);
|
||||||
_questionController.addListener(_syncQuestion);
|
_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
|
@override
|
||||||
@@ -161,6 +210,10 @@ class _DivinationScreenState extends State<DivinationScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final updatedSettings = widget.profileSettings.copyWith(
|
||||||
|
divinationTutorial: _localTutorialSettings,
|
||||||
|
);
|
||||||
|
|
||||||
if (_params.method == DivinationMethod.manual) {
|
if (_params.method == DivinationMethod.manual) {
|
||||||
final nextParams = _params.copyWith(divinationTime: DateTime.now());
|
final nextParams = _params.copyWith(divinationTime: DateTime.now());
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
@@ -170,6 +223,8 @@ class _DivinationScreenState extends State<DivinationScreen> {
|
|||||||
runService: _runService,
|
runService: _runService,
|
||||||
divinationApi: _divinationApi,
|
divinationApi: _divinationApi,
|
||||||
onCompleted: widget.onCompleted,
|
onCompleted: widget.onCompleted,
|
||||||
|
profileSettings: updatedSettings,
|
||||||
|
onProfileSettingsChanged: widget.onProfileSettingsChanged,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -187,6 +242,8 @@ class _DivinationScreenState extends State<DivinationScreen> {
|
|||||||
runService: _runService,
|
runService: _runService,
|
||||||
divinationApi: _divinationApi,
|
divinationApi: _divinationApi,
|
||||||
onCompleted: widget.onCompleted,
|
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/date_time_picker/date_time_picker_bottom_sheet.dart';
|
||||||
import '../../../../shared/widgets/toast/toast.dart';
|
import '../../../../shared/widgets/toast/toast.dart';
|
||||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||||
|
import '../../../settings/data/models/profile_settings.dart';
|
||||||
import '../../data/models/divination_backend_models.dart';
|
import '../../data/models/divination_backend_models.dart';
|
||||||
import '../../data/apis/divination_api.dart';
|
import '../../data/apis/divination_api.dart';
|
||||||
import '../../data/models/divination_params.dart';
|
import '../../data/models/divination_params.dart';
|
||||||
@@ -30,12 +31,17 @@ class ManualDivinationScreen extends StatefulWidget {
|
|||||||
required this.runService,
|
required this.runService,
|
||||||
this.divinationApi,
|
this.divinationApi,
|
||||||
required this.onCompleted,
|
required this.onCompleted,
|
||||||
|
required this.profileSettings,
|
||||||
|
required this.onProfileSettingsChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final DivinationParams params;
|
final DivinationParams params;
|
||||||
final DivinationRunService runService;
|
final DivinationRunService runService;
|
||||||
final DivinationApi? divinationApi;
|
final DivinationApi? divinationApi;
|
||||||
final Future<void> Function(DivinationResultData result) onCompleted;
|
final Future<void> Function(DivinationResultData result) onCompleted;
|
||||||
|
final ProfileSettingsV1 profileSettings;
|
||||||
|
final Future<void> Function(ProfileSettingsV1 settings)
|
||||||
|
onProfileSettingsChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ManualDivinationScreen> createState() => _ManualDivinationScreenState();
|
State<ManualDivinationScreen> createState() => _ManualDivinationScreenState();
|
||||||
@@ -47,6 +53,8 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
|
|||||||
final List<YaoType?> _selectedYaos = List<YaoType?>.filled(6, null);
|
final List<YaoType?> _selectedYaos = List<YaoType?>.filled(6, null);
|
||||||
late final AnimationController _blinkController;
|
late final AnimationController _blinkController;
|
||||||
bool _submitting = false;
|
bool _submitting = false;
|
||||||
|
bool _tutorialChecked = false;
|
||||||
|
late DivinationTutorialSettings _localTutorialSettings;
|
||||||
final GlobalKey<OnboardingState> _onboardingKey =
|
final GlobalKey<OnboardingState> _onboardingKey =
|
||||||
GlobalKey<OnboardingState>();
|
GlobalKey<OnboardingState>();
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
@@ -66,6 +74,37 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
|
|||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 500),
|
duration: const Duration(milliseconds: 500),
|
||||||
)..repeat(reverse: true);
|
)..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
|
@override
|
||||||
@@ -146,6 +185,7 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
|
|||||||
key: _onboardingKey,
|
key: _onboardingKey,
|
||||||
steps: guideSteps,
|
steps: guideSteps,
|
||||||
onChanged: _onGuideStepChanged,
|
onChanged: _onGuideStepChanged,
|
||||||
|
onEnd: (_) => _markTutorialShown(),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
allowVibration: widget.profileSettings.notification.allowVibration,
|
allowVibration: widget.profileSettings.notification.allowVibration,
|
||||||
notificationBloc: widget.notificationBloc,
|
notificationBloc: widget.notificationBloc,
|
||||||
notificationRepository: widget.notificationRepository,
|
notificationRepository: widget.notificationRepository,
|
||||||
|
profileSettings: widget.profileSettings,
|
||||||
|
onProfileSettingsChanged: widget.onProfileSettingsChanged,
|
||||||
),
|
),
|
||||||
_ProfileTab(
|
_ProfileTab(
|
||||||
account: widget.account,
|
account: widget.account,
|
||||||
@@ -160,6 +162,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
userId: widget.account,
|
userId: widget.account,
|
||||||
onCompleted: widget.onDivinationCompleted,
|
onCompleted: widget.onDivinationCompleted,
|
||||||
allowVibration: widget.profileSettings.notification.allowVibration,
|
allowVibration: widget.profileSettings.notification.allowVibration,
|
||||||
|
profileSettings: widget.profileSettings,
|
||||||
|
onProfileSettingsChanged: widget.onProfileSettingsChanged,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -177,6 +181,8 @@ class _HomeTab extends StatelessWidget {
|
|||||||
required this.allowVibration,
|
required this.allowVibration,
|
||||||
required this.notificationBloc,
|
required this.notificationBloc,
|
||||||
required this.notificationRepository,
|
required this.notificationRepository,
|
||||||
|
required this.profileSettings,
|
||||||
|
required this.onProfileSettingsChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<DivinationResultData> historyItems;
|
final List<DivinationResultData> historyItems;
|
||||||
@@ -189,6 +195,9 @@ class _HomeTab extends StatelessWidget {
|
|||||||
final bool allowVibration;
|
final bool allowVibration;
|
||||||
final NotificationBloc notificationBloc;
|
final NotificationBloc notificationBloc;
|
||||||
final NotificationRepository notificationRepository;
|
final NotificationRepository notificationRepository;
|
||||||
|
final ProfileSettingsV1 profileSettings;
|
||||||
|
final Future<void> Function(ProfileSettingsV1 settings)
|
||||||
|
onProfileSettingsChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -304,6 +313,9 @@ class _HomeTab extends StatelessWidget {
|
|||||||
userId: userId,
|
userId: userId,
|
||||||
onCompleted: onDivinationCompleted,
|
onCompleted: onDivinationCompleted,
|
||||||
allowVibration: allowVibration,
|
allowVibration: allowVibration,
|
||||||
|
profileSettings: profileSettings,
|
||||||
|
onProfileSettingsChanged:
|
||||||
|
onProfileSettingsChanged,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ class ProfileApi {
|
|||||||
'allow_notifications': settings.notification.allowNotifications,
|
'allow_notifications': settings.notification.allowNotifications,
|
||||||
'allow_vibration': settings.notification.allowVibration,
|
'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>>(
|
final json = await _apiClient.rawDio.patch<Map<String, dynamic>>(
|
||||||
@@ -119,6 +127,23 @@ class ProfileApi {
|
|||||||
)
|
)
|
||||||
: const NotificationSettings();
|
: 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(
|
return ProfileSettingsV1(
|
||||||
displayName: (json['display_name'] as String?) ?? '',
|
displayName: (json['display_name'] as String?) ?? '',
|
||||||
bio: (json['bio'] as String?) ?? '',
|
bio: (json['bio'] as String?) ?? '',
|
||||||
@@ -130,6 +155,7 @@ class ProfileApi {
|
|||||||
const <String, dynamic>{})
|
const <String, dynamic>{})
|
||||||
: const <String, dynamic>{},
|
: const <String, dynamic>{},
|
||||||
notification: notification,
|
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 {
|
class ProfileSettingsV1 {
|
||||||
const ProfileSettingsV1({
|
const ProfileSettingsV1({
|
||||||
this.version = 1,
|
this.version = 1,
|
||||||
@@ -69,6 +94,7 @@ class ProfileSettingsV1 {
|
|||||||
this.preferences = const PreferenceSettings(),
|
this.preferences = const PreferenceSettings(),
|
||||||
this.privacy = const <String, Object?>{},
|
this.privacy = const <String, Object?>{},
|
||||||
this.notification = const NotificationSettings(),
|
this.notification = const NotificationSettings(),
|
||||||
|
this.divinationTutorial = const DivinationTutorialSettings(),
|
||||||
});
|
});
|
||||||
|
|
||||||
final int version;
|
final int version;
|
||||||
@@ -79,6 +105,7 @@ class ProfileSettingsV1 {
|
|||||||
final PreferenceSettings preferences;
|
final PreferenceSettings preferences;
|
||||||
final Map<String, Object?> privacy;
|
final Map<String, Object?> privacy;
|
||||||
final NotificationSettings notification;
|
final NotificationSettings notification;
|
||||||
|
final DivinationTutorialSettings divinationTutorial;
|
||||||
|
|
||||||
ProfileSettingsV1 copyWith({
|
ProfileSettingsV1 copyWith({
|
||||||
int? version,
|
int? version,
|
||||||
@@ -89,6 +116,7 @@ class ProfileSettingsV1 {
|
|||||||
PreferenceSettings? preferences,
|
PreferenceSettings? preferences,
|
||||||
Map<String, Object?>? privacy,
|
Map<String, Object?>? privacy,
|
||||||
NotificationSettings? notification,
|
NotificationSettings? notification,
|
||||||
|
DivinationTutorialSettings? divinationTutorial,
|
||||||
}) {
|
}) {
|
||||||
return ProfileSettingsV1(
|
return ProfileSettingsV1(
|
||||||
version: version ?? this.version,
|
version: version ?? this.version,
|
||||||
@@ -99,6 +127,7 @@ class ProfileSettingsV1 {
|
|||||||
preferences: preferences ?? this.preferences,
|
preferences: preferences ?? this.preferences,
|
||||||
privacy: privacy ?? this.privacy,
|
privacy: privacy ?? this.privacy,
|
||||||
notification: notification ?? this.notification,
|
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/auto_divination_screen.dart';
|
||||||
import 'package:meeyao_qianwen/features/divination/presentation/screens/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/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';
|
import 'package:meeyao_qianwen/l10n/app_localizations.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -18,6 +19,9 @@ void main() {
|
|||||||
api: DivinationApi(apiClient: ApiClient(baseUrl: 'http://localhost:5775')),
|
api: DivinationApi(apiClient: ApiClient(baseUrl: 'http://localhost:5775')),
|
||||||
);
|
);
|
||||||
final sessionStore = SessionStore(LocalKvStore());
|
final sessionStore = SessionStore(LocalKvStore());
|
||||||
|
final profileSettings = ProfileSettingsV1.defaultsForLocale(
|
||||||
|
const Locale('zh'),
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('divination screen navigates to auto screen', (tester) async {
|
testWidgets('divination screen navigates to auto screen', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -36,6 +40,8 @@ void main() {
|
|||||||
userId: 'user_test',
|
userId: 'user_test',
|
||||||
onCompleted: (_) async {},
|
onCompleted: (_) async {},
|
||||||
runServiceOverride: runService,
|
runServiceOverride: runService,
|
||||||
|
profileSettings: profileSettings,
|
||||||
|
onProfileSettingsChanged: (_) async {},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -77,6 +83,8 @@ void main() {
|
|||||||
params: params,
|
params: params,
|
||||||
runService: runService,
|
runService: runService,
|
||||||
onCompleted: (_) async {},
|
onCompleted: (_) async {},
|
||||||
|
profileSettings: profileSettings,
|
||||||
|
onProfileSettingsChanged: (_) async {},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -108,6 +116,8 @@ void main() {
|
|||||||
userId: 'user_test',
|
userId: 'user_test',
|
||||||
onCompleted: (_) async {},
|
onCompleted: (_) async {},
|
||||||
runServiceOverride: runService,
|
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/models/divination_params.dart';
|
||||||
import 'package:meeyao_qianwen/features/divination/data/services/divination_run_service.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/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';
|
import 'package:meeyao_qianwen/l10n/app_localizations.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -24,6 +25,9 @@ void main() {
|
|||||||
apiClient: ApiClient(baseUrl: 'http://localhost:5775'),
|
apiClient: ApiClient(baseUrl: 'http://localhost:5775'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
final profileSettings = ProfileSettingsV1.defaultsForLocale(
|
||||||
|
const Locale('zh'),
|
||||||
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
@@ -39,6 +43,8 @@ void main() {
|
|||||||
params: params,
|
params: params,
|
||||||
runService: runService,
|
runService: runService,
|
||||||
onCompleted: (_) async {},
|
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 typing import Any, Sequence
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from ag_ui.core.types import Tool
|
from ag_ui.core.types import Tool
|
||||||
from core.agentscope.prompts.agent_prompt import build_agent_prompt
|
from core.agentscope.prompts.agent_prompt import build_agent_prompt
|
||||||
from core.agentscope.prompts.sections import wrap_section
|
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))
|
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(
|
def build_system_prompt(
|
||||||
*,
|
*,
|
||||||
agent_type: AgentType,
|
agent_type: AgentType,
|
||||||
ai_language: str,
|
ai_language: str,
|
||||||
llm_config: SystemAgentLLMConfig | None = None,
|
llm_config: SystemAgentLLMConfig | None = None,
|
||||||
tools: Sequence[Tool | dict[str, Any]] | None = None,
|
tools: Sequence[Tool | dict[str, Any]] | None = None,
|
||||||
|
now_utc: datetime | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
sections: list[str | None] = [
|
sections: list[str | None] = [
|
||||||
|
_build_time_context(now_utc=now_utc),
|
||||||
_build_safety_section(),
|
_build_safety_section(),
|
||||||
build_agent_prompt(
|
build_agent_prompt(
|
||||||
agent_type=agent_type,
|
agent_type=agent_type,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Awaitable, Callable
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
from ag_ui.core.types import RunAgentInput
|
from ag_ui.core.types import RunAgentInput
|
||||||
@@ -275,6 +276,7 @@ class AgentScopeRunner:
|
|||||||
ai_language=ai_language,
|
ai_language=ai_language,
|
||||||
llm_config=stage_config.llm_config,
|
llm_config=stage_config.llm_config,
|
||||||
tools=None,
|
tools=None,
|
||||||
|
now_utc=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
||||||
_, worker_payload_raw = await finalize_json_response(
|
_, worker_payload_raw = await finalize_json_response(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from .agent_chat_message import AgentChatMessage
|
from .agent_chat_message import AgentChatMessage
|
||||||
from .agent_chat_session import AgentChatSession
|
from .agent_chat_session import AgentChatSession
|
||||||
|
from .anonymous_session_snapshot import AnonymousSessionSnapshot
|
||||||
from .auth_user import AuthUser
|
from .auth_user import AuthUser
|
||||||
from .invite_code import InviteCode
|
from .invite_code import InviteCode
|
||||||
from .llm import Llm
|
from .llm import Llm
|
||||||
@@ -18,6 +19,7 @@ from .user_points import UserPoints
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"AgentChatMessage",
|
"AgentChatMessage",
|
||||||
"AgentChatSession",
|
"AgentChatSession",
|
||||||
|
"AnonymousSessionSnapshot",
|
||||||
"AuthUser",
|
"AuthUser",
|
||||||
"InviteCode",
|
"InviteCode",
|
||||||
"Llm",
|
"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
|
||||||
|
)
|
||||||
@@ -46,11 +46,20 @@ class NotificationSettings(BaseModel):
|
|||||||
allow_vibration: bool = True
|
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):
|
class ProfileSettingsV1(BaseModel):
|
||||||
version: Literal[1] = 1
|
version: Literal[1] = 1
|
||||||
preferences: PreferenceSettings = Field(default_factory=PreferenceSettings)
|
preferences: PreferenceSettings = Field(default_factory=PreferenceSettings)
|
||||||
privacy: dict[str, object] = Field(default_factory=dict)
|
privacy: dict[str, object] = Field(default_factory=dict)
|
||||||
notification: NotificationSettings = Field(default_factory=NotificationSettings)
|
notification: NotificationSettings = Field(default_factory=NotificationSettings)
|
||||||
|
divination_tutorial: DivinationTutorialSettings = Field(
|
||||||
|
default_factory=DivinationTutorialSettings
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
ProfileSettingsUnion = ProfileSettingsV1
|
ProfileSettingsUnion = ProfileSettingsV1
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -5,7 +5,7 @@ from decimal import Decimal
|
|||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
from uuid import UUID, uuid4
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from core.http.errors import ApiProblemError
|
from core.http.errors import ApiProblemError
|
||||||
@@ -17,6 +17,7 @@ from schemas.domain.chat_message import (
|
|||||||
AgentChatMessage as AgentChatMessageSchema,
|
AgentChatMessage as AgentChatMessageSchema,
|
||||||
AgentChatMessageMetadata,
|
AgentChatMessageMetadata,
|
||||||
)
|
)
|
||||||
|
from v1.agent.anonymizer import anonymize
|
||||||
|
|
||||||
|
|
||||||
class ToolResultPayloadStorage(Protocol):
|
class ToolResultPayloadStorage(Protocol):
|
||||||
@@ -96,7 +97,7 @@ class AgentRepository:
|
|||||||
async def rollback(self) -> None:
|
async def rollback(self) -> None:
|
||||||
await self._session.rollback()
|
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:
|
try:
|
||||||
session_uuid = UUID(session_id)
|
session_uuid = UUID(session_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -112,11 +113,50 @@ class AgentRepository:
|
|||||||
)
|
)
|
||||||
session = (await self._session.execute(stmt)).scalar_one_or_none()
|
session = (await self._session.execute(stmt)).scalar_one_or_none()
|
||||||
if session is None:
|
if session is None:
|
||||||
return
|
return []
|
||||||
if session.deleted_at is not None:
|
if session.deleted_at is not None:
|
||||||
return
|
return []
|
||||||
session.deleted_at = datetime.now(timezone.utc)
|
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()
|
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(
|
async def persist_user_message(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class AgentRepositoryLike(Protocol):
|
|||||||
|
|
||||||
async def rollback(self) -> None: ...
|
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(
|
async def get_history_day(
|
||||||
self,
|
self,
|
||||||
@@ -126,6 +126,8 @@ class AttachmentStorageLike(Protocol):
|
|||||||
expires_in_seconds: int,
|
expires_in_seconds: int,
|
||||||
) -> str: ...
|
) -> str: ...
|
||||||
|
|
||||||
|
async def delete_prefix(self, *, bucket: str, prefix: str) -> int: ...
|
||||||
|
|
||||||
def parse_signed_url(self, url: str) -> tuple[str, str]: ...
|
def parse_signed_url(self, url: str) -> tuple[str, str]: ...
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -235,8 +235,28 @@ class AgentService:
|
|||||||
return
|
return
|
||||||
raise
|
raise
|
||||||
ensure_session_owner(owner_id=owner, current_user=current_user)
|
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._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(
|
async def _append_context_cache_user_message(
|
||||||
self,
|
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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user