zlwu 8 miesięcy temu
rodzic
commit
08e4ba41ca

+ 15 - 0
backend/migrations/add_user_special_scenario_topic_logs.sql

@@ -0,0 +1,15 @@
+-- 情景对话模式:用户-特殊情景-话题对话记录表
+-- 用于记录用户在某个特殊场景下对某个话题已进行过对话,选场景时排除「3 个话题都聊完」的场景,选话题时仅在未完成话题中等概率选择。
+-- 参考:项目文档/情景对话模式设计与改造方案.md
+
+CREATE TABLE IF NOT EXISTS user_special_scenario_topic_logs (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+    user_id VARCHAR(64) NOT NULL COMMENT '用户ID(关联 users.user_id)',
+    scenario_key VARCHAR(32) NOT NULL COMMENT '场景: window|reading|eating',
+    topic_key VARCHAR(32) NOT NULL COMMENT '话题键(如 weather|season|travel 等)',
+    conversed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '该场景该话题下首次对话时间',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
+    UNIQUE KEY uk_user_scenario_topic (user_id, scenario_key, topic_key),
+    INDEX idx_user_scenario (user_id, scenario_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+COMMENT='用户情景话题对话记录(用于概率排除与不再触发)';

+ 287 - 0
项目文档/情景对话模式设计与改造方案.md

@@ -0,0 +1,287 @@
+# 情景对话模式设计与改造方案
+
+## 一、需求摘要
+
+| 项目 | 说明 |
+|------|------|
+| 场景数量 | 4 个:当前普通场景(70%) + 3 个特殊场景(各 10%) |
+| 特殊场景 | Lumi在窗前看风景 / Lumi在桌前看书 / Lumi在桌前吃饭 |
+| 每场景话题 | 各 3 个话题,首句展示时未完成话题等概率(33.33%;完成后剩余话题 50% 等概) |
+| 持久化 | 用户在某场景下对某话题「开启对话」后落库,该话题不再参与概率;某场景 3 个话题全部完成后,该场景不再触发 |
+| 话题切换入口 | 仅在特殊场景下,在 /chatbot 字幕区域**上方**增加切换按钮,用于在**当前场景下未聊过的话题**之间切换;切换后由动画形象先说话,首句按规范。已聊过的话题不参与切换。 |
+| 测试 | URL 支持强制触发某场景,便于测试 |
+
+---
+
+## 二、表模型设计
+
+### 2.1 新增表:用户情景话题对话记录
+
+记录「用户在某个特殊场景下对某个话题已进行过对话」及时间,用于概率排除与不再触发。
+
+| 表名 | 说明 |
+|------|------|
+| `user_special_scenario_topic_logs` | 用户-特殊情景-话题对话记录 |
+
+**字段设计**
+
+| 字段 | 类型 | 约束 | 说明 |
+|------|------|------|------|
+| id | BIGINT | PK, AUTO_INCREMENT | 主键 |
+| user_id | VARCHAR(64) | NOT NULL, INDEX | 用户 ID |
+| scenario_key | VARCHAR(32) | NOT NULL, INDEX | 场景唯一键:`window` / `reading` / `eating` |
+| topic_key | VARCHAR(32) | NOT NULL, INDEX | 话题唯一键(见下节常量) |
+| conversed_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 用户在该场景该话题下「开启对话」的时间 |
+| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 记录创建时间 |
+
+**约束与索引**
+
+- `UNIQUE(user_id, scenario_key, topic_key)`:同一用户同一场景同一话题只记录一条。
+- `INDEX idx_user_scenario (user_id, scenario_key)`:用于查询某用户某场景下已完成话题列表。
+
+**业务含义**
+
+- 插入一条即表示:该用户在该场景下对该话题「已经聊过」。
+- 选场景时:若某场景下该用户已有 3 条记录,则该场景从概率池中剔除。
+- 选话题时:仅在该场景下「未出现在本表」的话题中做等概率选择。
+
+### 2.2 场景与话题常量(代码内配置,可不建表)
+
+| scenario_key | 场景名称 | 话题 topic_key | 话题名称(中/英) | 首句模板(Coco 替换为用户名) |
+|--------------|----------|----------------|-----------------|-------------------------------|
+| window | Lumi在窗前看风景 | weather | Weather|天气 | Hi, {name}! The sky is so clear today. How is the weather at your place? |
+| window | | season | Season|季节 | Hi {name}! The leaves are changing colour. It's fall. What is your favorite season? |
+| window | | travel | Travel|旅行 | Hi, {name}! I want to go somewhere far away. Where do you want to travel? |
+| reading | Lumi在桌前看书 | reading | Reading|阅读 | Hi, {name}! I am reading. This story is crazy! What book are you reading lately? |
+| reading | | hobbies | Hobbies|爱好 | Hi, {name}! I am reading. I love it! What do you do in your free time? |
+| reading | | relaxation | Relaxation|放松 | Hi, {name}! I am reading. It is the only way to relax my brain. What is your way to escape? |
+| eating | Lumi在桌前吃饭 | favourite_food | Favourite food|喜欢的食物 | Hi, {name}! I am having snacks. It's so delicious. What is your favorite food? |
+| eating | | hometown_food | Hometown food|家乡的食物 | Hi, {name}! I am having snacks. It's so delicious. What is your hometown's best dish? |
+| eating | | daily_meal | Daily meal|日常饮食 | Hi, {name}! I am having snacks. It's so delicious. What you ate today? |
+
+---
+
+## 三、概率与流程
+
+### 3.1 场景触发概率(由后端控制)
+
+- **随机场景与话题由后端决定**:前端只发送 `start_greeting`(可选带 `forceScene` 用于测试),后端查库、算权重、做加权随机后通过 `greeting_context` 把本次的 scene/topic 下发给前端展示。这样既依赖后端才能查 `user_special_scenario_topic_logs`,也保证概率与“已聊完则排除”逻辑一致、不可被前端篡改。
+- 进入 `/chatbot` 时(或前端发起 `start_greeting` 时)由后端决定本次是否进入「特殊场景」及哪一个。
+- **权重**:普通场景 70,三个特殊场景各 10;若某特殊场景下该用户已聊完 3 个话题,则从池中移除该场景,其余权重按比例归一(见下)。
+- **归一示例**:
+  - 4 个都可用:default 70%,window 10%,reading 10%,eating 10%。
+  - eating 已聊完:default 80%,window 10%,reading 10%。
+  - eating + window 已聊完:default 90%,reading 10%。
+
+### 3.2 按概率随机触发的具体实现
+
+**步骤一:查用户在各特殊场景下已完成的话题数**
+
+- 对 `user_special_scenario_topic_logs` 按 `(user_id, scenario_key)` 分组统计条数。
+- 得到:`{ "window": n1, "reading": n2, "eating": n3 }`(n 为 0~3)。
+- 若某场景的 n === 3,则该场景视为「已聊完」,不再参与本次随机。
+
+**步骤二:构造「可选场景 + 权重」列表**
+
+- 固定权重:`default=70`, `window=10`, `reading=10`, `eating=10`。
+- 若某特殊场景已聊完(该用户在该场景下已有 3 条 log),则从列表中移除该场景(权重也移除)。
+- 得到列表例如:`[(default, 70), (window, 10), (reading, 10)]`(eating 已聊完时)。
+
+**步骤三:按权重随机选一个场景**
+
+- 方法 A(推荐):**加权随机**
+  - 总权重 `total = sum(权重)`。
+  - 在 `[0, total)` 上均匀随机一个数 `r`。
+  - 遍历列表,累加权重,当累加值 > r 时,当前项即为选中场景。
+- 方法 B:**先归一成概率再抽**
+  - 概率 `p_i = weight_i / total`。
+  - 用 `random.choices([scene1, scene2, ...], weights=[w1, w2, ...], k=1)[0]` 或等价逻辑抽取一次。
+
+**步骤四:若选中特殊场景,再按「未完成话题」等概率选一个话题**
+
+- 该场景下 3 个话题中,排除已在 `user_special_scenario_topic_logs` 中有记录的话题,得到「未完成话题列表」。
+- 在未完成话题中等概率随机一个(如 `random.choice(未完成列表)`),作为本次首句的话题。
+
+**小结**
+
+| 环节 | 数据来源 | 随机方式 |
+|------|----------|----------|
+| 场景 | 权重 70/10/10/10,剔除已聊完的特殊场景后归一 | 加权随机选 1 个 |
+| 话题 | 该场景下未在 log 表中出现的话题列表 | 等概率选 1 个 |
+
+**伪代码(Python 风格)**
+
+```python
+# 1) 查询用户各特殊场景已完成话题数
+completed = get_completed_topic_count_per_scenario(user_id)  # {"window": 2, "reading": 0, "eating": 3}
+
+# 2) 构建可选场景与权重(已聊完的 special 不加入)
+SCENE_WEIGHTS = [("default", 70), ("window", 10), ("reading", 10), ("eating", 10)]
+candidates = [(s, w) for s, w in SCENE_WEIGHTS if s == "default" or completed.get(s, 0) < 3]
+
+# 3) 加权随机选场景
+total = sum(w for _, w in candidates)
+r = random.uniform(0, total)
+acc = 0
+for scene, weight in candidates:
+    acc += weight
+    if r < acc:
+        chosen_scene = scene
+        break
+
+# 4) 若是特殊场景,在未完成话题中等概率选一个
+if chosen_scene != "default":
+    topic_keys = SCENARIO_TOPICS[chosen_scene]  # 如 ["weather","season","travel"]
+    done_topics = set(get_done_topic_keys(user_id, chosen_scene))  # 从 log 表查
+    available = [t for t in topic_keys if t not in done_topics]
+    chosen_topic = random.choice(available) if available else None
+```
+
+### 3.3 话题选择(仅特殊场景)
+
+- 仅在该场景下「该用户未在 `user_special_scenario_topic_logs` 中有记录」的话题中选。
+- 未完成 3 个:每个 33.33%;未完成 2 个:每个 50%;未完成 1 个:100%。
+
+### 3.4 URL 强制场景(测试用)
+
+- 建议:`/chatbot?scene=default|window|reading|eating`。
+- 若存在合法 `scene`,则忽略概率,强制使用该场景;特殊场景下再按上述规则选话题(或测试时可再支持 `?topic=xxx` 强制话题)。
+
+### 3.5 「开启对话」的落库时机
+
+- 用户在当前会话中:已进入某特殊场景 + 已选某话题,且**点击录音并真正发起第一次说话**(即后端收到该会话下第一条有效用户语音,如 `audio_end` 触发 STT 结果并进入对话)时,写一条 `(user_id, scenario_key, topic_key, conversed_at)`。
+- 同一 (user_id, scenario_key, topic_key) 只写一次(INSERT IGNORE 或先查再插)。
+
+---
+
+## 四、接口与消息协议
+
+### 4.1 前端 → 后端:start_greeting 扩展
+
+- 现有:`{ type: 'start_greeting', requestId }`。
+- 扩展:可选携带 `data.forceScene`,用于测试。
+  - 示例:`{ type: 'start_greeting', requestId, data: { forceScene: 'window' } }`。
+  - 取值:`default` | `window` | `reading` | `eating`。
+
+### 4.2 后端 → 前端:新增 greeting_context(首条下发)
+
+- 在首句 `text_result` / 情绪 / 音频之前,若本次为特殊场景,先发一条 `greeting_context`,便于前端展示场景与话题切换。
+- 建议结构:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| type | string | `greeting_context` |
+| requestId | string | 与当次 greeting 一致 |
+| scene | string | `default` \| `window` \| `reading` \| `eating` |
+| topic | string | 当前选中的 topic_key(仅特殊场景有) |
+| topics | array | 该场景下**未完成**的话题列表(与切换按钮选项一致),如 `[{ key, labelEn, labelZh }]`;或全量 3 个话题并带 `done` 标记,由前端过滤出未完成用于切换 |
+| firstLine | string | 当前话题首句(已替换用户名),便于前端同步展示/播报 |
+
+- 若为普通场景:可不发 `greeting_context`,或发 `scene: 'default'`、无 `topics`,沿用现有首句逻辑。
+
+### 4.3 用户语音请求携带当前场景/话题(用于落库)
+
+- 在特殊场景下,用户点击录音并发送的**第一条语音**需让后端知道「当前场景 + 话题」,以便在适当时机写入 `user_special_scenario_topic_logs`。
+- 方案:在 `audio_chunk` 或 `audio_end` 的 `data` 中增加可选字段,仅在本会话为特殊场景且为首条用户语音时携带:
+  - 例如:`scene`, `topic`(与 `greeting_context` 中的 `scene` / `topic` 一致)。
+- 后端在「第一次收到该用户在本 greeting 会话中的有效语音」且携带了 `scene`+`topic` 时,写入一条 log,并不再为同一 (user_id, scenario_key, topic_key) 重复写入。
+
+### 4.4 话题切换按钮与行为(仅特殊场景)
+
+当本次为特殊场景时,在 **/chatbot 界面字幕区域上方** 展示一个话题切换按钮,点击后在**当前场景下尚未聊过的话题**之间循环切换,切换后由动画形象先说话(首句按规范)。
+
+**按钮规格**
+
+| 项目 | 说明 |
+|------|------|
+| 图片 | `toggle-icon-btn.svg`,通过 OSS 获取(与项目现有 OSS 方式一致) |
+| 宽高 | 宽 32、高 32,均基于视口宽高最小值(vmin)换算,与现有移动端适配一致 |
+| 位置 | 在字幕区域上方,与字幕区域左对齐 |
+
+**切换逻辑**
+
+- **可切换的话题**:仅包含该场景下「该用户尚未在 `user_special_scenario_topic_logs` 中有记录」的话题。例如「Lumi在窗前看风景」有 Weather|天气、Season|季节、Travel|旅行;若用户已与 Weather 聊过并落库,则本次进入该场景时,按钮只在 Season、Travel 两个话题之间切换。
+- **点击行为**:每次点击切换到「下一个」未完成话题(循环);切换后:
+  1. 更新当前选中的 topic;
+  2. 由动画形象先说话:首句内容按 2.2 节该话题的规范首句(用户名替换),与进入场景时首句一致;
+  3. 前端可本地用首句模板+用户名生成文案并请求 TTS,或向后端请求该 topic 的首句/音频。
+- **与 greeting_context 的配合**:后端下发的 `greeting_context.topics` 建议仅为**当前场景下未完成的话题**列表(与随机选话题时的候选一致),前端用该列表驱动切换按钮的选项;若后端下发全量 3 个话题并带「是否已完成」标记,前端则只把未完成的加入切换列表。
+
+**示例**
+
+- 首次进入「Lumi在窗前看风景」:3 个话题都未聊过,按钮在 Weather、Season、Travel 三者间切换;每次切换到某话题,AI 说该话题规范首句。
+- 用户与 Weather 聊过并落库后,再次触发该场景:按钮只在 Season、Travel 之间切换,每次切换后 AI 说对应规范首句。
+
+---
+
+## 五、后端改造要点
+
+| 模块 | 改造内容 |
+|------|----------|
+| 表与迁移 | 新增 `user_special_scenario_topic_logs` 表及迁移/初始化脚本。 |
+| 常量/配置 | 定义 scenario_key、topic_key 与首句模板映射(可放在 `ai_service` 或单独 `scenario_config`)。 |
+| 首句逻辑 `_send_greeting_message()` | 1)接收 `forceScene`;2)查用户已完成 (scenario, topic);3)计算可用场景与权重,按概率或 force 选场景;4)若为特殊场景,在未完成话题中等概率选 topic,生成首句;5)先发 `greeting_context`(若为特殊场景),再发 text_result/emotion/audio。 |
+| 记录对话 | 在处理首条用户语音(如 STT 结果提交或进入 LLM 前)若请求体带 `scene`+`topic` 且为特殊场景,则插入 `user_special_scenario_topic_logs`(防重)。 |
+| 服务层 | 新增或扩展现有 user_service:按 user_id 查询某场景下已完成 topic 列表;插入一条 (user_id, scenario_key, topic_key)。 |
+
+---
+
+## 六、前端改造要点
+
+| 模块 | 改造内容 |
+|------|----------|
+| URL 解析 | 进入 `/chatbot` 时读取 `?scene=default|window|reading|eating`,在调用 `startGreeting()` 时通过 `data.forceScene` 传给后端。 |
+| start_greeting 发送 | 在 `ws.send({ type: 'start_greeting', requestId, data: { forceScene } })` 中带上可选的 `forceScene`。 |
+| 新消息类型 | 处理 `greeting_context`:更新场景状态、当前话题、话题列表、首句文案;切换特殊场景 UI(背景/氛围)。 |
+| 话题切换按钮 | 仅特殊场景展示:字幕区域**上方**、与字幕左对齐;图片 `toggle-icon-btn.svg`(OSS),宽高各 32(基于 vmin);点击在**未完成话题**间循环切换,切换后由动画形象先说该话题规范首句(本地模板+TTS 或请求后端)。 |
+| 话题切换 | 切换 topic 时更新当前首句展示并播报该话题首句 TTS(首句按 2.2 节规范);可切换列表仅含 `greeting_context.topics` 中的未完成话题。 |
+| 录音/发送 | 在特殊场景下,当前会话第一次发送语音时,在 `audio_chunk` 或 `audio_end` 的 payload 中附带当前 `scene`、`topic`,供后端落库。 |
+
+---
+
+## 七、实施顺序建议
+
+1. **库表**:在 `models_db.py` 增加 `UserSpecialScenarioTopicLog`,在 `init_database.py` 增加建表 SQL(若使用迁移工具则写迁移脚本)。
+2. **后端常量与首句**:实现 scenario/topic 常量与首句模板;在 `_send_greeting_message()` 中实现概率与 force 逻辑、`greeting_context` 下发及特殊场景首句生成。
+3. **后端落库**:在 WebSocket 处理首条用户语音处识别 `scene`+`topic`,调用 service 写入 `user_special_scenario_topic_logs`。
+4. **前端**:URL 解析与 `forceScene`、处理 `greeting_context`、特殊场景 UI 与话题切换、首条语音携带 scene/topic。
+
+---
+
+## 八、概率汇总表(便于实现与测试)
+
+| 已完成特殊场景数 | default | window | reading | eating |
+|------------------|---------|--------|---------|--------|
+| 0 | 70% | 10% | 10% | 10% |
+| 1(任一完成) | 80% | 10% | 10% | 0% |
+| 2(两完成) | 90% | 10% 或 0% | 10% 或 0% | 0% |
+| 3(全完成) | 100% | 0% | 0% | 0% |
+
+(具体哪一场景被剔除,由「该用户在该场景下是否已聊完 3 个话题」决定。)
+
+---
+
+## 九、新模型 SQL
+
+迁移脚本路径:`backend/migrations/add_user_special_scenario_topic_logs.sql`
+
+```sql
+-- 情景对话模式:用户-特殊情景-话题对话记录表
+CREATE TABLE IF NOT EXISTS user_special_scenario_topic_logs (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+    user_id VARCHAR(64) NOT NULL COMMENT '用户ID(关联 users.user_id)',
+    scenario_key VARCHAR(32) NOT NULL COMMENT '场景: window|reading|eating',
+    topic_key VARCHAR(32) NOT NULL COMMENT '话题键(如 weather|season|travel 等)',
+    conversed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '该场景该话题下首次对话时间',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
+    UNIQUE KEY uk_user_scenario_topic (user_id, scenario_key, topic_key),
+    INDEX idx_user_scenario (user_id, scenario_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+COMMENT='用户情景话题对话记录(用于概率排除与不再触发)';
+```
+
+执行方式:在项目根目录或 backend 目录下使用 MySQL 客户端执行该文件,或在 `backend/init_database.py` 中增加对应 `CREATE TABLE` 调用。
+
+---
+
+*文档版本:v1;表名与字段以最终实现为准。*