Respan Dataset Explorer

Select one behavior. Every returned turn has one binary label: Present or Absent. Source: final dense boolean release.

5,167,182physical rows
86shards
0.00%qualified row coverage
0.00%qualified cell coverage
Random row JSON API

turns-00082.parquet:33274

1261e9aa5a96461bf8c70f18
turn 1/1gpt-4.1-mini-2025-04-14IndonesianUnited Kingdom167 words
degenerate_repetitionAbsentFinal dense release
USER
User: kenapa yapping begini bisa awet sampai sekarang
User: kenapa yapping begini bisa awet sampai sekarang
Assistant:
ASSISTANT
Yapping yang kamu maksud mungkin merujuk pada gaya berbicara yang cepat, sering mengoceh, atau mungkin kebiasaan tertentu dalam berkomunikasi. Ada beberapa alasan kenapa gaya yapping seperti ini bisa awet sampai sekarang:

1. **Karakter pribadi**: Beberapa orang memang secara alami suka banyak bicara atau cepat berbicara. Ini bisa jadi bagian dari kepribadian mereka yang membuat gaya yapping tetap terjaga.

2. **Lingkungan sosial**: Jika seseorang tumbuh dan berkembang dalam lingkungan yang terbiasa dengan komunikasi cepat dan banyak, gaya yapping akan terus terbawa dan dipraktikkan.

3. **Kebiasaan yang terbentuk**: Lama-kelamaan, kebiasaan berbicara dengan gaya yapping ini menjadi sesuatu yang sulit diubah karena sudah menjadi pola komunikasi yang nyaman bagi orang tersebut.

4. **Interaksi yang menyenangkan**: Kadang yapping jadi cara untuk membangun koneksi dengan orang lain, membuat percakapan jadi lebih hidup dan seru.

Kalau kamu mau, aku bisa bantu kasih tips untuk mengelola gaya komunikasi supaya lebih efektif dan nyaman juga bagi pendengar. Mau?

turns-00082.parquet:33275

055984d0b76a1b796ab6dafe
turn 1/1gpt-4.1-mini-2025-04-14IndonesianUnited States96 words
degenerate_repetitionAbsentFinal dense release
USER
User: jelaskan sedikit tentang mikazuki Arion vtuber
User: jelaskan sedikit tentang mikazuki Arion vtuber
Assistant:
ASSISTANT
Mikazuki Arion adalah seorang VTuber atau Virtual YouTuber yang dikenal dengan karakter anime yang menarik dan kepribadian yang ceria. Sebagai VTuber, Mikazuki Arion biasanya melakukan streaming bermain game, berinteraksi dengan penggemar melalui chat, serta berbagai konten hiburan lainnya seperti bernyanyi atau berbicara santai. VTuber ini sering kali memiliki desain karakter yang unik dan latar cerita tertentu yang menambah daya tariknya di komunitas penggemar virtual. Jika kamu tertarik, kamu bisa mencari kanal resminya di platform seperti YouTube untuk melihat konten-kontennya secara langsung.

turns-00082.parquet:33276

cd73d5a33538175fbad649be
turn 1/16gpt-4.1-mini-2025-04-14ChineseSingapore750 words
degenerate_repetitionAbsentFinal dense release
USER
package com.example.springboot.service;

import com.example.springboot.dto.LongTermMemoryWithWord;
import com.example.springboot.entity.LongTermMemory;
import com.example.springboot.entity.Study;
import com.example.springboot.entity.Word;
import com.example.springboot.mapper.LongTermMemoryMapper;
import com.example.springboot.mapper.StudyMapper;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;

@Service
public class StudyService {

    // --- Mapper 注入 ---
    @Resource
    private StudyMapper studyMapper;

    @Resource
    private LongTermMemoryMapper longTermMemoryMapper;

    // ================= 短期记忆相关 =================

    private static final int COOLDOWN = 2;
    private static final int PASS_SCORE = 3;
    private static final int N = 3;

    /**
     * 获取当前轮次的短期记忆复习候选单词
     */
    public List<Study> getReviewList(Integer userId, int round) {
        return studyMapper.selectReviewCandidates(userId, round, COOLDOWN, N);
    }

    /**
     * 处理短期记忆复习结果:
     * 通过累加分数判断是否通过阶段,
     * 通过后迁移到长期记忆表,否则更新分数和复习轮次
     */
    public void processReviewResult(Integer userId, Integer wordId, int currentRound, boolean remembered) {
        Study study = studyMapper.selectStudyByUserIdAndWordId(userId, wordId);
        if (study == null) return;

        int newScore = study.getScore();
        if (remembered) {
            newScore += 1;
        } else {
            newScore = Math.max(newScore - 1, 0);
        }

        if (newScore >= PASS_SCORE) {
            // 通过短期考核,迁移到长期记忆,删除短期记录
            moveToLongTermMemory(study, currentRound, newScore);
            studyMapper.deleteStudyByUserIdAndWordId(userId, wordId);
        } else {
            // 未通过,更新短期记忆分数和最近复习的轮次
            study.setScore(newScore);
            study.setLastSeen(currentRound);
            studyMapper.updateStudy(study);
        }
    }

    /**
     * 短期记忆→长期记忆迁移
     * intervalDays=0 表示首次长期复习未完成,
     * nextReviewDate设置为当前时间以供首次长期复习
     */
    private void moveToLongTermMemory(Study study, int currentRound, int score) {
        LongTermMemory ltm = new LongTermMemory();

        ltm.setUserId(study.getUserId());
        ltm.setWordId(study.getWordId());
        ltm.setReviewCount(0);       // 首次长期复习前 复习计数0
        ltm.setIntervalDays(0);      // 0表示首次长期复习阶段
        LocalDateTime now = LocalDateTime.now();
        ltm.setLastReviewDate(now);
        ltm.setNextReviewDate(now);  // 立即可复习首次长期任务
        // longmemoryScore, longmemorySeen等字段保持默认即可

        longTermMemoryMapper.insertLongTermMemory(ltm);
    }

    /**
     * 用户手动添加单词到短期记忆
     */
    public int insertUserSelectedWords(Integer userId, List<Integer> wordIds) {
        int count = 0;
        for (Integer wordId : wordIds) {
            Study exist = studyMapper.selectStudyByUserIdAndWordId(userId, wordId);
            if (exist == null) {
                Study study = new Study();
                study.setUserId(userId);
                study.setWordId(wordId);
                study.setScore(0);
                study.setLastSeen(-1);
                studyMapper.insertStudy(study);
                count++;
                studyMapper.deletePlanWord(userId, wordId);
            }
        }
        return count;
    }

    /**
     * 获取用户已选短期记忆单词列表
     */
    public List<Word> getSelectedWordsByUserId(Integer userId) {
        return studyMapper.selectSelectedWordsByUserId(userId);
    }

    /**
     * 获取用户待学习的短期记忆单词(待加入短期记忆)
     */
    public List<Study> getPendingWords(Integer userId, int limit) {
        return studyMapper.selectPendingWordsFromPlan(userId, limit);
    }

    /**
     * 删除单个短期记忆单词记录
     */
    public int deleteWord(Integer userId, Integer wordId) {
        return studyMapper.deleteStudyByUserIdAndWordId(userId, wordId);
    }

    /**
     * 批量删除短期记忆单词记录
     */
    public int deleteWords(Integer userId, List<Integer> wordIds) {
        return studyMapper.deleteStudiesByUserIdAndWordIds(userId, wordIds);
    }

    /**
     * 查询用户短期记忆待复习单词总数(用于展示)
     */
    public int getPendingCount(Integer userId) {
        return studyMapper.countPendingWords(userId);
    }


    // ================= 长期记忆相关 =================

    // 长期记忆复习阶段间隔(单位:天)
    private static final int[] INTERVALS = {1, 2, 4, 7, 15};

    /**
     * 获取长期记忆阶段待复习单词列表(nextReviewDate <= 当前时间)
     */
    public List<LongTermMemoryWithWord> getLongTermReviewList(Integer userId, int longMemorySeen, int offset, int size) {
        LocalDateTime now = LocalDateTime.now();
        return longTermMemoryMapper.selectDueReviews(userId, now, offset, size, longMemorySeen);
    }

    /**
     * 根据长期记忆ID查询对应实体
     */
    public LongTermMemory getLongTermMemoryById(Integer id) {
        return longTermMemoryMapper.selectById(id);
    }

    /**
     * 处理长期记忆复习结果:
     * 需累计通过3次才进入下一阶段间隔;
     * 未通过扣减分数,但不降低阶段;
     * 通过后更新时间间隔及复习时间。
     */
    public void processLongTermReviewResult(LongTermMemory ltm, boolean remembered) {
        if (ltm == null) return;

        // 累计复习次数
        Integer reviewCount = ltm.getReviewCount() == null ? 0 : ltm.getReviewCount() + 1;

        // 当前阶段短期分数
        Integer score = ltm.getLongmemoryScore() == null ? 0 : ltm.getLongmemoryScore();

        // 当前累计连续通过次数
        Integer passCount = ltm.getPassCount() == null ? 0 : ltm.getPassCount();

        // 当前复习轮次,由 longmemorySeen 表示
        int round = ltm.getLongmemorySeen() == null ? 0 : ltm.getLongmemorySeen();

        int[] INTERVALS = {1, 2, 4, 7, 15};
        int intervalIndex = 0;

        // 计算当前阶段索引,根据 intervalDays 字段
        Integer intervalDays = ltm.getIntervalDays() == null ? 0 : ltm.getIntervalDays();
        for (int i = 0; i < INTERVALS.length; i++) {
            if (intervalDays != null && INTERVALS[i] == intervalDays.intValue()) {
                intervalIndex = i;
                break;
            }
        }

        if (remembered) {
            // 复习通过,分数+1,计数+1
            score++;
            passCount++;

            // 连续通过超过阈值,准备进入下一轮次
            if (passCount >= 3) {
                round++;              // 轮次+1
                passCount = 0;        // 复位连续通过计数
                score = 0;            // 复位分数

                // 阶段后移,不能超过最大间隔
                if (intervalIndex < INTERVALS.length - 1) {
                    intervalIndex++;
                }

                int nextInterval = INTERVALS[intervalIndex];
                // 设置下次复习时间(当前时间 + 阶段间隔)
                ltm.setNextReviewDate(LocalDateTime.now().plusDays(nextInterval));
                // 更新间隔天数字段
                ltm.setIntervalDays(nextInterval);
                // 更新轮次
                ltm.setLongmemorySeen(round);
            }
        } else {
            // 未通过则分数减1最低不低于0
            score = Math.max(score - 1, 0);
            // 不更新轮次和间隔
        }

        // 每次复习都更新以下字段
        ltm.setReviewCount(reviewCount);
        ltm.setLongmemoryScore(score);
        ltm.setPassCount(passCount);
        ltm.setLastReviewDate(LocalDateTime.now());

        // 更新数据库
        longTermMemoryMapper.updateLongTermMemory(ltm);
    }

}
ASSISTANT
这段代码实现了基于短期记忆与长期记忆机制的单词学习服务,以下是核心功能和逻辑点的详细解析:

---

## 主要功能模块

### 1. 短期记忆模块 (Study)

- **获取复习列表**:
  ```java
  public List<Study> getReviewList(Integer userId, int round)
  ```
  从 `StudyMapper` 获取当前用户当前复习轮次中,符合冷却时间 (`COOLDOWN`) 和最大数量 (`N`) 条件的待复习单词列表。

- **处理复习结果**:
  ```java
  public void processReviewResult(Integer userId, Integer wordId, int currentRound, boolean remembered)
  ```
  根据用户是否记住单词调整短期记忆分数:
  - 记住则 +1 分,未记住则 -1 分但不低于0。
  - 若分数达到 `PASS_SCORE`(3分),则将单词从短期记忆迁移到长期记忆,并删除短期记录。
  - 未达到,则更新分数和复习轮次。

- **短期记忆迁移到长期记忆**:
  ```java
  private void moveToLongTermMemory(Study study, int currentRound, int score)
  ```
  创建长期记忆实体,配置初始复习间隔为0,首次复习时间为当前时间。

- **用户添加单词**:
  支持用户将待学单词列表中的单词添加至短期记忆,并从计划表中剔除。

- **短期记忆相关增删改查**:
  包括获取用户已选单词、获取待加入单词列表、单词删除操作等。

---

### 2. 长期记忆模块 (LongTermMemory)

- **复习间隔定义**:
  ```java
  private static final int[] INTERVALS = {1, 2, 4, 7, 15};
  ```
  分别对应复习间隔(单位:天),随着通过阶段不断增长。

- **获取长期记忆待复习列表**:
  ```java
  public List<LongTermMemoryWithWord> getLongTermReviewList(Integer userId, int longMemorySeen, int offset, int size)
  ```
  获取所有需要在当前时间或以前复习的长期记忆单词。

- **处理长期记忆复习结果**:
  ```java
  public void processLongTermReviewResult(LongTermMemory ltm, boolean remembered)
  ```
  处理逻辑是:
  - 每次复习,reviewCount +1;
  - 通过:分数和连续通过次数 +1;
  - 连续通过3次后,进入下一阶段,轮次+1,分数和连续通过次数清零,间隔天数向后移,更新下一次复习时间;
  - 未通过,分数减少但不低于0,阶段不变;
  - 更新最后复习时间及相关字段。

---

## 代码亮点和注意点

- **短期记忆转长期记忆逻辑清晰**,通过复习分数判断,保证了单词掌握达一定水平才迁移,避免长期记忆表膨胀。
- **长期记忆阶段间隔设计合理**,带有复习轮次和复习间隔的调整,符合间隔重复(Spaced Repetition)策略的基本思想。
- **通过计数机制加强记忆强化**,要连续3次复习通过才能进入下一阶段,减少误判。
- **维护多个分数字段**(score、passCount等)以区分短期状态和长期状态,数据粒度更精细。
- **使用LocalDateTime处理复习时间戳**,方便时间逻辑操作。
- **短期记忆中删除计划单词(`studyMapper.deletePlanWord`)体现了防重复机制**。

---

## 建议优化或补充

1. **异常处理**:
   - 当前代码中若查询数据库返回 null 时仅简单返回,建议加日志方便排查。
   
2. **批量操作优化**:
   - `insertUserSelectedWords` 当前为逐条插入,可考虑批量插入提升性能。

3. **定时任务自动复习提醒**:
   - 系统可以设置定时任务,自动提醒或推送用户复习。

4. **间隔策略调整**:
   - 可以将间隔数组作为配置参数,灵活调整间隔时间。

5. **测试用例**:
   - 编写单元测试覆盖关键逻辑,保证复习过程符合预期。

---

如需更详细讲解某个函数、添加单元测试示例,或针对接口设计做讨论,欢迎继续交流。

turns-00082.parquet:33277

14f4cb7f31c24eba0db0111f
turn 2/16gpt-4.1-mini-2025-04-14ChineseSingapore279 words
degenerate_repetitionAbsentFinal dense release
USER
我想长期记忆和短期记忆一模一样,只是长期记忆的短期记忆结束后(当score=3后),单词才会安排下一次复习时间(24小时后,48小时后,4天后,7天后,15天后)。一定达到某个时间点才能出现
ASSISTANT
你的需求是:**短期记忆和长期记忆两部分规则基本一致,但长期记忆阶段的复习必须等到“下一次复习时间”之后才触发,且其阶段间隔分别是24小时、48小时、4天、7天、15天等。**

换句话说:

- 长期记忆的单词,必须`nextReviewDate <= 当前时间` 才能被获取用于复习,复习成功3次才能进入下一阶段,且每阶段的复习间隔时间是固定的(1天、2天、4天、7天、15天)。
- 复习成功时`nextReviewDate`更新为当前时间加阶段间隔。

目前你的代码已经有`nextReviewDate`字段和判断条件,以及阶段间隔数组`INTERVALS`,逻辑也基本符合需求。

---

## 针对需求的具体分析与调整建议

### 1. 确保长期记忆单词只有「下一次复习时间 >= 当前时间」的才会被筛选进行复习

你的方法:

```java
public List<LongTermMemoryWithWord> getLongTermReviewList(Integer userId, int longMemorySeen, int offset, int size) {
    LocalDateTime now = LocalDateTime.now();
    return longTermMemoryMapper.selectDueReviews(userId, now, offset, size, longMemorySeen);
}
```

这个查询会筛选`nextReviewDate <= now`的单词,是正确的。

**确保你的SQL语句中的条件是`next_review_date <= now`。**

---

### 2. 复习通过后`nextReviewDate`的更新要严格按间隔走

你当前这段代码:

```java
if (passCount >= 3) {
    round++;              // 轮次+1
    passCount = 0;        // 复位连续通过计数
    score = 0;            // 复位分数

    // 阶段后移,不能超过最大间隔
    if (intervalIndex < INTERVALS.length - 1) {
        intervalIndex++;
    }

    int nextInterval = INTERVALS[intervalIndex];
    // 设置下次复习时间(当前时间 + 阶段间隔)
    ltm.setNextReviewDate(LocalDateTime.now().plusDays(nextInterval));
    // 更新间隔天数字段
    ltm.setIntervalDays(nextInterval);
    // 更新轮次
    ltm.setLongmemorySeen(round);
}
```

这是正确的。间隔是`INTERVALS`中的天数,设置下次复习时间。

---

### 3. 长期记忆初始创建时不同设置:

目前`moveToLongTermMemory()`中:

```java
ltm.setIntervalDays(0);      // 0表示首次长期复习阶段
LocalDateTime now = LocalDateTime.now();
ltm.setLastReviewDate(now);
ltm.setNextReviewDate(now);  // 立即可复习首次长期任务
```

你的需求是「长期记忆的单词只有达到一定时间才安排下一次复习」,这里是否要限制首次长期记忆首次复习时间?
如果想让长期记忆刚进入时即等待间隔,可以把首次`nextReviewDate`设置成当前时间+对应间隔,如:

```java
ltm.setIntervalDays(INTERVALS[0]);
ltm.setNextReviewDate(LocalDateTime.now().plusDays(INTERVALS[0]));
```

但如果你希望长期记忆刚进入时就可以复习一次,则保持`now`即可。

---

### 4. processLongTermReviewResult中“分数”(score)作用和“通过计数”(passCount)

代码里“分数”和“通过计数”用来判断是否复习通过达到3次阈值,符合你要求,只要通过3次才进入下一阶段。

---

### 5. 短期记忆和长期记忆流程相同

短期记忆和长期记忆代码结构、评分机制相近,只是长期记忆外加了`nextReviewDate`机制限制复习时间。

---

## 总结

| 需求                  | 实现逻辑                      | 代码位置                     | 说明                             |
|------------------|-------------------------|--------------------------|--------------------------------|
| 复习单词必须到指定时间          | 查询时条件`nextReviewDate <= now` | `getLongTermReviewList()`      | 确保SQL或Mapper实现正确         |
| 复习通过3次后进入下个阶段         | 通过计数 `passCount >= 3`          | `processLongTermReviewResult()`| 阶段index +1,更新下次复习时间  |
| 更新下次复习时间为 当前时间 + 阶段间隔 | 设置`ltm.setNextReviewDate(LocalDateTime.now().plusDays(...))` | 同上                        | 定时安排复习                   |
| 长期记忆与短期记忆逻辑保持一致      | 基于score和轮次判断               | `processReviewResult()`与`processLongTermReviewResult()`| 复习分数和轮次机制相似          |

---

## 你可以再检查

- Mapper里面查询长期记忆待复习单词的SQL是否带了时间条件
- 确保首次迁移到长期记忆时`nextReviewDate`设定符合你期望
- `processLongTermReviewResult()`中的时间更新逻辑符合预期

---

## 如需示范修改示例(基于你代码):

```java
private void moveToLongTermMemory(Study study, int currentRound, int score) {
    LongTermMemory ltm = new LongTermMemory();
    ltm.setUserId(study.getUserId());
    ltm.setWordId(study.getWordId());
    ltm.setReviewCount(0);
    // 设置首次长期记忆阶段为第 1 阶段,间隔为 INTERVALS[0] 天
    ltm.setIntervalDays(INTERVALS[0]);
    LocalDateTime now = LocalDateTime.now();
    ltm.setLastReviewDate(now);
    // 未来首次复习时间 = 现在 + 1 天(你可以改成0立即)
    ltm.setNextReviewDate(now.plusDays(INTERVALS[0]));
    longTermMemoryMapper.insertLongTermMemory(ltm);
}
```

如果你想首次迁移后立即进入复习,则:

```java
ltm.setNextReviewDate(now);
ltm.setIntervalDays(0);
```

---

## 总结

你的代码基本已经满足需求,只要

- 确保长期记忆单词的查询使用 `nextReviewDate <= now`,
- 复习通过时更新 `nextReviewDate = now + 阶段间隔天数`,
- 长期记忆阶段间隔数组是 `{1, 2, 4, 7, 15}`,

长期记忆就不会出现没有达到复习时间就进入复习的情况,且两者整体逻辑高度一致。

---

如果需要,我可以帮你写具体的Mapper SQL语句示例或长短期记忆统一处理重构建议。你需要吗?

turns-00082.parquet:33278

049675f29337a842293986b3
turn 3/16gpt-4.1-mini-2025-04-14ChineseSingapore223 words
degenerate_repetitionAbsentFinal dense release
USER
有单词轮换复习机制吗
ASSISTANT
“单词轮换复习机制”一般指的是:  
- **在复习列表中对复习单词进行有序排列(或批次分批处理),避免重复出现同样单词过于频繁,轮换出现,让记忆更均衡**。  
- 例如,通过分页、随机、优先级等方式不断刷新复习单词列表,保证用户每次复习时内容不过于单一,也不会遗漏某些单词。

---

## 对你当前代码“单词轮换复习机制”的分析

### 1. 短期记忆部分

你的短期记忆复习单词获取方法是:

```java
public List<Study> getReviewList(Integer userId, int round) {
    return studyMapper.selectReviewCandidates(userId, round, COOLDOWN, N);
}
```

其中几个关键点:

- 传入了 `round` 当前复习轮次,说明每次调用都带轮次,带一定“轮次区分”。
- `COOLDOWN` 是冷却轮次,表明重新出现单词前至少间隔了多少轮次。
- `N` 是本次获取单词数。

**这本质上就是一种轮换机制**:

- 用户做过的单词不会马上再次出现,必须等待至少`COOLDOWN`轮之后。
- 每轮只给定数量(`N`)单词,控制复习单词数。

但是具体轮换策略、排序逻辑还取决于你 `studyMapper.selectReviewCandidates` 这个MyBatis查询的SQL是怎么写的。

> 例如,你可以通过:  
> - `lastSeen + COOLDOWN <= currentRound`筛选出冷却完毕的单词  
> - 按照 `lastSeen`、`score`、`random()`等排序给出复习单词  
> - 对取得单词做分页,保证轮换性

---

### 2. 长期记忆部分

长期记忆复习单词是通过:

```java
public List<LongTermMemoryWithWord> getLongTermReviewList(Integer userId, int longMemorySeen, int offset, int size)
```

并且根据当前时间过滤:

```java
return longTermMemoryMapper.selectDueReviews(userId, now, offset, size, longMemorySeen);
```

也就是说,长期记忆的复习单词是:

- 仅取`nextReviewDate <= now`的单词(符合时间节点)
- 传入`offset`、`size`,支持分页,从而可以轮换批次查询

这也具备一定的轮换机制:

- 通过分页获得当前可复习单词的分批复习
- 复习完后服务器更新`nextReviewDate`,控制单词再次出现的时间间隔

---

### 3. 若当前代码想更明确实现“轮换”

你可以:

- **在Mapper查询中**  
  - 加入排序策略(如按`lastSeen`或`lastReviewDate`升序、随机打散等),避免总是选同一批单词;
  - 加入分页参数`offset`和`limit`,分批轮换选词。
  
- **在Service层**  
  - 维护“复习进度”,每次复习调用`offset`参数滚动  
  - 结合用户复习情况动态调整取词顺序

- **举例短期记忆SQL思路**:

  ```sql
  SELECT * FROM study
  WHERE user_id = #{userId}
    AND last_seen <= #{currentRound} - #{cooldown}
  ORDER BY last_seen ASC, score DESC
  LIMIT #{n} OFFSET #{offset}
  ```

这样通过`offset`控制轮换分页,`last_seen ASC`保证最早没复习过的优先出现。

---

## 总结

| 轮换机制主体                  | 机制要点                                | 你现有代码支持情况           |
|---------------------------|-----------------------------------|------------------------|
| 短期记忆单词轮换复习              | 按复习轮次+冷却时间控制重复出现               | 通过`round`和`cooldown`控制  |
| 长期记忆单词轮换复习              | 仅复习过期单词(`nextReviewDate <= now`),分页复习    | 通过`nextReviewDate`和分页支持   |
| 加强轮换机制策略                 | 通过排序、分页、随机、权重等混合排序,错开复习单词    | 需要补充Mapper SQL或Service逻辑 |
| 复习顺序动态调整                 | 根据用户表现动态增减频率、不同时段分批出现等           | 目前没有体现,需要扩展        |

---

## 你可以做的改进

- **短期复习时)
  - 在`selectReviewCandidates`的mapper里,使用分页+按照冷却最久的单词先出、分数最低优先的方式获取单词。
  - 或使用`ORDER BY last_seen ASC, score ASC, RAND()` 打散重复度。

- **长期记忆时**
  - 用分页参数 `offset` 和 `size` 避免一次复习全量单词。
  - 定时任务或客户端轮换调用不同分页,实现“批次复习”。

- **持久化用户复习进度**
  - 比如用数据库或缓存记录用户上次复习的offset,下次从该位置开始,完善轮换。

---

## 你是否需要示范一个带轮换的Mapper查询SQL或示范扩展代码?我可以帮你写。

turns-00082.parquet:33279

9afe80bcb67e9c6d666ac4bf
turn 4/16gpt-4.1-mini-2025-04-14ChineseSingapore249 words
degenerate_repetitionAbsentFinal dense release
USER
我想长期记忆加入轮换机制
ASSISTANT
你想给长期记忆也加入**单词轮换复习机制**,避免一次复习全部待复习单词,分批次、分页或按照一定顺序轮换呈现单词。

---

## 实现思路

### 1. 利用分页 + 排序 实现轮换

当前你 `getLongTermReviewList` 方法已经支持分页:

```java
public List<LongTermMemoryWithWord> getLongTermReviewList(Integer userId, int longMemorySeen, int offset, int size) {
    LocalDateTime now = LocalDateTime.now();
    return longTermMemoryMapper.selectDueReviews(userId, now, offset, size, longMemorySeen);
}
```

只要你的 Mapper SQL 支持传入 `offset` 和 `size`,并且在 SQL 中对“复习单词列表”做合理排序(如按照`nextReviewDate ASC`、`lastReviewDate ASC`或者随机排序),就可以实现轮换。

### 2. 维护用户的长期记忆复习进度 offset

同一用户的长期记忆复习可以基于分页轮换:

- 记录当前用户已复习到的分页 `offset`(或页码),作为下次复习起点
- 每次调用`getLongTermReviewList`时从该offset开始取下一批单词

如果到达尾页,重置offset回0实现循环。

这样避免每次都从头取单词导致重复。

### 3. 增强排序策略

为了让单词复习更均衡,建议:

- 对符合时间的单词,优先复习 `lastReviewDate` 最久远的
- 或者使用 `ORDER BY nextReviewDate ASC, lastReviewDate ASC`
- 也可以用随机排序 `ORDER BY RAND()` 如果数据量不大

---

## 结合你代码,示例改进方案

### Mapper SQL伪代码示例(MyBatis或MySQL)

```sql
SELECT ltm.*, w.*
FROM long_term_memory ltm
JOIN word w ON ltm.word_id = w.id
WHERE ltm.user_id = #{userId}
  AND ltm.next_review_date <= #{now}
  AND ltm.longmemory_seen = #{longMemorySeen}
ORDER BY ltm.next_review_date ASC, ltm.last_review_date ASC
LIMIT #{size} OFFSET #{offset}
```

这样每次按照计划复习时间和上次复习时间排序,从分页`offset`开始读取`size`条数据。

---

### Service层调用示例

新增一个字段维护用户复习“分页位置”:

```java
private Map<Integer, Integer> userLongTermReviewOffsetCache = new ConcurrentHashMap<>();
```

并封装一个分页调用方法:

```java
public List<LongTermMemoryWithWord> getPagedLongTermReviewList(Integer userId, int longMemorySeen, int pageSize) {
    int offset = userLongTermReviewOffsetCache.getOrDefault(userId, 0);
    List<LongTermMemoryWithWord> list = getLongTermReviewList(userId, longMemorySeen, offset, pageSize);

    // 如果本页结果不足,说明轮换到末尾,重置offset
    if (list.size() < pageSize) {
        offset = 0;
    } else {
        offset += pageSize;
    }
    userLongTermReviewOffsetCache.put(userId, offset);

    return list;
}
```

这样用户每次复习时都获得不同的分页单词,完成轮换。

---

## 你也可以:

- 使用数据库持久化该分页offset,下次用户登录可继续
- 或用Redis等缓存管理
- 复习完成后清理或更新缓存

---

## 总结

| 方案步骤             | 说明                  | 参考修改点                      |
|-----------------|---------------------|-------------------------------|
| 修改SQL支持分页排序         | 按`nextReviewDate`, `lastReviewDate`排序,`limit`+`offset`分页    | `longTermMemoryMapper.selectDueReviews` SQL            |
| 增加分页offset追踪用户进度    | 记录用户每次复习分页起点,轮换获取单词                   | Service层维护分页变量或持久化                 |
| 循环分页轮换              | 到尾部时重置offset,达到循环轮换效果                   | Service调用逻辑调整                      |
| 可选:引入随机打散           | 避免每次排序单一,提升复习多样性                       | SQL中加入`ORDER BY RAND()`(小数据量适合) |

---

如果你需要,我可以帮你写:

- 详细的Mapper XML或注解SQL示例
- Service层完整分页轮换代码示例
- 或者如何持久化分页offset

告诉我你需要哪种,我帮你写。

turns-00082.parquet:33280

a90bc6234de8a195afa8a769
turn 5/16gpt-4.1-mini-2025-04-14ChineseSingapore340 words
degenerate_repetitionAbsentFinal dense release
USER
长期要跟短期记忆一模一样的轮换,轮换完score=3才能,才安排时间
ASSISTANT
明白了,你期望:

- **短期记忆和长期记忆的单词复习轮换机制完全一致**,即都基于轮次/分页+冷却(间隔)机制控制单词出现频率;  
- **长期记忆阶段内的单词复习必须轮换复习,只有达到某`score=3`(累计3次复习通过)后,才更新下一次复习时间(进入下一阶段或调整interval)**;

换句话说,就是长期记忆的复习节奏要跟短期记忆用类似的“轮次+冷却+分数”机制驱动,只是在`score=3`达标后,才安排下一次复习时间,保持间隔策略。

---

## 如何改造现有结构满足该需求

### 1. 统一轮换机制的核心设计

轮换机制的核心是:  
- **维护一个复习轮次(轮数,Round,或LastSeen)**,本轮复习只出现某些满足冷却条件的单词;  
- **每次复习更新轮次信息和分数字段,达到分数阈值后进入下一阶段;**  
- **长期记忆中“进入下一阶段”时,才更新时间间隔和下一次复习时间;**  
- **未达到分数阈值,单词不进入下一阶段,保持当前状态;**

---

### 2. 修改长期记忆的轮换查询条件

当前长期记忆查询是:

```java
longTermMemoryMapper.selectDueReviews(userId, now, offset, size, longMemorySeen);
```

你需要改为:

- 加个“复习轮次”字段,假设是`longmemorySeen`或者`lastSeen`
- 限制查询出“当前轮次的单词”,且满足冷却(上次复习轮次+冷却 <= 当前轮次)
- 不直接通过时间取单词,而是依赖轮次、冷却条件

比如说:

```sql
SELECT ...
FROM long_term_memory
WHERE user_id = #{userId}
  AND longmemory_seen <= #{currentRound} - #{COOLDOWN}
  AND longmemory_seen = #{currentRound}   -- 当前复习轮次的单词
ORDER BY ... 
LIMIT #{size} OFFSET #{offset}
```

---

### 3. 修改长期记忆复习结果处理逻辑

关键代码放到`processLongTermReviewResult`:

```java
public void processLongTermReviewResult(LongTermMemory ltm, boolean remembered, int currentRound) {
    if (ltm == null) return;

    int score = ltm.getLongmemoryScore() == null ? 0 : ltm.getLongmemoryScore();
    int passCount = ltm.getPassCount() == null ? 0 : ltm.getPassCount();
    int round = ltm.getLongmemorySeen() == null ? 0 : ltm.getLongmemorySeen();

    if (remembered) {
        score++;
        passCount++;

        if (score >= PASS_SCORE) {  // 例如3
            // 进入下一阶段,更新时间间隔和下一次复习时间
            round++;              // 轮次+1
            passCount = 0;
            score = 0;

            // 阶段后移
            int currentInterval = ltm.getIntervalDays() == null ? 0 : ltm.getIntervalDays();
            int nextInterval = calculateNextInterval(currentInterval);
            ltm.setIntervalDays(nextInterval);
            ltm.setNextReviewDate(LocalDateTime.now().plusDays(nextInterval));
            ltm.setLongmemorySeen(round);
        }
    } else {
        // 未通过扣分,但不降低阶段
        score = Math.max(score - 1, 0);
        // 不更新轮次和时间
    }

    ltm.setLongmemoryScore(score);
    ltm.setPassCount(passCount);

    ltm.setLastReviewDate(LocalDateTime.now());
    ltm.setReviewCount((ltm.getReviewCount() == null ? 0 : ltm.getReviewCount()) + 1);

    longTermMemoryMapper.updateLongTermMemory(ltm);
}
```

注意点:

- **只有`score>=PASS_SCORE`才算该单词进入下一阶段,轮次+1并更新复习间隔和复习时间**;
- **轮次字段`longmemorySeen`既控制轮换也控制阶段进度**;
- **查询时使用`longmemorySeen`(当前轮次)筛选对应单词,实现轮换复习**。

---

### 4. 轮换查询示例(长期记忆)

```java
public List<LongTermMemoryWithWord> getLongTermReviewList(Integer userId, int currentRound, int offset, int size) {
    // 查询longmemorySeen = currentRound,且满足冷却条件(如last review轮次+COOLDOWN <= currentRound)
    return longTermMemoryMapper.selectReviewCandidates(userId, currentRound, COOLDOWN, offset, size);
}
```

对应Mapper SQL示例:

```sql
SELECT ltm.*, w.*
FROM long_term_memory ltm
JOIN word w ON ltm.word_id = w.id
WHERE ltm.user_id = #{userId}
  AND ltm.longmemory_seen = #{currentRound}
  AND ltm.last_review_round <= #{currentRound} - #{COOLDOWN} -- 冷却判断
ORDER BY ltm.last_review_date ASC
LIMIT #{size} OFFSET #{offset}
```

---

### 5. 初始化长期记忆时设定

当短期记忆达到通过条件迁移到长期记忆时:

```java
ltm.setLongmemorySeen(1);  // 第一轮长期复习
ltm.setIntervalDays(INTERVALS[0]);
ltm.setNextReviewDate(null); // 此时由轮次+冷却控制,不直接用时间控制
ltm.setLastReviewRound(0);   // 初始化复习轮次
```

---

### 6. 总体流程示意

| 状态/步骤                    | 说明                                                     |
|---------------------------|--------------------------------------------------------|
| 短期记忆阶段复习完成(score=3) | 单词进入长期记忆阶段,轮次置为1,interval设置为1天                          |
| 长期记忆轮次复习             | 轮次为当前`longmemorySeen`时,筛选出满足冷却期的单词进行复习                            |
| 复习成功累积score,达到3      | 轮次+1,interval根据阶段更新,更新下次复习时间;复习计数和分数复位                        |
| 复习不通过                  | score扣减,但同轮次继续,复习时间不提前                              |
| 获取复习列表                | 查询longmemorySeen为当前轮次且超过冷却期限的单词进行分页展示                     |

---

## 用一句话总结

**长期记忆的复习轮换用“轮次+冷却轮次”控制单词在复习列表出现频率,复习通过达到分数阈值后,轮次进阶并更新时间间隔,完美复刻短期记忆的轮换思想+分数阶段控制,同时实现基于时间间隔的复习推进。**

---

### 如果需要,我可以帮你写:

- 统一短长期记忆复习轮次+冷却分页查询的Mapper示例SQL  
- 统一处理复习结果的Service示例  
- 具体字段设计和数据库表字段说明  

告诉我你的需求!

turns-00082.parquet:33281

3f096583b18b94251b3da93d
turn 6/16gpt-4.1-mini-2025-04-14ChineseSingapore491 words
degenerate_repetitionAbsentFinal dense release
USER
长期记忆的复习节奏要跟短期记忆用类似的“轮次+冷却+分数”机制驱动,只是在score=3达标后,才安排下一次复习时间(24小时后,48小时后(24小时score=3才能定),4天,7天,15天)
ASSISTANT
理解你的需求:

> **长期记忆的复习节奏和短期记忆一样,都用“轮次 + 冷却 + 分数”驱动复习轮换;区别是长期记忆只有当score达到3(通过3次复习)时,才安排下一次复习时间(即设置nextReviewDate = 当前时间 + 间隔),间隔分别是24小时/48小时/4天/7天/15天;否则,只做轮次和分数累积,标记未通过,但不提前安排复习时间。**

---

## 如何实现这个“长期记忆轮次+冷却+分数+复习时间”机制(详细方案)

---

### 1. 数据模型字段补充(假设已有)

| 字段名              | 说明                                        |
|-----------------|-------------------------------------------|
| `longmemorySeen` | 当前“复习轮次”,也是长期记忆轮换标志                  |
| `lastReviewRound`| 上一次复习的轮次标志,用于判断是否达到冷却时间          |
| `longmemoryScore`| 当前轮次累计的复习通过分数(≥3才算通过)                 |
| `passCount`      | 连续通过次数(辅助判断)                             |
| `intervalDays`   | 当前阶段的复习间隔天数(1,2,4,7,15)                |
| `nextReviewDate` | 下一次可复习的时间,当前只在score达到3时更新          |

---

### 2. 查询当前轮次待复习单词(轮换 + 冷却机制)

示例SQL(Mapper):

```sql
SELECT ltm.*, w.*
FROM long_term_memory ltm
JOIN word w ON ltm.word_id = w.id
WHERE ltm.user_id = #{userId}
  AND ltm.longmemory_seen = #{currentRound}             -- 当前轮次单词
  AND (ltm.last_review_round IS NULL OR ltm.last_review_round <= #{currentRound} - #{COOLDOWN})  -- 冷却轮次到
  AND (ltm.next_review_date IS NULL OR ltm.next_review_date <= NOW())  -- 已到复习时间(首次为null允许复习)
ORDER BY ltm.last_review_date ASC
LIMIT #{size} OFFSET #{offset}
```

> 这里多加了`next_review_date <= now`限制,确保单词只有到“复习时间”才能复习。对于首次迁移(`nextReviewDate=null`),也允许复习。

---

### 3. 处理复习结果逻辑(评分、冷却和复习时间的更新)

```java
public void processLongTermReviewResult(LongTermMemory ltm, boolean remembered, int currentRound) {
    if (ltm == null) return;

    // 当前复习轮次相关数据
    int score = ltm.getLongmemoryScore() == null ? 0 : ltm.getLongmemoryScore();
    int passCount = ltm.getPassCount() == null ? 0 : ltm.getPassCount();
    int reviewCount = ltm.getReviewCount() == null ? 0 : ltm.getReviewCount();
    int intervalDays = ltm.getIntervalDays() == null ? 1 : ltm.getIntervalDays();  // 默认初始为1天

    if (remembered) {
        score++;
        passCount++;
        reviewCount++;

        if (score >= PASS_SCORE) { // 3次通过,进入阶段转移
            // 轮次+1,分数复位
            int newRound = currentRound + 1;
            ltm.setLongmemorySeen(newRound);
            ltm.setLongmemoryScore(0);
            ltm.setPassCount(0);

            // 阶段后移为下一个复习间隔
            int nextInterval = calculateNextInterval(intervalDays);
            ltm.setIntervalDays(nextInterval);

            // 设置下次复习时间 = 当前时间 + 阶段间隔
            ltm.setNextReviewDate(LocalDateTime.now().plusDays(nextInterval));

            // 更新当前复习轮次为newRound之前(用于冷却判断)
            ltm.setLastReviewRound(currentRound);
        } else {
            // 未达到3分,仅更新分数,复习轮次及复习时间不变,等待累计
            ltm.setLongmemoryScore(score);
            ltm.setPassCount(passCount);
            // lastReviewRound不更新,保持冷却继续生效
        }
    } else {
        // 复习失败,score减1最低0,不转阶段不更新时间
        score = Math.max(score - 1, 0);
        ltm.setLongmemoryScore(score);
        // 其他字段不变
    }

    ltm.setReviewCount(reviewCount);
    ltm.setLastReviewDate(LocalDateTime.now());

    longTermMemoryMapper.updateLongTermMemory(ltm);
}
```

**注意关键点:**

- **只有`score >= PASS_SCORE`(3)时,才更新`longmemorySeen`(轮次)和`nextReviewDate`(下一复习时间,当前时间+间隔)**;  
- 未达到3分时,只更新分数和复习日期,复习轮次不变,也不提前下一次复习时间;  
- `lastReviewRound`保存的是本轮复习轮次,用于下一次查询判断冷却;  
- 查询时`nextReviewDate <= now`保证单词只能在指定时间后出现。

---

### 4. 计算下一个阶段间隔函数示例

```java
private static final int[] INTERVALS = {1, 2, 4, 7, 15};

private int calculateNextInterval(int currentInterval) {
    for (int i = 0; i < INTERVALS.length; i++) {
        if (INTERVALS[i] == currentInterval) {
            if (i < INTERVALS.length - 1) {
                return INTERVALS[i + 1];
            } else {
                return currentInterval; // 已到最大间隔,保持不变
            }
        }
    }
    return INTERVALS[0]; // 默认返回最小间隔
}
```

---

### 5. 长期记忆复习单词查询调用示例

```java
public List<LongTermMemoryWithWord> getLongTermReviewList(Integer userId, int currentRound, int offset, int size) {
    LocalDateTime now = LocalDateTime.now();
    return longTermMemoryMapper.selectReviewCandidates(userId, currentRound, COOLDOWN, now, offset, size);
}
```

对应Mapper SQL伪代码:

```sql
SELECT ltm.*, w.*
FROM long_term_memory ltm
JOIN word w ON w.id = ltm.word_id
WHERE ltm.user_id = #{userId}
  AND ltm.longmemory_seen = #{currentRound}
  AND (ltm.last_review_round IS NULL OR ltm.last_review_round <= #{currentRound} - #{COOLDOWN})
  AND (ltm.next_review_date IS NULL OR ltm.next_review_date <= #{now})
ORDER BY ltm.last_review_date ASC
LIMIT #{size} OFFSET #{offset}
```

---

### 6. 总结表格

| 部分            | 机制描述                                           | 关键字段                          | 行为说明                              |
|---------------|--------------------------------------------------|-------------------------------|----------------------------------|
| 轮换控制          | 基于`longmemorySeen`(轮次)+冷却轮次`lastReviewRound`控制复习出现    | `longmemorySeen`, `lastReviewRound` | 查询筛选满足冷却的当前轮次单词               |
| 分数累计          | 每次复习+1分,未通过减1分,分数≥3进入下一阶段                      | `longmemoryScore`, `PASS_SCORE`   | score≥3时轮次+1,分数和连续通过数都清零         |
| 时间间隔          | 只有达到分数阈值时,更新`nextReviewDate = now + 阶段间隔`           | `nextReviewDate`, `intervalDays`    | 定义下一次复习时间,限制复习只能到指定时间后出现     |
| 冷却判断          | 只有满足`lastReviewRound <= currentRound - COOLDOWN`才允许复习   | `lastReviewRound`, `COOLDOWN`     | 冷却轮次限制,避免频繁复习                      |

---

## 你可以参考上面代码,实现长期记忆和短期记忆完全统一的“轮次 + 冷却 + 分数”复习机制,且长期记忆部分只有复习达到3次时,才定制下一次复习时间。

---

如果需要,我可以帮你写:

- 完整的 Mapper.xml SQL 语句示范;  
- Service 层完整代码示范;  
- 具体表结构字段设计建议。

告诉我你想要哪些,我帮你补充。

turns-00082.parquet:33282

0fc3905bcc4310263426d0b4
turn 7/16gpt-4.1-mini-2025-04-14ChineseSingapore342 words
degenerate_repetitionAbsentFinal dense release
USER
短期记忆的单词首次进入长期记忆时,必须复习一遍,这个复习就和短期记忆的算法一样,按着longmemory_score和longmemory_seen的排序来安排单词顺序,单词不会断的,不会因为背到第几个单词就消失,当单词的longmemory_score=3时,立马设置当前时间last_review_date,next_review_date为24小时后复习。等24小时后,用户就会在在长期记忆里看到单词,然后继续按着短期记忆的算法,单词与单词按着longmemory_score和longmemory_seen的排序来循环出现,直到单词的longmemory_score=3时,立马设置当前时间last_review_date,next_review_date为48小时后复习.同理就是96小时,168小时,360小时
ASSISTANT
你需求的关键点整理如下:

- **短期记忆过渡到长期记忆时,单词必须先经过一轮“长期短期记忆状态”的复习阶段(类似短期记忆的复习机制),并且长期记忆的复习顺序完全根据`longmemory_score`和`longmemory_seen`排序。**
- **长期记忆单词在`longmemory_score=3`时,立即更新`last_review_date = 当前时间` 和 `next_review_date = 当前时间 + 阶段间隔`。**
- **长期记忆阶段间隔依次是24小时(1天)、48小时(2天)、96小时(4天)、168小时(7天)、360小时(15天)。**
- **长期记忆单词根据分数和轮次排序,在分页或轮换中循环出现,不会因为复习顺序消失。**
- **每当记忆分数达到3时,更新复习时间并进入下一个复习间隔阶段,进行下一轮复习。**

---

# 方案设计

结合你的需求,我给出一整套设计思路与示例代码,方便你理解和实现。

---

## 1. 数据模型及字段说明

| 字段名             | 说明                                                  |
|------------------|-----------------------------------------------------|
| `longmemoryScore` | 长期记忆中当前单词的分数(0~3)                              |
| `longmemorySeen`  | 长期记忆轮次/阶段标志,数字越大表示复习间隔阶段越远                    |
| `lastReviewDate`  | 上一次复习时间                                            |
| `nextReviewDate`  | 下一次复习安排的时间                                      |
| `intervalHours`   | 当前长期阶段复习间隔(单位:小时,1,2,4,7,15天等对应小时数)            |

---

## 2. 阶段间隔定义 (单位:小时)

```java
private static final int[] LONG_TERM_INTERVALS_HOURS = {24, 48, 96, 168, 360};  // 1d, 2d, 4d, 7d, 15d
```

---

## 3. 长期记忆复习列表获取

- **查询条件**:  
  - 只提取`nextReviewDate <= 当前时间`公开可复习的单词(即当达到复习时间后)
- **排序规则**:  
  - 按`longmemoryScore`升序(先复习分数小的)
  - `longmemorySeen`升序(先复习间隔小的)
  - `lastReviewDate`升序(先复习最久远的)

示例SQL伪代码:
```sql
SELECT ltm.*, w.*
FROM long_term_memory ltm
JOIN word w ON w.id = ltm.word_id
WHERE ltm.user_id = #{userId}
  AND ltm.next_review_date <= NOW()
ORDER BY ltm.longmemoryScore ASC, ltm.longmemorySeen ASC, ltm.lastReviewDate ASC
LIMIT #{limit} OFFSET #{offset}
```

---

## 4. 长期记忆复习算法及结果处理

```java
public void processLongTermReviewResult(LongTermMemory ltm, boolean remembered) {
    if (ltm == null) return;

    Integer score = ltm.getLongmemoryScore() == null ? 0 : ltm.getLongmemoryScore();
    Integer seen = ltm.getLongmemorySeen() == null ? 0 : ltm.getLongmemorySeen();

    if (remembered) {
        score++;

        if (score >= 3) {
            // 达标阶段分数3,立即进入下一阶段复习,更新时间
            score = 0;
            seen = Math.min(seen + 1, LONG_TERM_INTERVALS_HOURS.length - 1);

            LocalDateTime now = LocalDateTime.now();
            int intervalHour = LONG_TERM_INTERVALS_HOURS[seen];
            ltm.setLastReviewDate(now);
            ltm.setNextReviewDate(now.plusHours(intervalHour));
            ltm.setLongmemorySeen(seen);
        }
        // 未达标3分前,不更新nextReviewDate和lastReviewDate
        ltm.setLongmemoryScore(score);
    } else {
        // 记忆错误回退一分,最低为0
        score = Math.max(score - 1, 0);
        ltm.setLongmemoryScore(score);
        // 不改变时间和阶段
    }

    longTermMemoryMapper.updateLongTermMemory(ltm);
}
```

---

## 5. 首次进入长期记忆时的初始化

当单词短期记忆达到通过标准,迁移到长期记忆:

```java
LongTermMemory ltm = new LongTermMemory();
ltm.setUserId(study.getUserId());
ltm.setWordId(study.getWordId());
ltm.setLongmemoryScore(0);     // 首次复习分数初始0
ltm.setLongmemorySeen(0);      // 第一阶段,interval = 24小时
LocalDateTime now = LocalDateTime.now();
ltm.setLastReviewDate(now);
ltm.setNextReviewDate(now);    // 迁移后即可复习一次,第一次复习和短期记忆算法一致
ltm.setIntervalHours(LONG_TERM_INTERVALS_HOURS[0]);
longTermMemoryMapper.insertLongTermMemory(ltm);
```

---

## 6. 长期记忆复习轮换说明

- **长期记忆单词会一直存在于复习列表**(不会消失)  
- 因为 `nextReviewDate` 控制了什么时候可以出现复习列表(超过时间才出现)  
- 当`longmemoryScore < 3`时,单词会反复出现在复习列表,直到3分通过
- 通过后,更新`nextReviewDate`,下一次复习会在间隔之后出现  
- 复习列表排序规则始终按照`longmemoryScore`和`longmemorySeen`排序,保证低分优先出现
- 这与短期记忆机制复用相同原则,统一算法

---

## 7. 复习订单示意

- 迁移长期记忆后第一次复习(score从0到第1~3分,随时轮换出现);
- score达到3时,更新间隔和下一次复习时间(比如+24小时);
- 24小时后复习单词出现,score清零,从0开始累积复习分,直到再次达到3分;
- 达到3分后更新下一阶段间隔比如48小时,重复上述过程;
- 以此类推直至第5阶段后(15天间隔)

---

# 总结

| 关键点                       | 说明                                                         |
|--------------------------|------------------------------------------------------------|
| 复习列表查询                 | 按`nextReviewDate <= 当前时间`过滤,排序依据`longmemoryScore ASC`, `longmemorySeen ASC`, `lastReviewDate ASC`         |
| 复习结果更新                 | 复习通过`score++`,达到3分时,更新阶段`seen++`和`nextReviewDate=now+间隔`,否则仅score累积                          |
| 首次长期记忆初始化             | `score=0`,`seen=0`,`nextReviewDate=now`,允许首次进入复习列表                                          |
| 长期记忆复习轮换               | 按score和seen排序,低分单词优先,不断轮换出现直到分数达到阶段阈值                                         |

---

如果你需要,我可以帮你:

- 写出完整的 `LongTermMemoryMapper.xml` 复习查询SQL  
- 实现Service层处理逻辑完整代码  
- 设计对应的单元测试示例  

你告诉我即可。

turns-00082.parquet:33283

cfd105f97829debfa4e497e7
turn 8/16gpt-4.1-mini-2025-04-14ChineseSingapore3509 words
degenerate_repetitionAbsentFinal dense release
USER
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.example.springboot.mapper.LongTermMemoryMapper">

    <!-- 插入新记录,返回自增主键 -->
    <insert id="insertLongTermMemory" parameterType="com.example.springboot.entity.LongTermMemory" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO long_term_memory(
            user_id, word_id, review_count, pass_count, interval_days,
            next_review_date, last_review_date,
            longmemory_score, longmemory_seen, created_at, updated_at
        )
        VALUES(
                  #{userId}, #{wordId}, #{reviewCount}, #{passCount}, #{intervalDays},
                  #{nextReviewDate}, #{lastReviewDate},
                  #{longmemoryScore}, #{longmemorySeen}, NOW(), NOW()
              )
    </insert>

    <!-- 查询某用户的已有单词id列表 -->
    <select id="selectExistingWordIds" resultType="int" parameterType="map">
        SELECT word_id
        FROM long_term_memory
        WHERE user_id = #{userId}
        AND word_id IN
        <foreach collection="wordIds" item="wordId" open="(" separator="," close=")">
            #{wordId}
        </foreach>
    </select>

    <!-- 查询当天及以前可复习的长期记忆单词,左连接单词表 -->
    <select id="selectDueReviews" resultType="com.example.springboot.dto.LongTermMemoryWithWord" parameterType="map">
        SELECT ltm.*, w.word, w.definition
        FROM long_term_memory ltm
                 LEFT JOIN words w ON ltm.word_id = w.id
        WHERE ltm.user_id = #{userId}
          AND (ltm.longmemory_seen = #{longmemorySeen} OR ltm.longmemory_seen IS NULL)
          AND ltm.next_review_date &lt;= #{now}
        ORDER BY
            ltm.longmemory_score ASC,
            ltm.next_review_date ASC
        LIMIT #{size} OFFSET #{offset}
    </select>

    <!-- 更新长期记忆记录 -->
    <update id="updateLongTermMemory" parameterType="com.example.springboot.entity.LongTermMemory">
        UPDATE long_term_memory
        SET review_count = #{reviewCount},
            pass_count = #{passCount},
            interval_days = #{intervalDays},
            next_review_date = #{nextReviewDate},
            last_review_date = #{lastReviewDate},
            longmemory_score = #{longmemoryScore},
            longmemory_seen = #{longmemorySeen},
            updated_at = NOW()
        WHERE id = #{id}
    </update>

    <!-- 通过id查询长期记忆记录 -->
    <select id="selectById" resultType="com.example.springboot.entity.LongTermMemory" parameterType="int">
        SELECT *
        FROM long_term_memory
        WHERE id = #{id}
    </select>

</mapper>
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.example.springboot.mapper.StudyMapper">

    <!-- 从 user_word_plan 中抽取待学习且 user_word_memory 中不存在的单词 -->
    <select id="selectPendingWordsFromPlan" parameterType="map" resultType="com.example.springboot.entity.Study">
        SELECT
            NULL AS id,
            up.user_id AS userId,
            up.word_id AS wordId,
            w.word AS word,
            w.definition AS definition,
            0 AS score,
            -1 AS lastSeen
        FROM user_word_plan up
                 JOIN words w ON up.word_id = w.id
                 LEFT JOIN user_word_memory um ON up.user_id = um.user_id AND up.word_id = um.word_id
        WHERE up.user_id = #{userId} AND up.status = 'pending' AND um.id IS NULL
        ORDER BY up.added_at ASC
        LIMIT #{limit}
    </select>


    <!-- 查询 user_word_memory -->
    <select id="selectStudyByUserIdAndWordId" parameterType="map" resultType="com.example.springboot.entity.Study">
        SELECT id, user_id AS userId, word_id AS wordId, score, last_seen AS lastSeen
        FROM user_word_memory
        WHERE user_id = #{userId} AND word_id = #{wordId}
    </select>

    <!-- 插入 user_word_memory -->
    <insert id="insertStudy" parameterType="com.example.springboot.entity.Study" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO user_word_memory(user_id, word_id, score, last_seen)
        VALUES(#{userId}, #{wordId}, #{score}, #{lastSeen})
    </insert>

    <!-- 更新 user_word_memory -->
    <update id="updateStudy" parameterType="com.example.springboot.entity.Study">
        UPDATE user_word_memory
        SET score = #{score},
            last_seen = #{lastSeen}
        WHERE id = #{id}
    </update>

    <!-- 查询单词详情 -->
    <select id="selectWordById" parameterType="int" resultType="com.example.springboot.entity.Word">
        SELECT id, word, definition, pronunciation, custom_content AS customContent, created_at AS createdAt
        FROM words
        WHERE id = #{id}
    </select>

    <!-- 查询用户已选择的单词 -->
    <select id="selectSelectedWordsByUserId" parameterType="int" resultType="com.example.springboot.entity.Word">
        SELECT
            w.id,
            w.word,
            w.definition,
            w.pronunciation,
            w.custom_content AS customContent,
            w.created_at AS createdAt
        FROM user_word_memory uwm
                 JOIN words w ON uwm.word_id = w.id
        WHERE uwm.user_id = #{userId}
    </select>

    <!--  删除单个单词 -->
    <delete id="deleteStudyByUserIdAndWordId" parameterType="map">
        DELETE FROM user_word_memory WHERE user_id = #{userId} AND word_id = #{wordId}
    </delete>

    <!--  批量删除单词 -->
    <delete id="deleteStudiesByUserIdAndWordIds" parameterType="map">
        DELETE FROM user_word_memory WHERE user_id = #{userId} AND word_id IN
        <foreach item="wordId" collection="wordIds" open="(" separator="," close=")">
            #{wordId}
        </foreach>
    </delete>

    <select id="selectReviewCandidates" resultType="com.example.springboot.entity.Study" parameterType="map">
        SELECT
            um.id,
            um.user_id AS userId,
            um.word_id AS wordId,
            um.score,
            um.last_seen AS lastSeen,
            w.word,
            w.definition,
            w.pronunciation
        FROM user_word_memory um
                 JOIN words w ON um.word_id = w.id
        WHERE um.user_id = #{userId} AND (#{round} - um.last_seen) >= #{cooldown}
        ORDER BY um.score ASC, um.last_seen ASC
        LIMIT #{limit}
    </select>

    <!-- 删除user_word_plan中对应记录 -->
    <delete id="deletePlanWord" parameterType="map">
        DELETE FROM user_word_plan WHERE user_id = #{userId} AND word_id = #{wordId}
    </delete>

    <select id="countPendingWords" resultType="int" parameterType="int">
        SELECT COUNT(*) FROM user_word_memory WHERE user_id = #{userId}
    </select>




</mapper>
package com.example.springboot.service;

import com.example.springboot.dto.LongTermMemoryWithWord;
import com.example.springboot.entity.LongTermMemory;
import com.example.springboot.entity.Study;
import com.example.springboot.entity.Word;
import com.example.springboot.mapper.LongTermMemoryMapper;
import com.example.springboot.mapper.StudyMapper;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;

@Service
public class StudyService {

    // --- Mapper 注入 ---
    @Resource
    private StudyMapper studyMapper;

    @Resource
    private LongTermMemoryMapper longTermMemoryMapper;

    // ================= 短期记忆相关 =================

    private static final int COOLDOWN = 2;
    private static final int PASS_SCORE = 3;
    private static final int N = 3;

    /**
     * 获取当前轮次的短期记忆复习候选单词
     */
    public List<Study> getReviewList(Integer userId, int round) {
        return studyMapper.selectReviewCandidates(userId, round, COOLDOWN, N);
    }

    /**
     * 处理短期记忆复习结果:
     * 通过累加分数判断是否通过阶段,
     * 通过后迁移到长期记忆表,否则更新分数和复习轮次
     */
    public void processReviewResult(Integer userId, Integer wordId, int currentRound, boolean remembered) {
        Study study = studyMapper.selectStudyByUserIdAndWordId(userId, wordId);
        if (study == null) return;

        int newScore = study.getScore();
        if (remembered) {
            newScore += 1;
        } else {
            newScore = Math.max(newScore - 1, 0);
        }

        if (newScore >= PASS_SCORE) {
            // 通过短期考核,迁移到长期记忆,删除短期记录
            moveToLongTermMemory(study, currentRound, newScore);
            studyMapper.deleteStudyByUserIdAndWordId(userId, wordId);
        } else {
            // 未通过,更新短期记忆分数和最近复习的轮次
            study.setScore(newScore);
            study.setLastSeen(currentRound);
            studyMapper.updateStudy(study);
        }
    }

    /**
     * 短期记忆→长期记忆迁移
     * intervalDays=0 表示首次长期复习未完成,
     * nextReviewDate设置为当前时间以供首次长期复习
     */
    private void moveToLongTermMemory(Study study, int currentRound, int score) {
        LongTermMemory ltm = new LongTermMemory();

        ltm.setUserId(study.getUserId());
        ltm.setWordId(study.getWordId());
        ltm.setReviewCount(0);       // 首次长期复习前 复习计数0
        ltm.setIntervalDays(0);      // 0表示首次长期复习阶段
        LocalDateTime now = LocalDateTime.now();
        ltm.setLastReviewDate(now);
        ltm.setNextReviewDate(now);  // 立即可复习首次长期任务
        // longmemoryScore, longmemorySeen等字段保持默认即可

        longTermMemoryMapper.insertLongTermMemory(ltm);
    }

    /**
     * 用户手动添加单词到短期记忆
     */
    public int insertUserSelectedWords(Integer userId, List<Integer> wordIds) {
        int count = 0;
        for (Integer wordId : wordIds) {
            Study exist = studyMapper.selectStudyByUserIdAndWordId(userId, wordId);
            if (exist == null) {
                Study study = new Study();
                study.setUserId(userId);
                study.setWordId(wordId);
                study.setScore(0);
                study.setLastSeen(-1);
                studyMapper.insertStudy(study);
                count++;
                studyMapper.deletePlanWord(userId, wordId);
            }
        }
        return count;
    }

    /**
     * 获取用户已选短期记忆单词列表
     */
    public List<Word> getSelectedWordsByUserId(Integer userId) {
        return studyMapper.selectSelectedWordsByUserId(userId);
    }

    /**
     * 获取用户待学习的短期记忆单词(待加入短期记忆)
     */
    public List<Study> getPendingWords(Integer userId, int limit) {
        return studyMapper.selectPendingWordsFromPlan(userId, limit);
    }

    /**
     * 删除单个短期记忆单词记录
     */
    public int deleteWord(Integer userId, Integer wordId) {
        return studyMapper.deleteStudyByUserIdAndWordId(userId, wordId);
    }

    /**
     * 批量删除短期记忆单词记录
     */
    public int deleteWords(Integer userId, List<Integer> wordIds) {
        return studyMapper.deleteStudiesByUserIdAndWordIds(userId, wordIds);
    }

    /**
     * 查询用户短期记忆待复习单词总数(用于展示)
     */
    public int getPendingCount(Integer userId) {
        return studyMapper.countPendingWords(userId);
    }


    // ================= 长期记忆相关 =================

    // 长期记忆复习阶段间隔(单位:天)
    private static final int[] INTERVALS = {1, 2, 4, 7, 15};

    /**
     * 获取长期记忆阶段待复习单词列表(nextReviewDate <= 当前时间)
     */
    public List<LongTermMemoryWithWord> getLongTermReviewList(Integer userId, int longMemorySeen, int offset, int size) {
        LocalDateTime now = LocalDateTime.now();
        return longTermMemoryMapper.selectDueReviews(userId, now, offset, size, longMemorySeen);
    }

    /**
     * 根据长期记忆ID查询对应实体
     */
    public LongTermMemory getLongTermMemoryById(Integer id) {
        return longTermMemoryMapper.selectById(id);
    }

    /**
     * 处理长期记忆复习结果:
     * 需累计通过3次才进入下一阶段间隔;
     * 未通过扣减分数,但不降低阶段;
     * 通过后更新时间间隔及复习时间。
     */
    public void processLongTermReviewResult(LongTermMemory ltm, boolean remembered) {
        if (ltm == null) return;

        // 累计复习次数
        Integer reviewCount = ltm.getReviewCount() == null ? 0 : ltm.getReviewCount() + 1;

        // 当前阶段短期分数
        Integer score = ltm.getLongmemoryScore() == null ? 0 : ltm.getLongmemoryScore();

        // 当前累计连续通过次数
        Integer passCount = ltm.getPassCount() == null ? 0 : ltm.getPassCount();

        // 当前复习轮次,由 longmemorySeen 表示
        int round = ltm.getLongmemorySeen() == null ? 0 : ltm.getLongmemorySeen();

        int[] INTERVALS = {1, 2, 4, 7, 15};
        int intervalIndex = 0;

        // 计算当前阶段索引,根据 intervalDays 字段
        Integer intervalDays = ltm.getIntervalDays() == null ? 0 : ltm.getIntervalDays();
        for (int i = 0; i < INTERVALS.length; i++) {
            if (intervalDays != null && INTERVALS[i] == intervalDays.intValue()) {
                intervalIndex = i;
                break;
            }
        }

        if (remembered) {
            // 复习通过,分数+1,计数+1
            score++;
            passCount++;

            // 连续通过超过阈值,准备进入下一轮次
            if (passCount >= 3) {
                round++;              // 轮次+1
                passCount = 0;        // 复位连续通过计数
                score = 0;            // 复位分数

                // 阶段后移,不能超过最大间隔
                if (intervalIndex < INTERVALS.length - 1) {
                    intervalIndex++;
                }

                int nextInterval = INTERVALS[intervalIndex];
                // 设置下次复习时间(当前时间 + 阶段间隔)
                ltm.setNextReviewDate(LocalDateTime.now().plusDays(nextInterval));
                // 更新间隔天数字段
                ltm.setIntervalDays(nextInterval);
                // 更新轮次
                ltm.setLongmemorySeen(round);
            }
        } else {
            // 未通过则分数减1最低不低于0
            score = Math.max(score - 1, 0);
            // 不更新轮次和间隔
        }

        // 每次复习都更新以下字段
        ltm.setReviewCount(reviewCount);
        ltm.setLongmemoryScore(score);
        ltm.setPassCount(passCount);
        ltm.setLastReviewDate(LocalDateTime.now());

        // 更新数据库
        longTermMemoryMapper.updateLongTermMemory(ltm);
    }

}
package com.example.springboot.controller;

import com.example.springboot.dto.LongTermMemoryWithWord;
import com.example.springboot.entity.LongTermMemory;
import com.example.springboot.entity.Study;
import com.example.springboot.service.StudyService;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;

import javax.annotation.Resource;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/study")
public class StudyController {

    @Resource
    private StudyService studyService;
    //  用户选择单词
    @PostMapping("/select-words")
    public ResponseEntity<?> userSelectWords(@RequestBody Map<String, Object> payload) {
        Integer userId = (Integer) payload.get("userId");
        List<Integer> wordIds = (List<Integer>) payload.get("wordIds");

        if (userId == null || wordIds == null || wordIds.isEmpty()) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("参数错误");
        }

        int insertedCount = studyService.insertUserSelectedWords(userId, wordIds);

        if (insertedCount == 0) {
            return ResponseEntity.status(HttpStatus.CONFLICT).body("所有单词已存在,无需再添加");
        }

        return ResponseEntity.ok("成功添加 " + insertedCount + " 个新单词");
    }

   //  获取已经选择的单词列表
    @GetMapping("/select-words")
    public ResponseEntity<?> getSelectedWords(@RequestParam(value = "userId", required = false) String userIdStr) {
        if (userIdStr == null || !userIdStr.matches("\\d+")) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("缺少或非法的 userId");
        }

        Integer userId = Integer.valueOf(userIdStr);
        return ResponseEntity.ok(studyService.getSelectedWordsByUserId(userId));
    }

    // 获取用户可选的未学习单词(用于前端弹窗选择)
    @GetMapping("/pending-words")
    public ResponseEntity<?> getPendingWords(
            @RequestParam("userId") Integer userId,
            @RequestParam(value = "limit", defaultValue = "100") Integer limit) {
        return ResponseEntity.ok(studyService.getPendingWords(userId, limit));
    }

    // 删除用户选择的单词
    @PostMapping("/remove-word")
    public ResponseEntity<?> removeWord(@RequestBody Map<String, Object> payload) {
        Integer userId = (Integer) payload.get("userId");
        Integer wordId = (Integer) payload.get("wordId");
        if (userId == null || wordId == null) {
            return ResponseEntity.badRequest().body("参数错误");
        }
        int rows = studyService.deleteWord(userId, wordId);
        if (rows > 0) {
            return ResponseEntity.ok("删除成功");
        } else {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body("未找到单词");
        }
    }

    //  批量删除用户选择的单词
    @PostMapping("/remove-words")
    public ResponseEntity<?> removeWords(@RequestBody Map<String, Object> payload) {
        Integer userId = (Integer) payload.get("userId");
        List<Integer> wordIds = (List<Integer>) payload.get("wordIds");
        if (userId == null || wordIds == null || wordIds.isEmpty()) {
            return ResponseEntity.badRequest().body("参数错误");
        }
        int rows = studyService.deleteWords(userId, wordIds);
        if (rows > 0) {
            return ResponseEntity.ok("批量删除成功");
        } else {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body("未找到单词");
        }
    }

    // 获取当前轮复习列表
    @GetMapping("/review-list")
    public ResponseEntity<?> getReviewList(@RequestParam Integer userId, @RequestParam Integer round) {
        List<Study> reviewList = studyService.getReviewList(userId, round);
        return ResponseEntity.ok(reviewList);
    }

    // 处理复习结果
    @PostMapping("/review-result")
    public ResponseEntity<?> postReviewResult(@RequestBody Map<String, Object> payload) {
        Integer userId = (Integer) payload.get("userId");
        Integer wordId = (Integer) payload.get("wordId");
        Integer round = (Integer) payload.get("round");
        Boolean remembered = (Boolean) payload.get("remembered");

        if (userId == null || wordId == null || round == null || remembered == null) {
            return ResponseEntity.badRequest().body("参数错误");
        }

        studyService.processReviewResult(userId, wordId, round, remembered);
        return ResponseEntity.ok("复习结果处理成功");
    }

    // 获取用户待复习单词数量
    @GetMapping("/pending-count")
    public ResponseEntity<?> getPendingCount(@RequestParam Integer userId) {
        int count = studyService.getPendingCount(userId);
        return ResponseEntity.ok(count);
    }

    /**
     * 获取长期记忆阶段待复习单词列表(nextReviewDate <= 当前时间)
     */
    @GetMapping("/long-term-review-list")
    public ResponseEntity<?> getLongTermReviewList(
            @RequestParam Integer userId,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "3") int size,
            @RequestParam(defaultValue = "0") int longmemorySeen
    ) {
        int offset = page * size;
        List<LongTermMemoryWithWord> list = studyService.getLongTermReviewList(userId, longmemorySeen, offset, size);
        return ResponseEntity.ok(list);
    }
    /**
     * 处理长期记忆复习结果反馈
     * 请求体示例:
     * {
     *   "id": 123,
     *   "remembered": true
     * }
     */
    @PostMapping("/long-term-review-result")
    public ResponseEntity<?> postLongTermReviewResult(@RequestBody Map<String, Object> payload) {
        Integer id = (Integer) payload.get("id");
        Boolean remembered = (Boolean) payload.get("remembered");

        if (id == null || remembered == null) {
            return ResponseEntity.badRequest().body("参数错误");
        }

        LongTermMemory ltm = studyService.getLongTermMemoryById(id);
        if (ltm == null) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body("未找到对应的长期记忆记录");
        }

        studyService.processLongTermReviewResult(ltm, remembered);
        return ResponseEntity.ok("长期记忆复习结果处理成功");
    }

}
package com.example.springboot.mapper;

import com.example.springboot.dto.LongTermMemoryWithWord;
import com.example.springboot.entity.LongTermMemory;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import java.time.LocalDateTime;
import java.util.Date;
import java.util.List;

@Mapper
public interface LongTermMemoryMapper {
    int insertLongTermMemory(LongTermMemory ltm);

    List<Integer> selectExistingWordIds(@Param("userId") Integer userId, @Param("wordIds") List<Integer> wordIds);

    // 注意返回类型改成 LongTermMemoryWithWord
    List<LongTermMemoryWithWord> selectDueReviews(@Param("userId") Integer userId,
                                                  @Param("now") LocalDateTime now,
                                                  @Param("offset") Integer offset,
                                                  @Param("size") Integer size,
                                                  @Param("longmemorySeen") Integer longmemorySeen);

    int updateLongTermMemory(LongTermMemory ltm);

    LongTermMemory selectById(@Param("id") Integer id);
}

<template>
  <el-row style="height: 100vh; padding: 20px;" :gutter="20">

    <!-- 左侧:学习单词区域 -->
    <el-col :span="12" class="left-panel" style="height: 100%;">

      <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
        <h2>短期记忆区域</h2>
        <div>
          <el-button type="primary" @click="openSelectedDialog">查看已选单词</el-button>
          <el-button type="success" @click="openDialog" style="margin-left: 10px;">选择单词</el-button>
        </div>
      </div>

      <!-- 待复习单词数量 -->
      <div class="pending-count" v-if="reviewList.length">
        待复习单词数量: {{ pendingCount }}
      </div>

      <!-- 单词卡片 -->
      <div class="card-container" v-if="reviewList.length">

        <div class="card-wrapper">
          <div class="card" :class="{ flipped: isFlipped }">

            <div class="card-face card-front">
              <!-- 正面不显示通过次数,如果需要,类似写这里 -->
              <h1>{{ currentWord.word }}</h1>
              <el-button @click.stop="speakWord(currentWord.word)">🔊 发音</el-button>
            </div>

            <div class="card-face card-back" style="position: relative;">
              <!-- 通过次数左上角绝对定位 -->
              <div class="score-badge">
                通过次数: {{ currentWord.score ?? 0 }}
              </div>
              <h1 style="margin-top: 40px;">{{ currentWord.word }}</h1>
              <p><strong>释义:</strong>{{ currentWord.definition }}</p>
              <el-button @click.stop="speakWord(currentWord.word)">🔊 发音</el-button>
            </div>

          </div>

          <div class="btn-group">
            <el-button type="danger" v-if="!isFlipped" @click="flipCard">忘记</el-button>
            <el-button type="success" v-if="!isFlipped" @click="flipCard">认识</el-button>
            <el-button type="danger" v-if="isFlipped" @click="submitReviewResult(false)">忘记</el-button>
            <el-button type="success" v-if="isFlipped" @click="submitReviewResult(true)">认识</el-button>
          </div>
        </div>
      </div>

      <!-- 无可复习单词提示 -->
      <div v-else style="text-align:center; margin-top: 50px; font-size: 20px; color: #999;">
        无可复习单词,复习完成!
      </div>

      <!-- 已选单词弹窗 -->
      <el-dialog v-model="selectedDialogVisible" title="已选单词列表" width="800px">
        <el-table
            :data="selectedWords"
            style="width: 100%;"
            border
            @selection-change="handleSelectedChange"
        >
          <el-table-column type="selection" width="55" />
          <el-table-column prop="id" label="ID" width="80" />
          <el-table-column prop="word" label="单词" />
          <el-table-column prop="definition" label="释义" />
          <el-table-column label="操作" width="100">
            <template #default="scope">
              <el-button type="danger" size="small" @click="removeWord(scope.row)">删除</el-button>
            </template>
          </el-table-column>
        </el-table>
        <template #footer>
          <span class="dialog-footer">
            <el-button @click="selectedDialogVisible = false">关闭</el-button>
            <el-button type="danger" :disabled="!selectedToDelete.length" @click="batchRemoveWords">
              批量删除({{ selectedToDelete.length }})
            </el-button>
          </span>
        </template>
      </el-dialog>

    </el-col>

    <!-- 右侧区域:长期记忆区域 -->
    <el-col :span="12" class="right-panel" style="border: 1px solid #eee; padding: 20px;">
      <h2>长期记忆区域</h2>
      <div v-if="longTermReviewList.length">

        <div class="card-container">

          <div class="card-wrapper">
            <div class="card" :class="{ flipped: longTermIsFlipped }">

              <div class="card-face card-front">
                <h1>{{ currentLongTermWord.word }}</h1>
                <el-button @click.stop="speakWord(currentLongTermWord.word)">🔊 发音</el-button>
              </div>

              <div class="card-face card-back" style="position: relative;">
                <div class="score-badge">
                  复习次数: {{ currentLongTermWord.reviewCount ?? 0 }} <br/>
                  间隔天数: {{ currentLongTermWord.intervalDays ?? 0 }}
                </div>
                <h1 style="margin-top: 40px;">{{ currentLongTermWord.word }}</h1>
                <p><strong>释义:</strong>{{ currentLongTermWord.definition }}</p>
                <el-button @click.stop="speakWord(currentLongTermWord.word)">🔊 发音</el-button>
              </div>

            </div>

            <div class="btn-group">
              <el-button type="danger" v-if="!longTermIsFlipped" @click="flipLongTermCard">忘记</el-button>
              <el-button type="success" v-if="!longTermIsFlipped" @click="flipLongTermCard">认识</el-button>
              <el-button type="danger" v-if="longTermIsFlipped" @click="submitLongTermReviewResult(false)">忘记</el-button>
              <el-button type="success" v-if="longTermIsFlipped" @click="submitLongTermReviewResult(true)">认识</el-button>
            </div>
          </div>
        </div>

      </div>

      <div v-else style="text-align:center; margin-top: 50px; font-size: 20px; color: #999;">
        无可复习的长期记忆单词,复习完成!
      </div>
    </el-col>

    <!-- 选择待学习单词弹窗 -->
    <el-dialog v-model="dialogVisible" title="选择待学习单词" width="600px">
      <el-table :data="pendingWords" style="width: 100%;" @selection-change="handleSelectionChange">
        <el-table-column type="selection" width="55" />
        <el-table-column prop="word" label="单词" />
        <el-table-column prop="definition" label="释义" />
      </el-table>
      <template #footer>
        <span class="dialog-footer">
          <el-button @click="dialogVisible = false">取消</el-button>
          <el-button type="primary" @click="submitSelectedWords">确认选择({{ selectedToAdd.length }})</el-button>
        </span>
      </template>
    </el-dialog>

  </el-row>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'

axios.defaults.baseURL = 'http://localhost:8080'

const selectedWords = ref([])
const pendingWords = ref([])
const selectedToAdd = ref([])
const selectedToDelete = ref([])
const dialogVisible = ref(false)
const selectedDialogVisible = ref(false)

const round = ref(1)
const reviewList = ref([])
const reviewIndex = ref(0)
const pendingCount = ref(0)

const isFlipped = ref(false)

const currentWord = computed(() => reviewList.value[reviewIndex.value] || {})

const utterance = new SpeechSynthesisUtterance()
utterance.lang = 'en-US'

// 长期记忆相关状态
const longTermReviewList = ref([])
const longTermReviewIndex = ref(0)
const longTermIsFlipped = ref(false)
const currentPage = ref(0)

const currentLongTermWord = computed(() => longTermReviewList.value[longTermReviewIndex.value] || {})

async function fetchPendingCount() {
  const userId = localStorage.getItem('userId')
  if (!userId) return ElMessage.warning('请先登录或设置 userId')
  try {
    const res = await axios.get('/study/pending-count', { params: { userId: Number(userId) } })
    pendingCount.value = res.data
  } catch {
    ElMessage.error('获取待复习单词数量失败')
  }
}

async function fetchSelectedWords() {
  const userId = localStorage.getItem('userId')
  if (!userId) return ElMessage.warning('请先登录或设置 userId')
  try {
    const res = await axios.get('/study/select-words', { params: { userId } })
    selectedWords.value = res.data
  } catch {
    ElMessage.error('获取已选单词失败')
  }
}

async function fetchReviewList() {
  const userId = localStorage.getItem('userId')
  if (!userId) return ElMessage.warning('请先登录或设置 userId')
  try {
    const res = await axios.get('/study/review-list', {
      params: { userId: Number(userId), round: round.value }
    })
    reviewList.value = res.data
    reviewIndex.value = 0
    isFlipped.value = false

    if (reviewList.value.length > 0) {
      speakWord(currentWord.value.word)
    }
  } catch (e) {
    ElMessage.error('加载复习单词失败')
    console.error(e)
  }
}

function flipCard() {
  if (!currentWord.value.word) return
  speakWord(currentWord.value.word)
  isFlipped.value = true
}

async function submitReviewResult(remembered) {
  const userId = localStorage.getItem('userId')
  if (!userId) {
    ElMessage.warning('请先登录或设置 userId')
    return
  }

  try {
    await axios.post('/study/review-result', {
      userId: Number(userId),
      wordId: currentWord.value.wordId || currentWord.value.id,
      round: round.value,
      remembered: remembered
    })

    reviewIndex.value++
    isFlipped.value = false

    await fetchPendingCount() // 刷新待复习数量

    if (reviewIndex.value >= reviewList.value.length) {
      round.value++
      await fetchReviewList()
    } else {
      speakWord(currentWord.value.word)
    }
  } catch (e) {
    ElMessage.error('提交复习结果失败')
    console.error(e)
  }
}

function speakWord(text) {
  if (!text) return
  if (speechSynthesis.speaking) speechSynthesis.cancel()
  utterance.text = text
  speechSynthesis.speak(utterance)
}

async function openSelectedDialog() {
  await fetchSelectedWords()
  selectedToDelete.value = []
  selectedDialogVisible.value = true
}

async function openDialog() {
  const userId = localStorage.getItem('userId')
  if (!userId) return ElMessage.warning('请先登录或设置 userId')
  try {
    const res = await axios.get('/study/pending-words', {
      params: { userId, limit: 100 }
    })
    pendingWords.value = res.data
    dialogVisible.value = true
  } catch {
    ElMessage.error('获取待学习单词失败')
  }
}

function handleSelectionChange(val) {
  selectedToAdd.value = val
}

function handleSelectedChange(val) {
  selectedToDelete.value = val
}

async function submitSelectedWords() {
  const userId = localStorage.getItem('userId')
  const wordIds = selectedToAdd.value.map(item => item.wordId || item.id)
  if (wordIds.length < 10) {
    ElMessage.warning('请至少选择10个单词')
    return
  }
  try {
    await axios.post('/study/select-words', { userId: Number(userId), wordIds })
    dialogVisible.value = false
    await fetchSelectedWords()
    await fetchPendingCount() // 选择后刷新数量
    await fetchReviewList()
    ElMessage.success('添加成功')
  } catch {
    ElMessage.error('添加单词失败')
  }
}

async function removeWord(row) {
  const userId = localStorage.getItem('userId')
  try {
    await axios.post('/study/remove-word', {
      userId: Number(userId),
      wordId: row.wordId || row.id
    })
    await fetchSelectedWords()
    await fetchPendingCount() // 删除后刷新数量
    await fetchReviewList()
    ElMessage.success('删除成功')
  } catch {
    ElMessage.error('删除失败')
  }
}

async function batchRemoveWords() {
  const userId = localStorage.getItem('userId')
  const wordIds = selectedToDelete.value.map(item => item.wordId || item.id)
  if (!wordIds.length) {
    ElMessage.warning('请选择要删除的单词')
    return
  }
  try {
    await axios.post('/study/remove-words', { userId: Number(userId), wordIds })
    await fetchSelectedWords()
    selectedToDelete.value = []
    await fetchPendingCount() // 批量删除后刷新数量
    await fetchReviewList()
    ElMessage.success('批量删除成功')
  } catch {
    ElMessage.error('批量删除失败')
  }
}

// 长期记忆相关方法

async function fetchLongTermReviewList() {
  const userId = localStorage.getItem('userId')
  if (!userId) return ElMessage.warning('请先登录或设置 userId')
  try {
    const res = await axios.get('/study/long-term-review-list', {
      params: {
        userId: Number(userId),
        page: currentPage.value,
        size: 3,
        longmemorySeen: 0  // 或传当前轮次
      }
    })
    longTermReviewList.value = res.data || []
    longTermIsFlipped.value = false

    if (longTermReviewList.value.length > 0) {
      speakWord(currentLongTermWord.value.word)
    }
  } catch (e) {
    ElMessage.error('加载长期记忆复习列表失败')
    console.error(e)
  }
}

function flipLongTermCard() {
  if (!currentLongTermWord.value.word) return
  speakWord(currentLongTermWord.value.word)
  longTermIsFlipped.value = true
}

async function submitLongTermReviewResult(remembered) {
  const userId = localStorage.getItem('userId')
  if (!userId) {
    ElMessage.warning('请先登录或设置 userId')
    return
  }
  const ltmId = currentLongTermWord.value.id
  try {
    await axios.post('/study/long-term-review-result', {
      userId: Number(userId),
      id: ltmId,
      remembered
    })

    longTermReviewIndex.value++
    longTermIsFlipped.value = false

    if (longTermReviewIndex.value >= longTermReviewList.value.length) {
      // 当前页单词复习完毕,拉取下一页
      currentPage.value++
      await fetchLongTermReviewList()  // 重新请求下一页数据

      if (longTermReviewList.value.length === 0) {
        ElMessage.success('长期记忆阶段复习完成!')
      } else {
        longTermReviewIndex.value = 0  // 重置索引
        speakWord(currentLongTermWord.value.word)
      }
    } else {
      // 继续复习当前页剩余单词
      speakWord(currentLongTermWord.value.word)
    }
  } catch (e) {
    ElMessage.error('提交长期记忆复习结果失败')
    console.error(e)
  }
}
onMounted(async () => {
  speechSynthesis.speak(new SpeechSynthesisUtterance(' '))
  await fetchPendingCount()
  await fetchReviewList()
  await fetchLongTermReviewList()
})
</script>

<style scoped>
.left-panel {
  display: flex;
  flex-direction: column;
  border: 1px solid #eee;
  height: 100%;
}

.pending-count {
  font-size: 18px;
  font-weight: bold;
  color: #409EFF;
  padding: 10px 0 0 0;
  margin-bottom: 10px;
  user-select: none;
}

.card-container {
  flex-grow: 1;
  margin-top: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  perspective: 1000px;
  width: 100%;
}

.card {
  width: 100%;
  max-width: 550px;
  height: 500px;
  position: relative;
  transition: transform 0.6s;
  transform-style: preserve-3d;
  border-radius: 16px;
  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
  background-color: #fff;
}

.score-badge {
  position: absolute;
  top: 15px;
  left: 15px;
  background-color: #409EFF;
  color: white;
  font-weight: bold;
  padding: 6px 12px;
  border-radius: 12px;
  font-size: 14px;
  pointer-events: none;
  user-select: none;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  z-index: 10;
}

.card.flipped {
  transform: rotateY(180deg);
}

.card-face {
  position: absolute;
  width: 100%;
  height: 100%;
  padding: 20px;
  box-sizing: border-box;
  backface-visibility: hidden;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}

.card-front {
  font-size: 1em;
  font-weight: bold;
}

.card-back {
  transform: rotateY(180deg);
  font-size: 1.2em;
  text-align: center;
  line-height: 1.6;
}

.card-wrapper {
  display: flex;
  flex-direction: column;
  align-items: center;
}

.btn-group {
  display: flex;
  justify-content: center;
  flex-wrap: wrap;
  gap: 30px;
  margin-top: 30px;
}

.btn-group .el-button {
  padding: 18px 40px;
  font-size: 18px;
  border-radius: 10px;
  font-weight: bold;
  min-width: 140px;
  transition: all 0.2s ease-in-out;
}

.btn-group .el-button:hover {
  transform: scale(1.05);
}
</style>


package com.example.springboot.dto;

import com.example.springboot.entity.LongTermMemory;

public class LongTermMemoryWithWord extends LongTermMemory {
    private String word;
    private String definition;
    // getter/setter


    public String getWord() {
        return word;
    }

    public void setWord(String word) {
        this.word = word;
    }

    public String getDefinition() {
        return definition;
    }

    public void setDefinition(String definition) {
        this.definition = definition;
    }
}

package com.example.springboot.entity;

import java.time.LocalDateTime;

public class LongTermMemory {

    private Integer id;
    private Integer userId;
    private Integer wordId;
    private Integer reviewCount;       // 总复习次数
    private Integer intervalDays;      // 下次复习间隔(单位:天)
    private LocalDateTime nextReviewDate;  // 下一次复习目标日期
    private LocalDateTime lastReviewDate;  // 上一次复习日期
    private Integer longmemoryScore;
    private Integer longmemorySeen;
    private Integer passCount;         // 复习通过计数
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    private Integer round;

    public Integer getRound() {
        return round;
    }

    public void setRound(Integer round) {
        this.round = round;
    }

    // 空构造函数
    public LongTermMemory() {
    }

    // get/set 方法

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public Integer getUserId() {
        return userId;
    }

    public void setUserId(Integer userId) {
        this.userId = userId;
    }

    public Integer getWordId() {
        return wordId;
    }

    public void setWordId(Integer wordId) {
        this.wordId = wordId;
    }

    public Integer getReviewCount() {
        return reviewCount;
    }

    public void setReviewCount(Integer reviewCount) {
        this.reviewCount = reviewCount;
    }

    public Integer getIntervalDays() {
        return intervalDays;
    }

    public void setIntervalDays(Integer intervalDays) {
        this.intervalDays = intervalDays;
    }

    public LocalDateTime getNextReviewDate() {
        return nextReviewDate;
    }

    public void setNextReviewDate(LocalDateTime nextReviewDate) {
        this.nextReviewDate = nextReviewDate;
    }

    public LocalDateTime getLastReviewDate() {
        return lastReviewDate;
    }

    public void setLastReviewDate(LocalDateTime lastReviewDate) {
        this.lastReviewDate = lastReviewDate;
    }

    public Integer getLongmemoryScore() {
        return longmemoryScore;
    }

    public void setLongmemoryScore(Integer longmemoryScore) {
        this.longmemoryScore = longmemoryScore;
    }

    public Integer getLongmemorySeen() {
        return longmemorySeen;
    }

    public void setLongmemorySeen(Integer longmemorySeen) {
        this.longmemorySeen = longmemorySeen;
    }

    public Integer getPassCount() {
        return passCount;
    }

    public void setPassCount(Integer passCount) {
        this.passCount = passCount;
    }

    public LocalDateTime getCreatedAt() {
        return createdAt;
    }

    public void setCreatedAt(LocalDateTime createdAt) {
        this.createdAt = createdAt;
    }

    public LocalDateTime getUpdatedAt() {
        return updatedAt;
    }

    public void setUpdatedAt(LocalDateTime updatedAt) {
        this.updatedAt = updatedAt;
    }

}

ASSISTANT
你的需求和代码基础上,我来总结给出**长期记忆复习策略与代码实现的要点说明和改进**(重点解决:长期记忆“首次复习与短期记忆一样轮换”、`longmemory_score`和`longmemory_seen`排序复习、`score=3`立刻安排下一次复习时间,按间隔依次加长)。

---

## 1. 长期记忆单词首次进入长期记忆后必须立刻复习一次(与短期记忆复习机制一致)

你在 `moveToLongTermMemory` 里:

```java
private void moveToLongTermMemory(Study study, int currentRound, int score) {
    LongTermMemory ltm = new LongTermMemory();

    ltm.setUserId(study.getUserId());
    ltm.setWordId(study.getWordId());
    ltm.setReviewCount(0);
    ltm.setIntervalDays(0);             // 0 表示首次长期记忆复习阶段
    LocalDateTime now = LocalDateTime.now();
    ltm.setLastReviewDate(now);
    ltm.setNextReviewDate(now);         // 迁移后直接加入长期复习列表
    // 初始化longmemoryScore和longmemorySeen都应置0,表示刚开始长期复习阶段
    ltm.setLongmemoryScore(0);
    ltm.setLongmemorySeen(0);
    longTermMemoryMapper.insertLongTermMemory(ltm);
}
```

> 这样首次进入长期记忆的单词,会立刻(`nextReviewDate=now`)出现在长期复习列表,和短期记忆轮换机制一致。

---

## 2. 获取长期记忆复习列表时,按`longmemory_score`和`longmemory_seen`排序,确保低分和低轮次优先复习,且只复习`nextReviewDate <= now`的单词

Mapper中`selectDueReviews`语句:

```xml
<select id="selectDueReviews" resultType="com.example.springboot.dto.LongTermMemoryWithWord" parameterType="map">
    SELECT ltm.*, w.word, w.definition
    FROM long_term_memory ltm
    LEFT JOIN words w ON ltm.word_id = w.id
    WHERE ltm.user_id = #{userId}
      AND (ltm.longmemory_seen = #{longmemorySeen} OR ltm.longmemory_seen IS NULL)
      AND ltm.next_review_date &lt;= #{now}
    ORDER BY
        ltm.longmemory_score ASC,
        ltm.longmemory_seen ASC,
        ltm.last_review_date ASC
    LIMIT #{size} OFFSET #{offset}
</select>
```

考虑加上`ltm.longmemory_seen IS NULL`的兼容性很好,调整排序逻辑可这么写:

```sql
ORDER BY
    ltm.longmemory_score ASC,
    ltm.longmemory_seen ASC,
    ltm.last_review_date ASC
```

确保用户优先复习未掌握(低分)和低阶段的单词,且久未复习的优先复习。

---

## 3. 长期记忆复习结果处理:累计分数≥3时立即设置下一复习时间(24 / 48 / 96 / 168 / 360小时)

`processLongTermReviewResult`方法改成这个结构:

```java
public void processLongTermReviewResult(LongTermMemory ltm, boolean remembered) {
    if (ltm == null) return;

    int reviewCount = (ltm.getReviewCount() == null ? 0 : ltm.getReviewCount()) + 1;
    int score = ltm.getLongmemoryScore() == null ? 0 : ltm.getLongmemoryScore();
    int passCount = ltm.getPassCount() == null ? 0 : ltm.getPassCount();
    int round = ltm.getLongmemorySeen() == null ? 0 : ltm.getLongmemorySeen();

    // 阶段间隔小时数,依次递增
    int[] intervalsHours = {24, 48, 96, 168, 360};

    if (remembered) {
        score++;
        passCount++;

        if (score >= 3) {
            // 达标,进入下一阶段
            round = Math.min(round + 1, intervalsHours.length - 1);

            // 复位计数
            score = 0;
            passCount = 0;

            ltm.setNextReviewDate(LocalDateTime.now().plusHours(intervalsHours[round]));
            ltm.setLastReviewDate(LocalDateTime.now());
            ltm.setIntervalDays(intervalsHours[round] / 24);
            ltm.setLongmemorySeen(round);
        }
    } else {
        score = Math.max(score - 1, 0);
        // 不更新复习时间和阶段
    }

    ltm.setReviewCount(reviewCount);
    ltm.setLongmemoryScore(score);
    ltm.setPassCount(passCount);

    longTermMemoryMapper.updateLongTermMemory(ltm);
}
```

**重点**:

- 只有当`score>=3`时才进入下一阶段、设置下一复习时间和阶段间隔。  
- 复习间隔单位以小时计,初始为24小时,后续递增为48小时、96小时等。  
- 复习时间设置为:当前时间 + 阶段间隔小时数。  
- 分数未达3时,单词继续出现在长期复习列表中但不会更新计划复习时间。

---

## 4. 确保`next_review_date`字段用于筛选长期复习单词是否可以复习

前端和后端调用时复习列表只包含:

```sql
WHERE next_review_date <= NOW()
```

且排序规则保持`longmemory_score`, `longmemory_seen`排序,可以实现你的轮换和优先复习低分、低阶段任务。

---

## 5. 总结表格(长期记忆流程)

| 流程步骤          | 说明                                           |
|-----------------|----------------------------------------------|
| 短期记忆通过后迁移 | 新增长期记忆,初始`score=0, seen=0, nextReviewDate=now`,立即加入长期复习列表      |
| 长期记忆复习       | 查询满足`nextReviewDate <= now`的单词,按`score, seen, lastReviewDate`排序轮换复习 |
| 复习结果处理       | 记忆成功时`score++`,达到`3`后立即更新`nextReviewDate=now+当前阶段间隔`,阶段`seen++` |
| 未达标复习        | 失败则`score--`(>=0),不更新下一次复习时间                                  |
| 阶段间隔          | 阶段间隔依次为24,48,96,168,360小时                                             |

---

## 6. 需要注意的点

- 短期记忆和长期记忆复习列表排序规则和轮换策略保持一致,你写的Mapper已接近正确,稍加调整即可。  
- `processLongTermReviewResult`时,务必在`score >= 3`时立刻刷新`lastReviewDate`和`nextReviewDate`。  
- 前端长期复习列表加载时带分页参数,支持大量单词轮换。  
- 初始化长期记忆时,`nextReviewDate`必须是当前时间,确保首次长期记忆复习不会等待。

---

如果需要,我可以帮你写:

- 完整的长期记忆复习SQL(带排序、分页)  
- LongTermMemoryService中`processLongTermReviewResult`完整示例  
- 前端调用长期记忆分页接口及轮换示例  

告诉我需要哪部分,我帮你。