完善韩泰 EOT 标注编辑器并更新标注规范与项目资料

This commit is contained in:
Xin Wang
2026-10-09 15:55:46 +08:00
parent 138b08a8e4
commit 0204f1288a
29 changed files with 1340 additions and 1271 deletions
+3
View File
@@ -0,0 +1,3 @@
该项目为了构建一个旁路分支服务,用来在asr-llm-tts的pipeline里面添加话轮检测来更好地实现语音交互
该项目目前聚焦韩泰机器人EOT检测准确率问题
+11
View File
@@ -0,0 +1,11 @@
fireredchat turn detection
pipecat turn detector
easyturn
fastturn
soulx-duplg
mai vap model
+182 -16
View File
@@ -1,19 +1,23 @@
# EOT 标注与离线对话转写格式
本版用于 VAD 判断静音过长之后的额外 EOT 判断。每个用户话轮对应一个判断位置,标签回答:**在该位置,让机器人接话是否合适。**
## 当前版本:人机对话中的判断点标注
对话转写和 EOT 标注分别保存在两份 CSV 中。本版按录音时间组织离线转写,用于结合音频和对话文本进行标注与回测。
本项目当前录音是**真人用户与机器人之间的人机对话**,重点处理韩泰机器人过早接话问题。`user` 是真人用户,`agent` 是机器人;标注判断真人用户在指定时刻是否已经可以把发言权交给机器人。
## 文件组织
**本版保留判断点标注:一行代表一个判断位置,标签回答“在该位置,让机器人接话是否合适”。** 判断位置优先取 VAD 请求 EOT 判断的时刻,可在同一通人机对话中保留多个位置。一个用户话轮可以包含多个停顿和判断点;本版不要求一个话轮恰好一个判断点,也不要求覆盖话轮内全部静音区间或先建立完整话轮表。
当前使用 `asr_history.csv` 和 `eot_labels.csv` 两份文件,按整通录音的媒体时间关联音频与文本,进行判断点标注和离线分类回测。**后续再逐步补齐完整用户话轮、静音区间和策略回放,向 LiveKit EOT bench 的评估方法靠近。** 下文的“后续计划”是扩展设计,尚不是当前标注的必填要求。
### 文件组织
| 文件 | 内容 | 一行代表什么 |
| --- | --- | --- |
| `asr_history.csv` | 整通对话的 agent/user 转写。 | 一段有起止时间的实际语音及其转写内容。 |
| `asr_history.csv` | 整通人机对话的机器人/用户转写。 | 一段有起止时间的实际语音及其转写内容。 |
| `eot_labels.csv` | EOT 判断位置、该位置的当前用户文本及人工标签。 | 一个判断位置的 EOT 标注。 |
两份文件通过 `call_id` 关联,可以包含多通对话。EOT 样本用 `call_id + decision_ms` 唯一标识,不再填写话轮 ID。
两份文件通过 `call_id` 关联,可以包含多通人机对话。判断点用 `call_id + decision_ms` 唯一标识;本版无需填写 `turn_id` 或 `span_index`。现有工作台只读写这两份 CSV,不要直接增加列,当前解析器会拒绝未知字段。
## 对话转写 CSV
### 对话转写 CSV
固定表头:
@@ -44,7 +48,7 @@ demo,agent,7900,8500,后天上午可以吗?
demo,user,9000,9250,可以
```
## EOT 标注 CSV
### EOT 标注 CSV
固定表头:
@@ -70,7 +74,7 @@ call_id,decision_ms,user_text_at_decision,can_reply_now,confidence,reason,annota
判断位置优先取 VAD 日志;没有日志时,可用固定版本和配置重放录音生成,并在配套配置中记录其来源。
## 按判断时间获取全部对话历史
### 按判断时间获取全部对话历史
对每一条 EOT 标注:
@@ -83,7 +87,7 @@ call_id,decision_ms,user_text_at_decision,can_reply_now,confidence,reason,annota
转写文件保留整通对话全部内容,取上下文时保留判断位置之前的全部记录。上述规则按语音结束时间筛选离线转写,不记录 ASR 返回延迟。
## 标签和原因
### 标签和原因
标签依据音频、语气和对话上下文判断。短回答如“可以”“明天下午”也可能已经完成回答;句子完整或存在句号,不自动代表可以接话。ASR 错识别或漏识别不直接决定 EOT 标签。
@@ -103,23 +107,185 @@ call_id,decision_ms,user_text_at_decision,can_reply_now,confidence,reason,annota
```csv
call_id,decision_ms,user_text_at_decision,can_reply_now,confidence,reason,annotator
demo,5200,我明天下午,0,high,mid_turn_pause,A
demo,7300,我明天下午不行,后天可以,1,high,completed_turn,A
demo,9400,可以,1,high,completed_answer,A
```
第 5.2 秒的 `user_text_at_decision` 为“我明天下午”,文本上下文包含机器人问题和这段用户表达。示例设定为用户正在同一话轮内停顿,之后继续说“不行,后天可以”,所以标 `0`;后续这段文本不进入第 5.2 秒的输入。
第 7.3 秒的当前用户表达包含同一话轮中两段已说出的内容。本例设定用户在 7.1 秒完成表达,到 7.3 秒可以接话,所以标 `1`。
第 9.4 秒的 `user_text_at_decision` 为“可以”,文本上下文包含上面五条转写记录。示例设定为用户已用“可以”完成回答且语气收束,所以标 `1`。
## 标注步骤和数据核对
### 本版标注步骤和数据核对
1. 核对通话,在时间轴上定位 `decision_ms`。
2. 查看按上述规则取出的全部对话历史,播放截至判断位置的音频,填写 `user_text_at_decision`、标签、原因和标注员 ID;置信度可选。
3. 必要时听后续录音辅助仲裁;后续内容不加入该位置的输入。无法判断时留空标签,不强行填 `0` 或 `1`。
1. 核对通话、录音和机器人/用户声道,在时间轴上定位 `decision_ms`;优先依据 VAD 日志,没有日志时记录固定配置重放或人工选点的来源。
2. 查看截至该判断位置的对话历史和音频,填写当前用户已说出的文本、能否接话、原因和标注员 ID;置信度可选。
3. 必要时听后续录音辅助仲裁,但后续内容不加入该位置的模型输入。机器人插话后用户停止、原意不明时,使用 `interrupted_unclear` 并留空标签;不要强行认定用户已自然结束。
4. 核对 `0 <= start_ms < end_ms`、`decision_ms >= 0` 且时间不超出录音、角色和标签取值、原因与标签一致,以及 `call_id + decision_ms` 无重复。`0` 是有效负例,空标签不进入二分类训练和评分。
多人独立标注时,每个人各保存一份标签文件,使用同一份转写;仲裁后的最终文件每个 `call_id + decision_ms` 只有一行。
多人独立标注时,每个人各保存一份判断点标签文件,使用同一份转写;仲裁后的最终文件每个 `call_id + decision_ms` 只有一行。
CSV 使用 UTF-8 编码和标准 CSV 读写规则。转写内容含英文逗号、双引号或换行时,用双引号包裹,内部双引号写成两个双引号。
核对 `0 <= start_ms < end_ms`、`decision_ms >= 0`,并检查角色和标签取值。`0` 是有效负例,空标签不进入二分类训练和评分。
音频路径、声道映射、转写配置和判断点来源保存在配套数据中。本版无需补齐 `user_turns.csv` 或 `silence_spans.csv`,无需把每个点归入 `hold/eot` 区间,也无需逐一核验完整话轮边界。
原始表格中的“是”(发生过早打断)和“否”(未发生过早打断)可用于挑选候选样本,仍需针对本次判断位置重新核对。音频路径、声道映射和转写配置保存在配套数据中。
原始表格中的“是”(发生过早打断)和“否”(未发生过早打断)可用于挑选候选样本,仍需针对本次判断位置重新核对。韩泰初稿的判断点暂取估计的用户片段终点,标签由原表抢话结论映射;`xlsx_draft` 记录经回听确认前不进入正式评分。“未发生抢话”不能直接证明估计时刻已经可以接话。
### 本版评估范围
在已核验判断点上,将模型是否允许接话与 `can_reply_now` 比较,统计分类准确率、可以接话的检出率,以及误放行和误阻止。误放行表示人工标 `0` 时模型允许接话,误阻止表示人工标 `1` 时模型要求继续等待。人工标签与模型预测分开保存;比较模型时使用相同的判断点集合和输入规则。
这些指标只代表选定判断点上的表现。**判断点误放行不等于 LiveKit 的静音区间误抢话率;判断点检出率也不等于完整话轮末尾 EOT 检出率。** 同一静音区间中的多个点在本版可以分别评价;后续区间评测才按区间汇总。仅有判断点标签,无法计算完整话轮的平均结束等待时间,也无法推算模型拒绝后何时再次接话。
本版仍采用离线转写,不要求词级时间对齐或模拟 ASR 返回延迟;这部分在后续输入回放中完善。
## 后续计划:逐步靠近 LiveKit EOT bench
后续仍使用真人用户与机器人的对话录音,在保留现有判断点及其标注的基础上,补充完整用户话轮和静音区间,逐步从判断点分类扩展到“误抢话与结束等待时间”的策略评估。这里的用户话轮是一次完成表达的发言,可以包含多个停顿;一通人机对话通常包含多个用户话轮。
规划增加两份配套表,现有示例目录中的这两份文件仅用于演示未来扩展:
| 规划文件 | 内容 | 一行代表什么 |
| --- | --- | --- |
| `user_turns.csv` | 完整用户话轮的起点、真实最后发声终点及核验状态。 | 一个完整用户话轮或待核验的话轮候选。 |
| `silence_spans.csv` | 话轮内部停顿及末尾静音的起止时间和标签。 | 一个静音区间。 |
扩展后,四份文件通过 `call_id` 关联。配套表使用 `call_id + turn_id` 标识话轮,使用 `call_id + turn_id + span_index` 标识静音区间。现有工作台尚未实现两份规划表的编辑与校验;当前标注无需创建或维护它们。下面的字段、区间校验、文本延迟及导出规则在开展对应阶段评测时使用。
### 话轮、静音区间与判断点
- **完整用户话轮**:用户从开始发声到完成本轮表达的全过程,可以包含停顿、犹豫和自我修正。转写表的一行不等于一个话轮。
- **`hold` 区间**:同一话轮内部的静音,之后用户继续本轮表达。起点是停顿前最后发声的终点,终点是恢复发声的起点。
- **`eot` 区间**:完整用户话轮结束后的静音。起点是真实最后发声的终点,终点是可供回放评测的末尾静音结束位置,例如机器人开始发声或录音结束位置。
- **判断点**:静音区间中的一个模型输入截止位置。区间标签是真值,判断点继承所在区间的真值;模型分数及实际接话动作另行记录。
先通过音频和上下文核验话轮边界,再确定区间标签。每个已确认完整话轮有且只有一个末尾 `eot`,之前的静音区间为 `hold`。不能把机器人插话的位置、转写分段终点或任意裁剪片段的末尾自动认定为真实 EOT。
机器人抢话后用户停止、原本是否准备结束无法判断时,话轮状态填 `interrupted_unclear`。可以保留已经核验的判断点标签作局部分析,但该话轮不进入要求完整末尾 EOT 的策略评估。不能为了满足“最后一个区间为 eot”而补造正例。
静音以用户声道的发声活动为依据;机器人、背景声和声道串音应核验。默认收集至少 100 ms 的候选静音区间,以便接近 LiveKit 的候选集;使用的 VAD 版本、阈值和人工修订情况须记录。所有模型和基线使用同一个核验后的区间集合。
### 完整用户话轮 CSV
固定表头:
```csv
call_id,turn_id,start_ms,speech_end_ms,status,annotator
```
| 字段 | 填写规则 |
| --- | --- |
| `call_id` | 通话 ID。 |
| `turn_id` | 通话内稳定的话轮 ID,例如 `u001`;调整时间后仍保留 ID。 |
| `start_ms` | 该用户话轮首次发声的起点,整通录音毫秒。 |
| `speech_end_ms` | 真实最后发声的终点;尚未确认或被插话截断时留空。 |
| `status` | `complete`、`uncertain`、`audio_issue` 或 `interrupted_unclear`。只有人工核验后的完整话轮填写 `complete`。 |
| `annotator` | 核验或仲裁该话轮的标注员 ID。 |
`speech_end_ms` 不是 VAD 触发时刻,也不是机器人开始播放的时刻。完整话轮满足 `0 <= start_ms < speech_end_ms`。候选状态不自动代表其全部局部判断点无法标注,但不用于完整话轮策略评分。
### 静音区间 CSV
固定表头:
```csv
call_id,turn_id,span_index,start_ms,end_ms,label,confidence,reason,annotator
```
| 字段 | 填写规则 |
| --- | --- |
| `call_id`、`turn_id` | 关联话轮表。 |
| `span_index` | 话轮内按时间排序,从 `0` 开始连续编号。 |
| `start_ms`、`end_ms` | 静音起止时间,整通录音毫秒,满足 `start_ms < end_ms`。 |
| `label` | `hold`、`eot` 或空。空表示待核验,不当作负例。 |
| `confidence` | `high`、`medium`、`low` 或空。 |
| `reason` | 沿用当前版本的标签原因表;`hold` 对应负例原因,`eot` 对应正例原因,空对应无法判断的原因。 |
| `annotator` | 标注或最终仲裁该区间的人员 ID。 |
区间使用半开范围 `[start_ms, end_ms)`。对每个判断点,在相同通话中查找满足 `start_ms <= decision_ms < end_ms` 的区间,唯一确定其话轮和区间归属。一个区间可以关联多个判断点;同一通话的已确认区间不得重叠。不能唯一关联的点保留为待核验或局部分析样本,不直接导出为 bench 样本。
对 `complete` 话轮,所有区间必须已核验且位于该话轮起点之后;`hold` 区间结束不晚于 `speech_end_ms`,唯一末尾 `eot` 的 `start_ms` 必须等于 `speech_end_ms`。不要把说话期间的拒绝接话点当成 `hold` 静音区间。
### 后续判断点与区间关联
属于已核验区间的判断点,`hold` 对应 `can_reply_now=0`,`eot` 对应 `1`;空区间标签对应空判断点标签。若判断点与区间标签冲突,应重新核验边界和真值,不通过覆盖标签消除冲突。
判断点不需要逐一重复人工标注。核验区间后,评测可以按固定间隔生成多个判断点并继承区间标签,但每个点的音频和当前文本必须重新按该时刻截断。当前 VAD 日志中的点继续用于原策略回测,固定网格用于不同策略的统一比较。
### 后续文本可用性与 ASR 延迟
开展策略回放时,记录所用文本可用性模式:
- **离线转写模式**:使用上述规则及人工核对的当前文本,用于检查音频、语义和标注质量,不能据此宣称实时 ASR 效果。
- **固定延迟模式**:有词级对齐时,仅保留 `word_end_ms <= decision_ms - transcript_lag_ms` 的词,模拟 ASR 返回延迟。LiveKit 批量输入构造默认延迟为 500 ms。例如一个词在 1800 ms 说完,设置 500 ms 延迟后,到 2300 ms 才可作为模型文本输入。
- **日志回放模式**:按 ASR 实际返回时刻重放当时已收到的文本及修订结果,优先用于评估项目实时链路。
模拟延迟只影响模型输入,不改变话轮或区间真值。词级时间和 ASR 日志保存在配套数据中;仅有片段级完整转写时,不能按字符比例推算词级时间,也不能把离线 `user_text_at_decision` 原样用于所有生成时刻。若仅延后整段文本,报告中必须说明这是片段级近似。历史和当前文本统一遵循所选可用性规则,避免从历史绕过延迟限制。
### 后续扩展的虚构示例
对应的虚构配套示例为 [示例/user_turns.csv](示例/user_turns.csv) 和 [示例/silence_spans.csv](示例/silence_spans.csv)。本例假定录音延续到 9800 ms:
```csv
call_id,turn_id,start_ms,speech_end_ms,status,annotator
demo,u001,3000,7100,complete,A
demo,u002,9000,9250,complete,A
```
```csv
call_id,turn_id,span_index,start_ms,end_ms,label,confidence,reason,annotator
demo,u001,0,4400,5600,hold,high,mid_turn_pause,A
demo,u001,1,7100,7900,eot,high,completed_turn,A
demo,u002,0,9250,9800,eot,high,completed_answer,A
```
`5200` 属于 `u001` 的 `hold`;`7300` 属于同一话轮的 `eot`;`9400` 属于 `u002` 的 `eot`。最后一个点的静音等待为 150 ms,适用于该点的局部判断;若统一在静音 200 ms 评分,第二个话轮应另外生成 `9450 ms` 的输入和预测,不能直接复用 `9400 ms` 的分数。
### 策略评测与 LiveKit 导出
后续完整话轮策略评测的目标对比为:**在相同话轮和区间集合上,比较原 VAD 策略与 VAD + EOT 策略,观察减少的误抢话及增加的结束等待时间。** 记录 EOT 拒绝后是否重新推理、重试间隔、概率阈值、最小静音等待和超时时间。只保存一次拒绝结果,不能推算后续多久才会接话。
预测结果与人工真值分开保存。至少记录话轮、区间、`decision_ms`、已静音时长、模型分数,以及所用模型和输入配置。`can_reply_now` 是人工标签,不是模型输出。已静音时长为 `decision_ms - silence_start_ms`;两个时间都使用整通录音时钟。
评测分为两个层次:
| 层次 | 数据和指标 |
| --- | --- |
| 判断点分类 | 在已核验的选定判断点上评价误放行、误阻止、检出率等;只代表这些判断点上的表现。 |
| 静音区间策略评估 | 在完整话轮的统一候选集上,评价 `hold` 误抢话率、末尾 `eot` 检出率及平均结束等待时间,并比较不同阈值、最小等待和超时策略。 |
静音区间策略评分的具体口径:
1. **`hold` 误抢话率**:如果策略决定接话时用户还未恢复说话,计一次误抢话。分母是纳入评估的 `hold` 区间数;同一区间内多个判断点不重复计数。即使模型一直拒绝,超时早于用户恢复发声也会造成误抢话。
2. **末尾 `eot` 检出率**:模型超过阈值且经最小等待约束后能在超时时间以内触发,计检出成功;否则计漏检并由超时结束。分母是纳入评估的末尾 `eot` 区间数。
3. **平均结束等待时间**:从真实 `speech_end_ms` 到策略决定接话的时间差。成功检出使用策略动作时间,漏检使用超时时间;所有末尾 `eot` 都进入平均,不能只统计成功检出的样本。
4. `hold` 不进入末尾 EOT 检出率或平均等待时间;`eot` 不进入 `hold` 误抢话率。按区间分别评估时,即使之前的 `hold` 上发生抢话,末尾 `eot` 仍参与评分。这种口径不同于在首次抢话后停止的通话级回放。
采用 LiveKit 相近口径时,候选静音最短为 100 ms,指标默认筛选 200 ms 至 5 s 的 `hold` 区间。统一比较“平均等待不超过 300/600 ms 时的最低误抢话率”和“误抢话率不超过 5%/10% 时的最低平均等待时间”。分类指标作为辅助诊断,同时记录所用版本和配置,避免不同候选集的结果混比。
这里的等待时间衡量结束策略,未包含整个 ASR–LLM–TTS 链路耗时。若要衡量用户真正听到回复的延迟,应另记机器人播放起点,计算其与 `speech_end_ms` 的时间差。
导出到 LiveKit bench 时:
1. 每个 `complete` 话轮导出一行,`id` 使用可逆编码的 `call_id + turn_id`,确保整个数据集唯一;语言在配套配置中记录,例如中文为 `zh`。
2. 从已核验的用户声道裁剪该完整话轮及末尾静音;参照公开数据转换为 16 kHz 单声道 WAV。避免把整通双声道音频直接混成单声道而混入机器人语音。
3. 本地时间保留整通毫秒,导出时按实际裁剪起点换算为片段秒:`local_seconds = (recording_ms - crop_start_ms) / 1000`。静音区间和词级时间一起换算。
4. 生成按时间排序的 `silence_spans`,每项包含 `start`、`end`。LiveKit 会把列表最后一个区间视为 `eot`、此前区间视为 `hold`,所以导出前必须校验本地标签和完整话轮边界。
5. `messages` 只包含当前话轮之前的对话;当前话轮文本通过带时间的 `words` 按判断时刻构造。它们是可选字段,是否提供取决于模型需求;未来内容不进入输入。
6. 末尾静音需覆盖所选评分点,例如 200 ms。不足时剔除样本,或在所有模型共用的导出中统一补尾静音并明确记录补充长度和规则;不能越过机器人已发声的片段当作用户纯静音输入。补尾不改变真实 `speech_end_ms`,不能为 `hold` 人工延长静音。流式评分若需要更晚的分数,也须保证对应时间有合法的音频输入,不能把未观测时间冒充模型预测。
配套配置至少保存:录音路径、声道映射、语言、VAD 版本和参数、判断点来源、输入裁剪规则、文本可用性模式、区间筛选范围、策略参数、数据划分及 LiveKit 代码/数据版本。训练、调参和测试按通话划分,避免同一通话的不同点泄漏到不同集合。
参考:[LiveKit 评估说明](https://github.com/livekit/eot-bench#evaluation-model)、[区间和因果输入构造](https://github.com/livekit/eot-bench/blob/main/eot_harness/io.py)、[指标实现](https://github.com/livekit/eot-bench/blob/main/eot_harness/metrics.py)、[公开数据格式](https://huggingface.co/datasets/livekit/eot-bench-data)。本节为后续接入规划,当前判断点标注不要求满足这里的完整话轮评测条件。项目尚未实现上述导出器和策略评分程序。
### 后续扩展的标注与核对
1. 回听并核验完整用户话轮边界,填充话轮表;被机器人插话截断且原意不明的候选不能标为 `complete`。
2. 生成并核验话轮内的静音区间,为已确认完整话轮建立内部 `hold` 和唯一末尾 `eot`。
3. 将已有判断点关联到区间,无法唯一关联的点保留为局部分析样本;开展统一策略评估时,再按固定网格生成输入和预测。
4. 记录 EOT 拒绝后的重试和超时规则,使用相同区间集合比较原 VAD 与 VAD + EOT,并按所选模式完善文本回放。
完整话轮评测还需核对:话轮及区间唯一键、区间外键、时间顺序、静音确实存在、末尾 EOT 唯一且起点等于 `speech_end_ms`、判断点唯一归属,以及所有模型使用相同区间集。含未决区间或被插话截断的话轮不进入完整话轮策略评分;记录排除数量和原因。点位评分可以使用其中已经核验的点,但须单独报告。
原有韩泰初稿不自动生成 `complete` 话轮;需先核验真实边界和内部停顿。缺少规划表不影响当前判断点标注,也不意味着当前数据格式错误。
+1
View File
@@ -1,3 +1,4 @@
call_id,decision_ms,user_text_at_decision,can_reply_now,confidence,reason,annotator
demo,5200,我明天下午,0,high,mid_turn_pause,A
demo,7300,我明天下午不行,后天可以,1,high,completed_turn,A
demo,9400,可以,1,high,completed_answer,A
1 call_id decision_ms user_text_at_decision can_reply_now confidence reason annotator
2 demo 5200 我明天下午 0 high mid_turn_pause A
3 demo 7300 我明天下午不行,后天可以 1 high completed_turn A
4 demo 9400 可以 1 high completed_answer A
@@ -0,0 +1,4 @@
call_id,turn_id,span_index,start_ms,end_ms,label,confidence,reason,annotator
demo,u001,0,4400,5600,hold,high,mid_turn_pause,A
demo,u001,1,7100,7900,eot,high,completed_turn,A
demo,u002,0,9250,9800,eot,high,completed_answer,A
1 call_id turn_id span_index start_ms end_ms label confidence reason annotator
2 demo u001 0 4400 5600 hold high mid_turn_pause A
3 demo u001 1 7100 7900 eot high completed_turn A
4 demo u002 0 9250 9800 eot high completed_answer A
+3
View File
@@ -0,0 +1,3 @@
call_id,turn_id,start_ms,speech_end_ms,status,annotator
demo,u001,3000,7100,complete,A
demo,u002,9000,9250,complete,A
1 call_id turn_id start_ms speech_end_ms status annotator
2 demo u001 3000 7100 complete A
3 demo u002 9000 9250 complete A
@@ -0,0 +1,77 @@
call_id,role,start_ms,end_ms,transcript
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,agent,12680,14260,请问您现在在哪个城市呢?
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,user,16480,19520,贵州省遵义市仁怀市。
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,agent,21780,24520,请问您是要业务咨询还是投诉建议?
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,user,26500,27420,投诉建议。
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,agent,29860,36080,抱歉给您带来不好的体验了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,user,41140,48940,型号是24年380大众帕萨特星空龙腾版。
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,agent,49200,51780,购车时间和行驶里程方便说一下吗?
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,user,53740,55420,24年8月份。
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,agent,57640,59120,行驶里程大概是多少呢?
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,user,60700,61460,行驶3万公里。
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,agent,64000,67480,辛苦您仔细讲一讲想要反馈投诉的具体事情哈。
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,user,70360,77740,恒泰轮胎前门两条轮胎开裂,东西比较大。
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,agent,77580,83440,好,您反映的问题我全都记下了,我这边尽快安排专员给您回电沟通,您看可以吗?
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,user,85280,85680,对。
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,user,85680,88280,尽快,已经影响行车安全了。
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,agent,13920,15500,请问您现在在哪个城市呢?
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,user,19220,19660,宣传。
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,agent,22320,25060,请问您是要业务咨询还是投诉建议?
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,user,29040,29580,投诉建议。
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,agent,32240,38480,抱歉给您带来不好的体验了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,user,41760,50540,爱车型号是大众帕萨特,购车时间2023年10月份,行驶里程24000多公里。
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,agent,53020,56480,辛苦您仔细讲一讲想要反馈投诉的具体事情哈。
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,user,62500,63460,轮胎裂纹。
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,agent,62760,63640,方便说话吗?
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,user,65520,71080,轮胎裂纹同意更换新轮胎。
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,agent,69160,71580,好,您反映的问题我全都记下了。
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,user,71880,73620,但是20。
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,agent,73620,79480,好,您反映的问题我全都记下了,我这边尽快安排专员给您回电沟通,您看可以吗?
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,user,80560,80880,可以。
872a1e67-873a-4c49-a598-b38a670afcad,agent,12040,14760,刚才没太听懂您的意思,能不能再跟我说?
872a1e67-873a-4c49-a598-b38a670afcad,user,14600,15020,我姓宋。
872a1e67-873a-4c49-a598-b38a670afcad,agent,17520,19080,请问您现在在哪个城市呢?
872a1e67-873a-4c49-a598-b38a670afcad,user,20220,22860,郑州市,河南郑州市。
872a1e67-873a-4c49-a598-b38a670afcad,agent,25360,28100,请问您是要业务咨询还是投诉建议?
872a1e67-873a-4c49-a598-b38a670afcad,user,30280,31160,我的轮胎开裂了。
872a1e67-873a-4c49-a598-b38a670afcad,agent,33740,40000,抱歉,您的问题我已经了解了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。
872a1e67-873a-4c49-a598-b38a670afcad,user,42120,60680,帕萨特L2025款2025年3月购车流水流程29000公里。
872a1e67-873a-4c49-a598-b38a670afcad,agent,60180,66040,行驶里程大概是多少呢?另外,辛苦。行驶里程大概是多少呢?另外辛苦您仔细讲。
872a1e67-873a-4c49-a598-b38a670afcad,user,66340,70260,29000公里。29000公里。
872a1e67-873a-4c49-a598-b38a670afcad,agent,70260,73140,好的,您反映的问题我全都记下了,我这边尽快安排给您回电沟通,您看可以吗?
872a1e67-873a-4c49-a598-b38a670afcad,user,73440,73816,可以。
a266b9ef-5e47-4434-a04c-ce4eacd52c19,agent,12160,13720,请问您现在在哪个城市呢?
a266b9ef-5e47-4434-a04c-ce4eacd52c19,user,15260,15760,天津市。
a266b9ef-5e47-4434-a04c-ce4eacd52c19,agent,18360,21100,请问您是要业务咨询还是投诉建议?
a266b9ef-5e47-4434-a04c-ce4eacd52c19,user,22560,22900,投诉。
a266b9ef-5e47-4434-a04c-ce4eacd52c19,agent,25120,31360,抱歉给您带来不好的体验了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。
a266b9ef-5e47-4434-a04c-ce4eacd52c19,user,34480,51080,迈腾2023款330豪华版2.0T。出现的问题是轮购车时间是2024年5月份,里程是6851公里。
a266b9ef-5e47-4434-a04c-ce4eacd52c19,agent,53640,57100,辛苦您仔细想一想想要反馈投诉的具体事情哈。
a266b9ef-5e47-4434-a04c-ce4eacd52c19,user,60820,69860,轮胎质量问题规律,排水槽横向竖向规律。轮胎质量有问题。
a266b9ef-5e47-4434-a04c-ce4eacd52c19,agent,69360,75220,好,您反映的问题我全都记下了,我这边尽快安排专员给您回电沟通,您看可以吗?
a266b9ef-5e47-4434-a04c-ce4eacd52c19,user,75520,83016,可以。
cb3238dc-3946-42a5-aad8-9b9a54226363,agent,13540,15100,请问您现在在哪个城市呢?
cb3238dc-3946-42a5-aad8-9b9a54226363,user,16600,17380,辽宁葫芦岛。
cb3238dc-3946-42a5-aad8-9b9a54226363,agent,19620,22380,请问您是要业务咨询还是投诉建议?
cb3238dc-3946-42a5-aad8-9b9a54226363,user,24420,25060,投诉建议。
cb3238dc-3946-42a5-aad8-9b9a54226363,agent,27520,33760,抱歉给您带来不好的体验了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。
cb3238dc-3946-42a5-aad8-9b9a54226363,user,34980,51620,我是2023年、2024年1月份购置的迈腾3302023款轿车,目前已行驶28000公里。
cb3238dc-3946-42a5-aad8-9b9a54226363,agent,50220,53680,辛苦您仔细想一想,想要反馈投诉的具体事情哈。
cb3238dc-3946-42a5-aad8-9b9a54226363,user,56280,62820,我的轮胎平时28000公里。
cb3238dc-3946-42a5-aad8-9b9a54226363,agent,62340,65840,辛苦您仔细讲一讲想要反馈投诉的具体事情哈。
cb3238dc-3946-42a5-aad8-9b9a54226363,user,68660,75200,轮胎开裂,行驶28000公里,轮胎开裂、纵纹开裂、横纹开裂。
cb3238dc-3946-42a5-aad8-9b9a54226363,agent,75600,81460,好,您反映的问题我全都记下了,我这边尽快安排,您看可以吗?
cb3238dc-3946-42a5-aad8-9b9a54226363,user,83440,83791,对的。
cb3238dc-3946-42a5-aad8-9b9a54226363,user,83791,85080,请尽快帮我处理这件事。
cb3238dc-3946-42a5-aad8-9b9a54226363,agent,88040,93120,好的,感谢您的配合,祝您生活愉快,再见。
cb3238dc-3946-42a5-aad8-9b9a54226363,user,93420,93756,好的。
e304f740-85d4-427e-9fd6-c5474e9cd737,agent,12040,13600,请问您现在在哪个城市呢?
e304f740-85d4-427e-9fd6-c5474e9cd737,user,15060,16720,苏州昆山。
e304f740-85d4-427e-9fd6-c5474e9cd737,agent,19240,21980,请问您是要业务咨询还是投诉建议?
e304f740-85d4-427e-9fd6-c5474e9cd737,user,27060,27500,投诉。
e304f740-85d4-427e-9fd6-c5474e9cd737,agent,29980,36220,抱歉给您带来不好的体验了,麻烦告知一下您的爱车型号、购车时间,还有行驶里程。
e304f740-85d4-427e-9fd6-c5474e9cd737,user,38480,42760,帕萨特24年11月份。
e304f740-85d4-427e-9fd6-c5474e9cd737,agent,41520,44100,购车时间和行驶里程方便说一下吗?
e304f740-85d4-427e-9fd6-c5474e9cd737,user,45420,52800,24年11月份购买的帕萨特,15000公里左右。
e304f740-85d4-427e-9fd6-c5474e9cd737,agent,55240,58720,辛苦您仔细讲一讲想要反馈投诉的具体事情哈。
e304f740-85d4-427e-9fd6-c5474e9cd737,user,60880,68260,四条轮胎,最近我在。查看的时候发现全部开裂。
1 call_id role start_ms end_ms transcript
2 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 agent 12680 14260 请问您现在在哪个城市呢?
3 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 user 16480 19520 贵州省遵义市仁怀市。
4 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 agent 21780 24520 请问您是要业务咨询还是投诉建议?
5 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 user 26500 27420 投诉建议。
6 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 agent 29860 36080 抱歉给您带来不好的体验了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。
7 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 user 41140 48940 型号是24年380大众帕萨特星空龙腾版。
8 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 agent 49200 51780 购车时间和行驶里程方便说一下吗?
9 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 user 53740 55420 24年8月份。
10 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 agent 57640 59120 行驶里程大概是多少呢?
11 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 user 60700 61460 行驶3万公里。
12 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 agent 64000 67480 辛苦您仔细讲一讲想要反馈投诉的具体事情哈。
13 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 user 70360 77740 恒泰轮胎前门两条轮胎开裂,东西比较大。
14 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 agent 77580 83440 好,您反映的问题我全都记下了,我这边尽快安排专员给您回电沟通,您看可以吗?
15 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 user 85280 85680 对。
16 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 user 85680 88280 尽快,已经影响行车安全了。
17 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 agent 13920 15500 请问您现在在哪个城市呢?
18 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 user 19220 19660 宣传。
19 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 agent 22320 25060 请问您是要业务咨询还是投诉建议?
20 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 user 29040 29580 投诉建议。
21 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 agent 32240 38480 抱歉给您带来不好的体验了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。
22 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 user 41760 50540 爱车型号是大众帕萨特,购车时间2023年10月份,行驶里程24000多公里。
23 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 agent 53020 56480 辛苦您仔细讲一讲想要反馈投诉的具体事情哈。
24 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 user 62500 63460 轮胎裂纹。
25 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 agent 62760 63640 方便说话吗?
26 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 user 65520 71080 轮胎裂纹同意更换新轮胎。
27 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 agent 69160 71580 好,您反映的问题我全都记下了。
28 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 user 71880 73620 但是20。
29 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 agent 73620 79480 好,您反映的问题我全都记下了,我这边尽快安排专员给您回电沟通,您看可以吗?
30 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 user 80560 80880 可以。
31 872a1e67-873a-4c49-a598-b38a670afcad agent 12040 14760 刚才没太听懂您的意思,能不能再跟我说?
32 872a1e67-873a-4c49-a598-b38a670afcad user 14600 15020 我姓宋。
33 872a1e67-873a-4c49-a598-b38a670afcad agent 17520 19080 请问您现在在哪个城市呢?
34 872a1e67-873a-4c49-a598-b38a670afcad user 20220 22860 郑州市,河南郑州市。
35 872a1e67-873a-4c49-a598-b38a670afcad agent 25360 28100 请问您是要业务咨询还是投诉建议?
36 872a1e67-873a-4c49-a598-b38a670afcad user 30280 31160 我的轮胎开裂了。
37 872a1e67-873a-4c49-a598-b38a670afcad agent 33740 40000 抱歉,您的问题我已经了解了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。
38 872a1e67-873a-4c49-a598-b38a670afcad user 42120 60680 帕萨特L2025款2025年3月购车流水流程29000公里。
39 872a1e67-873a-4c49-a598-b38a670afcad agent 60180 66040 行驶里程大概是多少呢?另外,辛苦。行驶里程大概是多少呢?另外辛苦您仔细讲。
40 872a1e67-873a-4c49-a598-b38a670afcad user 66340 70260 29000公里。29000公里。
41 872a1e67-873a-4c49-a598-b38a670afcad agent 70260 73140 好的,您反映的问题我全都记下了,我这边尽快安排给您回电沟通,您看可以吗?
42 872a1e67-873a-4c49-a598-b38a670afcad user 73440 73816 可以。
43 a266b9ef-5e47-4434-a04c-ce4eacd52c19 agent 12160 13720 请问您现在在哪个城市呢?
44 a266b9ef-5e47-4434-a04c-ce4eacd52c19 user 15260 15760 天津市。
45 a266b9ef-5e47-4434-a04c-ce4eacd52c19 agent 18360 21100 请问您是要业务咨询还是投诉建议?
46 a266b9ef-5e47-4434-a04c-ce4eacd52c19 user 22560 22900 投诉。
47 a266b9ef-5e47-4434-a04c-ce4eacd52c19 agent 25120 31360 抱歉给您带来不好的体验了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。
48 a266b9ef-5e47-4434-a04c-ce4eacd52c19 user 34480 51080 迈腾2023款330豪华版2.0T。出现的问题是轮购车时间是2024年5月份,里程是6851公里。
49 a266b9ef-5e47-4434-a04c-ce4eacd52c19 agent 53640 57100 辛苦您仔细想一想想要反馈投诉的具体事情哈。
50 a266b9ef-5e47-4434-a04c-ce4eacd52c19 user 60820 69860 轮胎质量问题规律,排水槽横向竖向规律。轮胎质量有问题。
51 a266b9ef-5e47-4434-a04c-ce4eacd52c19 agent 69360 75220 好,您反映的问题我全都记下了,我这边尽快安排专员给您回电沟通,您看可以吗?
52 a266b9ef-5e47-4434-a04c-ce4eacd52c19 user 75520 83016 可以。
53 cb3238dc-3946-42a5-aad8-9b9a54226363 agent 13540 15100 请问您现在在哪个城市呢?
54 cb3238dc-3946-42a5-aad8-9b9a54226363 user 16600 17380 辽宁葫芦岛。
55 cb3238dc-3946-42a5-aad8-9b9a54226363 agent 19620 22380 请问您是要业务咨询还是投诉建议?
56 cb3238dc-3946-42a5-aad8-9b9a54226363 user 24420 25060 投诉建议。
57 cb3238dc-3946-42a5-aad8-9b9a54226363 agent 27520 33760 抱歉给您带来不好的体验了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。
58 cb3238dc-3946-42a5-aad8-9b9a54226363 user 34980 51620 我是2023年、2024年1月份购置的迈腾3302023款轿车,目前已行驶28000公里。
59 cb3238dc-3946-42a5-aad8-9b9a54226363 agent 50220 53680 辛苦您仔细想一想,想要反馈投诉的具体事情哈。
60 cb3238dc-3946-42a5-aad8-9b9a54226363 user 56280 62820 我的轮胎平时28000公里。
61 cb3238dc-3946-42a5-aad8-9b9a54226363 agent 62340 65840 辛苦您仔细讲一讲想要反馈投诉的具体事情哈。
62 cb3238dc-3946-42a5-aad8-9b9a54226363 user 68660 75200 轮胎开裂,行驶28000公里,轮胎开裂、纵纹开裂、横纹开裂。
63 cb3238dc-3946-42a5-aad8-9b9a54226363 agent 75600 81460 好,您反映的问题我全都记下了,我这边尽快安排,您看可以吗?
64 cb3238dc-3946-42a5-aad8-9b9a54226363 user 83440 83791 对的。
65 cb3238dc-3946-42a5-aad8-9b9a54226363 user 83791 85080 请尽快帮我处理这件事。
66 cb3238dc-3946-42a5-aad8-9b9a54226363 agent 88040 93120 好的,感谢您的配合,祝您生活愉快,再见。
67 cb3238dc-3946-42a5-aad8-9b9a54226363 user 93420 93756 好的。
68 e304f740-85d4-427e-9fd6-c5474e9cd737 agent 12040 13600 请问您现在在哪个城市呢?
69 e304f740-85d4-427e-9fd6-c5474e9cd737 user 15060 16720 苏州昆山。
70 e304f740-85d4-427e-9fd6-c5474e9cd737 agent 19240 21980 请问您是要业务咨询还是投诉建议?
71 e304f740-85d4-427e-9fd6-c5474e9cd737 user 27060 27500 投诉。
72 e304f740-85d4-427e-9fd6-c5474e9cd737 agent 29980 36220 抱歉给您带来不好的体验了,麻烦告知一下您的爱车型号、购车时间,还有行驶里程。
73 e304f740-85d4-427e-9fd6-c5474e9cd737 user 38480 42760 帕萨特24年11月份。
74 e304f740-85d4-427e-9fd6-c5474e9cd737 agent 41520 44100 购车时间和行驶里程方便说一下吗?
75 e304f740-85d4-427e-9fd6-c5474e9cd737 user 45420 52800 24年11月份购买的帕萨特,15000公里左右。
76 e304f740-85d4-427e-9fd6-c5474e9cd737 agent 55240 58720 辛苦您仔细讲一讲想要反馈投诉的具体事情哈。
77 e304f740-85d4-427e-9fd6-c5474e9cd737 user 60880 68260 四条轮胎,最近我在。查看的时候发现全部开裂。
@@ -0,0 +1,40 @@
call_id,decision_ms,user_text_at_decision,can_reply_now,confidence,reason,annotator
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,19520,贵州省遵义市仁怀市。,1,low,completed_answer,xlsx_draft
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,27420,投诉建议。,1,low,completed_answer,xlsx_draft
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,48940,型号是24年380大众帕萨特星空龙腾版。,1,low,completed_answer,xlsx_draft
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,55420,24年8月份。,1,low,completed_answer,xlsx_draft
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,61460,行驶3万公里。,1,low,completed_answer,xlsx_draft
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,77740,恒泰轮胎前门两条轮胎开裂,东西比较大。,0,low,unfinished_expression,xlsx_draft
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,85680,对。,1,low,completed_answer,xlsx_draft
2c83253a-7977-4e3c-a387-0aa1ea06fbe1,88280,尽快,已经影响行车安全了。,1,low,completed_answer,xlsx_draft
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,19660,宣传。,1,low,completed_answer,xlsx_draft
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,29580,投诉建议。,1,low,completed_answer,xlsx_draft
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,50540,爱车型号是大众帕萨特,购车时间2023年10月份,行驶里程24000多公里。,1,low,completed_answer,xlsx_draft
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,63460,轮胎裂纹。,,low,uncertain,xlsx_draft
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,71080,轮胎裂纹同意更换新轮胎。,,low,uncertain,xlsx_draft
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,73620,但是20。,0,low,unfinished_expression,xlsx_draft
40ae686c-8196-4d7b-b7ad-3e12a427ebb6,80880,可以。,1,low,completed_answer,xlsx_draft
872a1e67-873a-4c49-a598-b38a670afcad,15020,我姓宋。,1,low,completed_answer,xlsx_draft
872a1e67-873a-4c49-a598-b38a670afcad,22860,郑州市,河南郑州市。,1,low,completed_answer,xlsx_draft
872a1e67-873a-4c49-a598-b38a670afcad,31160,我的轮胎开裂了。,1,low,completed_answer,xlsx_draft
872a1e67-873a-4c49-a598-b38a670afcad,60680,帕萨特L2025款2025年3月购车流水流程29000公里。,0,low,unfinished_expression,xlsx_draft
872a1e67-873a-4c49-a598-b38a670afcad,70260,29000公里。29000公里。,1,low,completed_answer,xlsx_draft
872a1e67-873a-4c49-a598-b38a670afcad,73816,可以。,1,low,completed_answer,xlsx_draft
a266b9ef-5e47-4434-a04c-ce4eacd52c19,15760,天津市。,1,low,completed_answer,xlsx_draft
a266b9ef-5e47-4434-a04c-ce4eacd52c19,22900,投诉。,1,low,completed_answer,xlsx_draft
a266b9ef-5e47-4434-a04c-ce4eacd52c19,51080,迈腾2023款330豪华版2.0T。出现的问题是轮购车时间是2024年5月份,里程是6851公里。,0,low,unfinished_expression,xlsx_draft
a266b9ef-5e47-4434-a04c-ce4eacd52c19,69860,轮胎质量问题规律,排水槽横向竖向规律。轮胎质量有问题。,,low,uncertain,xlsx_draft
a266b9ef-5e47-4434-a04c-ce4eacd52c19,83016,可以。,1,low,completed_answer,xlsx_draft
cb3238dc-3946-42a5-aad8-9b9a54226363,17380,辽宁葫芦岛。,1,low,completed_answer,xlsx_draft
cb3238dc-3946-42a5-aad8-9b9a54226363,25060,投诉建议。,1,low,completed_answer,xlsx_draft
cb3238dc-3946-42a5-aad8-9b9a54226363,51620,我是2023年、2024年1月份购置的迈腾3302023款轿车,目前已行驶28000公里。,,low,uncertain,xlsx_draft
cb3238dc-3946-42a5-aad8-9b9a54226363,62820,我的轮胎平时28000公里。,0,low,unfinished_expression,xlsx_draft
cb3238dc-3946-42a5-aad8-9b9a54226363,75200,轮胎开裂,行驶28000公里,轮胎开裂、纵纹开裂、横纹开裂。,1,low,completed_answer,xlsx_draft
cb3238dc-3946-42a5-aad8-9b9a54226363,83791,对的。,1,low,completed_answer,xlsx_draft
cb3238dc-3946-42a5-aad8-9b9a54226363,85080,请尽快帮我处理这件事。,1,low,completed_answer,xlsx_draft
cb3238dc-3946-42a5-aad8-9b9a54226363,93756,好的。,1,low,completed_answer,xlsx_draft
e304f740-85d4-427e-9fd6-c5474e9cd737,16720,苏州昆山。,1,low,completed_answer,xlsx_draft
e304f740-85d4-427e-9fd6-c5474e9cd737,27500,投诉。,1,low,completed_answer,xlsx_draft
e304f740-85d4-427e-9fd6-c5474e9cd737,42760,帕萨特24年11月份。,,low,uncertain,xlsx_draft
e304f740-85d4-427e-9fd6-c5474e9cd737,52800,24年11月份购买的帕萨特,15000公里左右。,1,low,completed_answer,xlsx_draft
e304f740-85d4-427e-9fd6-c5474e9cd737,68260,四条轮胎,最近我在。查看的时候发现全部开裂。,0,low,unfinished_expression,xlsx_draft
1 call_id decision_ms user_text_at_decision can_reply_now confidence reason annotator
2 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 19520 贵州省遵义市仁怀市。 1 low completed_answer xlsx_draft
3 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 27420 投诉建议。 1 low completed_answer xlsx_draft
4 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 48940 型号是24年380大众帕萨特星空龙腾版。 1 low completed_answer xlsx_draft
5 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 55420 24年8月份。 1 low completed_answer xlsx_draft
6 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 61460 行驶3万公里。 1 low completed_answer xlsx_draft
7 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 77740 恒泰轮胎前门两条轮胎开裂,东西比较大。 0 low unfinished_expression xlsx_draft
8 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 85680 对。 1 low completed_answer xlsx_draft
9 2c83253a-7977-4e3c-a387-0aa1ea06fbe1 88280 尽快,已经影响行车安全了。 1 low completed_answer xlsx_draft
10 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 19660 宣传。 1 low completed_answer xlsx_draft
11 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 29580 投诉建议。 1 low completed_answer xlsx_draft
12 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 50540 爱车型号是大众帕萨特,购车时间2023年10月份,行驶里程24000多公里。 1 low completed_answer xlsx_draft
13 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 63460 轮胎裂纹。 low uncertain xlsx_draft
14 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 71080 轮胎裂纹同意更换新轮胎。 low uncertain xlsx_draft
15 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 73620 但是20。 0 low unfinished_expression xlsx_draft
16 40ae686c-8196-4d7b-b7ad-3e12a427ebb6 80880 可以。 1 low completed_answer xlsx_draft
17 872a1e67-873a-4c49-a598-b38a670afcad 15020 我姓宋。 1 low completed_answer xlsx_draft
18 872a1e67-873a-4c49-a598-b38a670afcad 22860 郑州市,河南郑州市。 1 low completed_answer xlsx_draft
19 872a1e67-873a-4c49-a598-b38a670afcad 31160 我的轮胎开裂了。 1 low completed_answer xlsx_draft
20 872a1e67-873a-4c49-a598-b38a670afcad 60680 帕萨特L2025款2025年3月购车流水流程29000公里。 0 low unfinished_expression xlsx_draft
21 872a1e67-873a-4c49-a598-b38a670afcad 70260 29000公里。29000公里。 1 low completed_answer xlsx_draft
22 872a1e67-873a-4c49-a598-b38a670afcad 73816 可以。 1 low completed_answer xlsx_draft
23 a266b9ef-5e47-4434-a04c-ce4eacd52c19 15760 天津市。 1 low completed_answer xlsx_draft
24 a266b9ef-5e47-4434-a04c-ce4eacd52c19 22900 投诉。 1 low completed_answer xlsx_draft
25 a266b9ef-5e47-4434-a04c-ce4eacd52c19 51080 迈腾2023款330豪华版2.0T。出现的问题是轮购车时间是2024年5月份,里程是6851公里。 0 low unfinished_expression xlsx_draft
26 a266b9ef-5e47-4434-a04c-ce4eacd52c19 69860 轮胎质量问题规律,排水槽横向竖向规律。轮胎质量有问题。 low uncertain xlsx_draft
27 a266b9ef-5e47-4434-a04c-ce4eacd52c19 83016 可以。 1 low completed_answer xlsx_draft
28 cb3238dc-3946-42a5-aad8-9b9a54226363 17380 辽宁葫芦岛。 1 low completed_answer xlsx_draft
29 cb3238dc-3946-42a5-aad8-9b9a54226363 25060 投诉建议。 1 low completed_answer xlsx_draft
30 cb3238dc-3946-42a5-aad8-9b9a54226363 51620 我是2023年、2024年1月份购置的迈腾3302023款轿车,目前已行驶28000公里。 low uncertain xlsx_draft
31 cb3238dc-3946-42a5-aad8-9b9a54226363 62820 我的轮胎平时28000公里。 0 low unfinished_expression xlsx_draft
32 cb3238dc-3946-42a5-aad8-9b9a54226363 75200 轮胎开裂,行驶28000公里,轮胎开裂、纵纹开裂、横纹开裂。 1 low completed_answer xlsx_draft
33 cb3238dc-3946-42a5-aad8-9b9a54226363 83791 对的。 1 low completed_answer xlsx_draft
34 cb3238dc-3946-42a5-aad8-9b9a54226363 85080 请尽快帮我处理这件事。 1 low completed_answer xlsx_draft
35 cb3238dc-3946-42a5-aad8-9b9a54226363 93756 好的。 1 low completed_answer xlsx_draft
36 e304f740-85d4-427e-9fd6-c5474e9cd737 16720 苏州昆山。 1 low completed_answer xlsx_draft
37 e304f740-85d4-427e-9fd6-c5474e9cd737 27500 投诉。 1 low completed_answer xlsx_draft
38 e304f740-85d4-427e-9fd6-c5474e9cd737 42760 帕萨特24年11月份。 low uncertain xlsx_draft
39 e304f740-85d4-427e-9fd6-c5474e9cd737 52800 24年11月份购买的帕萨特,15000公里左右。 1 low completed_answer xlsx_draft
40 e304f740-85d4-427e-9fd6-c5474e9cd737 68260 四条轮胎,最近我在。查看的时候发现全部开裂。 0 low unfinished_expression xlsx_draft
@@ -0,0 +1,314 @@
[
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source_row": 2,
"decision_ms": 19520,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source_row": 3,
"decision_ms": 27420,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source_row": 4,
"decision_ms": 48940,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source_row": 5,
"decision_ms": 55420,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source_row": 6,
"decision_ms": 61460,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source_row": 7,
"decision_ms": 77740,
"original_interrupted": "是",
"unheaded_column_e": "是",
"conflict": false
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source_row": 8,
"decision_ms": 85680,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source_row": 9,
"decision_ms": 88280,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source_row": 10,
"decision_ms": 19660,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source_row": 11,
"decision_ms": 29580,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source_row": 12,
"decision_ms": 50540,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source_row": 13,
"decision_ms": 63460,
"original_interrupted": "是",
"unheaded_column_e": "否",
"conflict": true
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source_row": 14,
"decision_ms": 71080,
"original_interrupted": "否",
"unheaded_column_e": "是",
"conflict": true
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source_row": 15,
"decision_ms": 73620,
"original_interrupted": "是",
"unheaded_column_e": "是",
"conflict": false
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source_row": 16,
"decision_ms": 80880,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "872a1e67-873a-4c49-a598-b38a670afcad",
"source_row": 17,
"decision_ms": 15020,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "872a1e67-873a-4c49-a598-b38a670afcad",
"source_row": 18,
"decision_ms": 22860,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "872a1e67-873a-4c49-a598-b38a670afcad",
"source_row": 19,
"decision_ms": 31160,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "872a1e67-873a-4c49-a598-b38a670afcad",
"source_row": 20,
"decision_ms": 60680,
"original_interrupted": "是",
"unheaded_column_e": "是",
"conflict": false
},
{
"call_id": "872a1e67-873a-4c49-a598-b38a670afcad",
"source_row": 21,
"decision_ms": 70260,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "872a1e67-873a-4c49-a598-b38a670afcad",
"source_row": 22,
"decision_ms": 73816,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "a266b9ef-5e47-4434-a04c-ce4eacd52c19",
"source_row": 23,
"decision_ms": 15760,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "a266b9ef-5e47-4434-a04c-ce4eacd52c19",
"source_row": 24,
"decision_ms": 22900,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "a266b9ef-5e47-4434-a04c-ce4eacd52c19",
"source_row": 25,
"decision_ms": 51080,
"original_interrupted": "是",
"unheaded_column_e": "是",
"conflict": false
},
{
"call_id": "a266b9ef-5e47-4434-a04c-ce4eacd52c19",
"source_row": 26,
"decision_ms": 69860,
"original_interrupted": "否",
"unheaded_column_e": "是",
"conflict": true
},
{
"call_id": "a266b9ef-5e47-4434-a04c-ce4eacd52c19",
"source_row": 27,
"decision_ms": 83016,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source_row": 28,
"decision_ms": 17380,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source_row": 29,
"decision_ms": 25060,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source_row": 30,
"decision_ms": 51620,
"original_interrupted": "否",
"unheaded_column_e": "是",
"conflict": true
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source_row": 31,
"decision_ms": 62820,
"original_interrupted": "是",
"unheaded_column_e": "是",
"conflict": false
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source_row": 32,
"decision_ms": 75200,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source_row": 33,
"decision_ms": 83791,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source_row": 34,
"decision_ms": 85080,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source_row": 35,
"decision_ms": 93756,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "e304f740-85d4-427e-9fd6-c5474e9cd737",
"source_row": 36,
"decision_ms": 16720,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "e304f740-85d4-427e-9fd6-c5474e9cd737",
"source_row": 37,
"decision_ms": 27500,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "e304f740-85d4-427e-9fd6-c5474e9cd737",
"source_row": 38,
"decision_ms": 42760,
"original_interrupted": "否",
"unheaded_column_e": "是",
"conflict": true
},
{
"call_id": "e304f740-85d4-427e-9fd6-c5474e9cd737",
"source_row": 39,
"decision_ms": 52800,
"original_interrupted": "否",
"unheaded_column_e": "",
"conflict": false
},
{
"call_id": "e304f740-85d4-427e-9fd6-c5474e9cd737",
"source_row": 40,
"decision_ms": 68260,
"original_interrupted": "是",
"unheaded_column_e": "是",
"conflict": false
}
]
@@ -0,0 +1,15 @@
# 韩泰可编辑初稿
源文件为 `data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx` 的“测试数据集”第 2–40 行,涵盖 6 通录音、39 条用户发言。忽略表尾的生成标识。
`asr_history.csv` 包含 76 条转写:39 条用户发言、37 条机器人发言。原表连续重复的“上一句机器人”合并为一个片段。这里只转换原表覆盖的内容,录音中的开场、重试和其他未收录发言仍需人工补充。
原表没有时间信息。初稿使用双声道音量和静音间隔提取语音区间,暂按右声道为机器人、左声道为用户,按顺序和时长匹配原表。连续多条回答共用一个机器人提问时,响应窗口按文本长度分配初始片段边界。这只是便于编辑的估计,不是词级对齐;声道角色、文本和起止时间均需回听校正。
`eot_labels.csv` 包含 39 个判断点,暂放在对应用户片段结束位置。用户文本直接复制原表,未推算句中字符。原表 D 列“被抢话=是”暂映射为 `can_reply_now=0`,“否”映射为 `1`;E 列无表头,仅用于发现与 D 列矛盾的记录,5 条矛盾记录的标签留空并设为 `uncertain`。全部置信度为 `low`、标注员为 `xlsx_draft`。这些标签是从抢话结论生成的候选值,不等同于经过该判断时刻核验的 EOT 真值。
请先回听每个判断点,校正 `decision_ms` 与 `user_text_at_decision`,再确认标签、原因和置信度。`source_rows.json` 保留原行号、原标签及无表头列的原值,便于追溯。
网页在没有“工作数据”时自动读取此初稿。编辑后点击“保存到项目”,写入 `labels/EOT标注/工作数据/`。已有工作数据优先,初稿与原 XLSX 均不被网页保存覆盖。
重新生成:在安装了 NumPy 和 FFmpeg 的环境中运行 `python3 labels/eot-studio/scripts/convert-hantai.py`。仅重新生成“韩泰初稿”,不会修改“工作数据”。
+9 -9
View File
@@ -14,15 +14,15 @@ npm run dev
## 使用
1. 默认聚焦“韩泰机器人抢话”的 6 通录音。左侧数据集选择器可切换到其他目录、标准示例或全部数据,通话数量与标注进度按当前数据集统计。分别导入 ASR 和 EOT CSV,或直接选择项目 `data` 目录中的通话录音开始整理。
2. 选择通话和录音,在时间轴查看机器人、用户语音段及 EOT 判断标记。播放、拖动时间轴或选择标记定位需要核对的位置。
3. 编辑 `decision_ms`、当前用户文本、是否可以接话、判断原因、置信度和标注员。音频播放器的时间采用整通录音的媒体时钟。
4. 对话历史默认只显示 `end_ms <= decision_ms` 的记录。当前段横跨判断位置时,请听音频填写 `user_text_at_decision`;不能按完整转写的字符长度推算前半句。查看后续对话用于仲裁,后续内容不进入该判断位置的模型输入。
5. 点击“保存到项目”将两份 CSV 写到 `labels/EOT标注/工作数据/`。导出 CSV 会下载当前数据,可交给其他标注员或用于训练和评测。
1. 默认载入“韩泰机器人抢话”的 6 通录音,以及原 XLSX 转换出的 76 条转写、39 个 EOT 判断点。已有工作数据时优先读取工作数据,初稿保留在 `labels/EOT标注/韩泰初稿/`。
2. 左右声道分别显示波形,共用时间刻度。机器人、用户转写作为对应轨道上的块显示;点击块在右侧栏编辑说话人、文本和起止时间;拖动左右边界直接调整片段范围。拖动结束作为一次修改,可撤销。查看模式的字段为只读。点击“在播放位置新增”的下拉菜单,选择机器人转写、用户转写或 EOT 判断点。
3. 点击判断点轨道空白处直接新增 EOT;聚焦该轨道后按 Enter 可在播放位置新增。点击已有标记选择标签,直接拖动判断点轴上的标记调整时间,聚焦标记后可用方向键微调;拖动时吸附转写边界、播放位置和其他判断点,按住 Alt 暂停吸附,松开后作为一次修改可撤销。同一通话的判断点不能重合。放大时间轴便于查看短片段。顶部只保留播放头。播放头在点击或拖动定位时,会吸附附近的转写块边界及 EOT 判断点;按住 Alt 暂停吸附。
4. 右侧编辑判断时的用户文本、是否可以接话、判断原因、置信度和标注员。标注员可选,不影响处理状态。标签为“可以接话”或“继续等待”就计入已处理,只有空白标签(需要仲裁)算待处理。
5. 时间轴下方“撤销上一步 / 重做上一步”支持最近 40 步转写、判断点和导入修改,Ctrl+Z 撤销,Ctrl+Y 或 Ctrl+Shift+Z 重做;Mac 也支持 ⌘+Z / ⌘+Shift+Z。在时间轴的块或判断点上聚焦时同样有效,文本输入框内保留原生文字撤销。撤销后作新修改会清空重做记录;刷新页面后历史记录清空,浏览器草稿仍按原规则恢复。
5. 时间轴显示整段对话,模型输入仍仅使用 `end_ms <= decision_ms` 的完整历史;当前用户表达跨越判断点时需回听填写 `user_text_at_decision`,不按字符比例推算。
6. 点击“保存到项目”写入 `labels/EOT标注/工作数据/`。切换数据集只改变浏览范围;导出菜单可选当前数据集或全部,项目保存保留全部数据。
草稿在当前浏览器的本地存储中保存,刷新后可恢复。录音来自项目目录或本次选择的本地文件;本地文件的播放地址不会存入草稿。浏览器草稿与项目 CSV 分开,只有明确点击“保存到项目”才会改写项目中的工作数据。原始示例和 `data` 下的录音不会被修改。
切换数据集只改变浏览范围;导出菜单可选择“当前数据集”或“全部”,项目保存会保留完整数据。韩泰录音旁可展开“原表校对参考”,查看已校对表提取的 39 条原始片段。它们没有起止时间或 EOT 判断时间,因此需回听定位后填写标准转写和标签。原表第 5 列没有表头,提取结果按原值展示;原表结论不会自动转为 EOT 标签。参考数据在 `labels/eot-studio/data/hantai-reference.json` 中,保留源文件、工作表和行号,不会随 CSV 保存被改写。当前工具没有工作 CSV 时,仍载入标准示例供切换查看。
浏览器草稿自动保留,刷新后可恢复。网页保存不覆盖原 XLSX、初稿和录音。初稿转换方法、标签映射与限制见 [转换说明](../EOT标注/韩泰初稿/转换说明.md),原行号及原结论保留在初稿目录的 `source_rows.json` 中。原表没有时间信息,录音中未收录的开场与其他发言需人工补充。
## CSV 格式
@@ -47,7 +47,7 @@ demo,5200,我明天下午,0,high,mid_turn_pause,A
## 本地接口
- `GET /api/workspace`:读取工作数据、项目录音列表和只读 `callReferences` 原表参考。工作数据的两份 CSV 都存在时使用它们,否则载入示例;已保存 CSV 格式错误时返回错误,不用示例覆盖。
- `GET /api/workspace`:读取工作数据和项目录音列表。工作数据的两份 CSV 都存在时使用它们,否则载入韩泰初稿;初稿不存在时载入示例;已保存 CSV 格式错误时返回错误,不用示例覆盖。
- `POST /api/workspace`:接受 `{ transcripts, annotations }`,校验后保存到固定工作数据目录,并返回 `savedAt`。同一进程的读写按顺序执行;文件锁防止不同进程同时操作,请求完成后释放。若进程在保存中被强制终止,请确认没有其他服务运行,检查残留 `.backup` 文件并恢复同一次保存前的两份 CSV,再移除工作数据目录的 `.workspace.lock`。
- `GET /api/audio?path=...`:仅提供 `data` 内的 MP3、WAV、M4A、OGG、FLAC 和 WebM 录音,支持 Range 请求和 HEAD,便于跳转播放。路径相对 `data` 目录,不能通过 `..` 或符号链接访问目录之外的文件。
+7 -39
View File
@@ -10,7 +10,6 @@ import {
type Transcript,
} from "@/lib/annotations";
import { isSameOriginRequest } from "@/lib/request-origin";
import type { CallReference } from "@/lib/call-reference";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -20,7 +19,7 @@ const dataRoot = path.join(projectRoot, "data");
const labelsRoot = path.join(projectRoot, "labels", "EOT标注");
const savedRoot = path.join(labelsRoot, "工作数据");
const exampleRoot = path.join(labelsRoot, "示例");
const callReferencesPath = path.join(process.cwd(), "data", "hantai-reference.json");
const draftRoot = path.join(labelsRoot, "韩泰初稿");
const lockPath = path.join(savedRoot, ".workspace.lock");
const extensions = new Set([".mp3", ".wav", ".m4a", ".ogg", ".flac", ".webm"]);
const maxPayloadBytes = 20 * 1024 * 1024;
@@ -119,39 +118,6 @@ async function discoverRecordings(): Promise<Recording[]> {
return result;
}
async function readCallReferences(): Promise<CallReference[]> {
let source: string;
try {
source = await fs.readFile(callReferencesPath, "utf8");
} catch (error) {
if (hasCode(error, "ENOENT")) return [];
throw error;
}
try {
const references: unknown = JSON.parse(source);
if (!Array.isArray(references)) throw new Error("顶层必须为数组。");
return references.map((value, index) => {
const message = `第 ${index + 1} 条原表记录格式无效。`;
if (!value || typeof value !== "object") throw new Error(message);
const row = value as Partial<CallReference>;
if (typeof row.call_id !== "string" || !row.call_id.trim()
|| !row.source || typeof row.source.file !== "string" || typeof row.source.sheet !== "string"
|| !Number.isSafeInteger(row.source.row) || row.source.row < 1
|| !Array.isArray(row.fields)
|| row.fields.some((field) => !field || typeof field.label !== "string" || typeof field.value !== "string")) {
throw new Error(message);
}
return {
call_id: row.call_id,
source: { file: row.source.file, sheet: row.source.sheet, row: row.source.row },
fields: row.fields.map((field) => ({ label: field.label, value: field.value })),
};
});
} catch (error) {
throw new WorkspaceError(`原表参考数据格式有误:${error instanceof Error ? error.message : "JSON 格式错误。"}`);
}
}
async function readCsvPair(directory: string): Promise<{ transcripts: Transcript[]; annotations: Annotation[] }> {
const [transcriptsCsv, annotationsCsv] = await Promise.all([
fs.readFile(path.join(directory, "asr_history.csv"), "utf8"),
@@ -179,22 +145,24 @@ export async function GET(): Promise<Response> {
if (await exists(lockPath)) {
throw new WorkspaceError("工作数据正在保存,请稍后重试。", 409);
}
const [hasTranscripts, hasAnnotations, recordings, callReferences] = await Promise.all([
const [hasTranscripts, hasAnnotations, recordings] = await Promise.all([
exists(path.join(savedRoot, "asr_history.csv")),
exists(path.join(savedRoot, "eot_labels.csv")),
discoverRecordings(),
readCallReferences(),
]);
if (hasTranscripts && hasAnnotations) {
return await withDiskLock(async () => json({
...await readCsvPair(savedRoot),
recordings,
callReferences,
source: "saved",
savedAt: await savedAt(),
}));
}
return json({ ...await readCsvPair(exampleRoot), recordings, callReferences, source: "example" });
if (await exists(path.join(draftRoot, "asr_history.csv")) && await exists(path.join(draftRoot, "eot_labels.csv"))) {
const stats = await Promise.all([fs.stat(path.join(draftRoot, "asr_history.csv")), fs.stat(path.join(draftRoot, "eot_labels.csv"))]);
return json({ ...await readCsvPair(draftRoot), recordings, source: "draft", savedAt: new Date(Math.max(...stats.map(stat => stat.mtimeMs))).toISOString() });
}
return json({ ...await readCsvPair(exampleRoot), recordings, source: "example" });
});
} catch (error) {
return errorResponse(error);
+13 -22
View File
@@ -198,7 +198,8 @@ button.transcript-time:hover { color: #6c8455; text-decoration: underline; }
.workspace-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 15px; color: #748167; font-size: 11px; }
.workspace-footer > span { display: flex; align-items: center; gap: 5px; }
.workspace-footer svg { width: 12px; height: 12px; }
.workspace-footer > button { display: flex; align-items: center; gap: 5px; padding: 0; border: 0; background: none; color: #7e8c6f; font-size: 11px; }
.history-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; }
.history-actions > button { display: flex; align-items: center; gap: 5px; padding: 0; border: 0; background: none; color: #7e8c6f; font-size: 11px; }
.annotation-sidebar { display: flex; flex-direction: column; min-width: 0; border-left: 1px solid var(--border); background: #fcfdf8; }
.editor-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 24px 21px 17px; border-bottom: 1px solid #e5ebdb; }
@@ -252,23 +253,6 @@ button.transcript-time:hover { color: #6c8455; text-decoration: underline; }
.empty-editor p { font-size: 12px; line-height: 1.8; }
.empty-marker { display: grid; place-items: center; width: 47px; height: 47px; margin: 0 auto 14px; border: 1px dashed #d9e3c9; border-radius: 12px; background: #f3f8e9; color: #748167; }
.empty-marker svg { width: 21px; height: 21px; }
.annotation-list-section { padding: 17px 21px 18px; border-top: 1px solid #e3eada; }
.list-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
.list-heading h3 { margin: 0; color: #738b5f; font-size: 11px; font-weight: 500; }
.list-heading h3 > span { margin-left: 7px; }
.list-heading span { color: #a0af8d; font-family: var(--font-mono); font-size: 11px; }
.annotation-list { display: flex; flex-direction: column; gap: 4px; max-height: 211px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #d4dfc6 transparent; }
.annotation-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 9px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: #748167; text-align: left; font-size: 11px; }
.annotation-item:hover { background: #f2f7e9; }
.annotation-item.selected { border-color: #e2e9d7; background: #eff5e5; }
.annotation-item > span:first-child { font-family: var(--font-mono); font-size: 11px; color: #748167; }
.annotation-item strong { font-family: var(--font-mono); font-weight: 500; font-size: 11px; color: #7e9469; }
.annotation-item > div { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
.annotation-item > div > small { display: block; overflow: hidden; max-width: 150px; color: #829274; font-size: 11px; white-space: nowrap; text-overflow: ellipsis; }
.annotation-item .label-pill { margin-left: auto; }
.label-pill { display: inline-flex; align-items: center; gap: 4px; padding: 2px 5px; border: 1px solid #dce6cc; border-radius: 4px; background: #f0f6e5; color: #748167; font-size: 11px; white-space: nowrap; }
.label-pill.negative { border-color: #ecdbc3; background: #fbf2e6; color: #b39a70; }
.label-pill.pending { border-color: #e1e7d6; background: #f5f8ee; color: #748167; }
.keyboard-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 9px; margin-top: auto; padding: 13px 25px; border-top: 1px solid #e5ecdb; background: #fafcf4; color: #748167; font-size: 11px; }
.keyboard-footer > span { display: inline-flex; align-items: center; gap: 4px; }
.keyboard-footer > span i { display: inline-block; width: 3px; height: 3px; margin-inline: 5px; border-radius: 50%; background: #b6c3a6; }
@@ -303,7 +287,7 @@ kbd { min-width: 17px; padding: 0 4px; border: 1px solid #dce5ce; border-bottom-
@media (max-width: 1200px) {
.workspace-grid { grid-template-columns: 208px minmax(0, 1fr) 300px; }
.main-workspace { padding-inline: 21px; }
.editor-heading, .editor-body, .annotation-list-section { padding-left: 17px; padding-right: 17px; }
.editor-heading, .editor-body { padding-left: 17px; padding-right: 17px; }
.topbar { gap: 14px; }
.save-status { display: none; }
.player-secondary { gap: 7px; }
@@ -315,12 +299,20 @@ kbd { min-width: 17px; padding: 0 4px; border: 1px solid #dce5ce; border-bottom-
.main-workspace { padding: 25px 25px 18px; }
.annotation-sidebar { grid-column: 2; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; border-top: 1px solid var(--border); border-left: 0; }
.editor-heading { grid-column: span 2; }
.annotation-list-section { border-top: 0; border-left: 1px solid #e3eada; margin: 18px 0; padding-top: 0; }
.keyboard-footer { grid-column: span 2; }
.topbar { padding-inline: 19px; }
.workspace-tag { display: none; }
}
@media (min-width: 900px) and (max-width: 1020px) {
.workspace-grid { grid-template-columns: 176px minmax(0, 1fr) 270px; }
.sidebar { grid-row: auto; padding-inline: 12px; }
.main-workspace { padding: 22px 14px 18px; }
.annotation-sidebar { grid-column: auto; display: flex; border-top: 0; border-left: 1px solid var(--border); }
.player { flex-wrap: wrap; gap: 10px; }
.player-secondary { width: 100%; justify-content: space-between; }
}
@media (max-width: 720px) {
.topbar { min-height: 65px; padding: 12px 15px; gap: 9px; flex-wrap: wrap; }
.brand { gap: 7px; font-size: 15px; }
@@ -363,8 +355,6 @@ kbd { min-width: 17px; padding: 0 4px; border: 1px solid #dce5ce; border-bottom-
.annotation-sidebar { display: flex; border-top: 1px solid var(--border); }
.editor-heading { padding: 18px 19px 15px; }
.editor-body { padding: 17px 19px 18px; }
.annotation-list-section { margin: 0; padding: 16px 19px 19px; border-left: 0; border-top: 1px solid #e3eada; }
.annotation-list { max-height: 210px; }
.keyboard-footer { padding: 14px 19px; }
.workspace-footer { flex-wrap: wrap; }
.modal-backdrop { padding: 14px; }
@@ -384,3 +374,4 @@ kbd { min-width: 17px; padding: 0 4px; border: 1px solid #dce5ce; border-bottom-
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
}
.transcript-editor-actions { display: flex; align-items: center; gap: 8px; }
+131 -63
View File
@@ -2,13 +2,13 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import Timeline from "@/components/timeline";
import CallReferencePanel from "@/components/call-reference";
import type { CallReference } from "@/lib/call-reference";
import TranscriptEditor from "@/components/transcript-editor";
import { createEditHistory, commitEdit, undoEdit, redoEdit } from "@/lib/edit-history";
import { createAnnotation, exportAnnotations, exportTranscripts, formatTime, parseAnnotations, parseTranscripts, reasonOptions, validateAnnotation, type Annotation, type Transcript } from "@/lib/annotations";
type Recording = { call_id: string; name: string; path: string; url: string };
type Dataset = { transcripts: Transcript[]; annotations: Annotation[] };
type Workspace = Dataset & { recordings: Recording[]; callReferences: CallReference[]; source: "saved" | "example"; savedAt?: string };
type Workspace = Dataset & { recordings: Recording[]; source: "saved" | "draft" | "example"; savedAt?: string };
type Notice = { message: string; kind: "success" | "error" | "info" };
const DRAFT_KEY = "eot-workbench-v1";
const DEFAULT_DATASET = "韩泰机器人抢话";
@@ -23,13 +23,13 @@ function callDataset(id: string, recordings: Recording[], originalGroups?: Map<s
const recording = recordings.find(row => row.call_id === id);
return recording ? recordingDataset(recording) : id === "demo" ? "标准示例" : "已导入对话";
}
type IconName = "wave" | "upload" | "download" | "save" | "search" | "play" | "pause" | "back" | "forward" | "plus" | "check" | "clock" | "help" | "edit" | "trash" | "undo" | "headphones" | "chevron" | "close" | "file";
type IconName = "wave" | "upload" | "download" | "save" | "search" | "play" | "pause" | "back" | "forward" | "plus" | "check" | "clock" | "help" | "edit" | "trash" | "undo" | "redo" | "headphones" | "chevron" | "close" | "file";
const paths: Record<IconName, string> = {
wave: "M3 10v4m4-8v12m5-16v20m5-16v12m4-8v4", upload: "M12 16V3m-5 5 5-5 5 5M4 16v4h16v-4", download: "M12 3v13m-5-5 5 5 5-5M4 17v3h16v-3",
save: "M5 3h13l3 3v15H3V3h2m2 0v6h10V3M7 21v-8h10v8", search: "M21 21l-5-5M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0", play: "m9 5 11 7-11 7V5", pause: "M8 5v14m8-14v14",
back: "M11 5 4 12l7 7M4 12h10a6 6 0 0 1 6 6", forward: "m13 5 7 7-7 7M20 12H10a6 6 0 0 0-6 6", plus: "M12 5v14M5 12h14", check: "m5 12 4 4L19 6", clock: "M12 7v5l3 2M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0",
help: "M9 9a3 3 0 0 1 6 0c0 2-3 2-3 4m0 3h.01M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0", edit: "m16 3 5 5-12 12-6 1 1-6L16 3m-2 2 5 5", trash: "M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7",
undo: "M8 3 3 8l5 5M3 8h11a7 7 0 0 1 0 14", headphones: "M4 14v-2a8 8 0 0 1 16 0v2M4 13H2v7h5v-7H4m16 0h2v7h-5v-7h3", chevron: "m6 9 6 6 6-6", close: "m6 6 12 12M18 6 6 18", file: "M14 2H5v20h14V7l-5-5v5h5M8 12h8m-8 4h6",
undo: "M8 3 3 8l5 5M3 8h11a7 7 0 0 1 0 14", redo: "m16 3 5 5-5 5M21 8H10a7 7 0 0 0 0 14", headphones: "M4 14v-2a8 8 0 0 1 16 0v2M4 13H2v7h5v-7H4m16 0h2v7h-5v-7h3", chevron: "m6 9 6 6 6-6", close: "m6 6 12 12M18 6 6 18", file: "M14 2H5v20h14V7l-5-5v5h5M8 12h8m-8 4h6",
};
function Icon({ name, size = 18 }: { name: IconName; size?: number }) { return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={paths[name]} /></svg>; }
function labelName(row: Annotation) { return row.can_reply_now === "1" ? "可以接话" : row.can_reply_now === "0" ? "继续等待" : row.reason ? "待仲裁" : "待标注"; }
@@ -37,15 +37,15 @@ function labelClass(row: Annotation) { return row.can_reply_now === "1" ? "posit
function download(filename: string, content: string) { const url = URL.createObjectURL(new Blob(["\uFEFF", content], { type: "text/csv;charset=utf-8" })); const link = document.createElement("a"); link.href = url; link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }
export default function Workbench() {
const [transcripts, setTranscripts] = useState<Transcript[]>([]);
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [history, setHistory] = useState(() => createEditHistory<Dataset>({ transcripts: [], annotations: [] }));
const historyRef = useRef(history);
const { transcripts, annotations } = history.present;
const [recordings, setRecordings] = useState<Recording[]>([]);
const [callReferences, setCallReferences] = useState<CallReference[]>([]);
const [callId, setCallId] = useState("demo");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [currentMs, setCurrentMs] = useState(0);
const [audioDuration, setAudioDuration] = useState(0);
const [waveform, setWaveform] = useState<number[] | null>(null);
const [waveform, setWaveform] = useState<number[][] | null>(null);
const [playing, setPlaying] = useState(false);
const [rate, setRate] = useState(1);
const [loading, setLoading] = useState(true);
@@ -57,10 +57,8 @@ export default function Workbench() {
const [filter, setFilter] = useState("all");
const [datasetScope, setDatasetScope] = useState(DEFAULT_DATASET);
const [mode, setMode] = useState<"edit" | "view">("edit");
const [showFuture, setShowFuture] = useState(false);
const [menu, setMenu] = useState<"import" | "export" | null>(null);
const [menu, setMenu] = useState<"import" | "export" | "create" | null>(null);
const [notice, setNotice] = useState<Notice | null>(null);
const [undoStack, setUndoStack] = useState<Dataset[]>([]);
const [transcriptDraft, setTranscriptDraft] = useState<{ index: number; row: Transcript } | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
const audioRef = useRef<HTMLAudioElement>(null);
@@ -73,12 +71,19 @@ export default function Workbench() {
const originalGroupsRef = useRef(new Map<string, string>());
const notify = useCallback((message: string, kind: Notice["kind"] = "success") => setNotice({ message, kind }), []);
const commit = useCallback((next: Dataset) => {
const commit = useCallback((next: Dataset | ((current: Dataset) => Dataset)) => {
const current = historyRef.current;
const updated = commitEdit(current, typeof next === "function" ? next(current.present) : next);
if (updated === current) return;
historyRef.current = updated; setHistory(updated);
versionRef.current++;
setUndoStack(stack => [...stack.slice(-39), { transcripts, annotations }]);
setTranscripts(next.transcripts); setAnnotations(next.annotations); setDirty(true); setSavedTime(null);
}, [annotations, transcripts]);
const updateAnnotation = useCallback((id: string, patch: Partial<Annotation>) => commit({ transcripts, annotations: annotations.map(row => row.id === id ? { ...row, ...patch } : row) }), [annotations, transcripts, commit]);
setDirty(true); setSavedTime(null);
}, []);
const updateAnnotation = useCallback((id: string, patch: Partial<Annotation>) => commit(current => {
const row = current.annotations.find(item => item.id === id);
if (!row || Object.entries(patch).every(([key, value]) => row[key as keyof Annotation] === value)) return current;
return { ...current, annotations: current.annotations.map(item => item.id === id ? { ...item, ...patch } : item) };
}), [commit]);
const loadWorkspace = useCallback(async () => {
try {
const response = await fetch("/api/workspace", { cache: "no-store" }); const payload = await response.json();
@@ -92,7 +97,7 @@ export default function Workbench() {
}
} catch { /* A corrupt browser draft never blocks opening the project. */ }
originalGroupsRef.current = new Map(workspace.recordings.map(row => [row.call_id, recordingDataset(row)]));
setTranscripts(dataset.transcripts); setAnnotations(dataset.annotations); setRecordings(workspace.recordings); setCallReferences(workspace.callReferences || []);
const initialHistory = createEditHistory(dataset); historyRef.current = initialHistory; setHistory(initialHistory); setRecordings(workspace.recordings);
const preferredRecording = workspace.recordings.find(row => recordingDataset(row) === DEFAULT_DATASET);
const initialCall = preferredRecording?.call_id || dataset.annotations[0]?.call_id || dataset.transcripts[0]?.call_id || workspace.recordings[0]?.call_id || "demo";
const initial = dataset.annotations.filter(row => row.call_id === initialCall).sort((a, b) => a.decision_ms - b.decision_ms)[0];
@@ -112,23 +117,22 @@ export default function Workbench() {
useEffect(() => { if (!notice) return; const timer = setTimeout(() => setNotice(null), notice.kind === "error" ? 9000 : 4500); return () => clearTimeout(timer); }, [notice]);
useEffect(() => { const urls = objectUrls.current; return () => urls.forEach(url => URL.revokeObjectURL(url)); }, []);
useEffect(() => {
if (!transcriptDraft && !helpOpen) return;
if (!helpOpen) return;
const oldFocus = document.activeElement as HTMLElement | null;
const modal = document.querySelector<HTMLElement>(".modal"); const focusables = () => [...(modal?.querySelectorAll<HTMLElement>('button:not(:disabled),input,select,textarea') || [])];
(modal?.querySelector<HTMLElement>("[autofocus]") || focusables()[0])?.focus();
const handler = (event: KeyboardEvent) => {
if (event.key === "Escape") { setTranscriptDraft(null); setHelpOpen(false); }
if (event.key === "Escape") setHelpOpen(false);
if (event.key === "Tab") { const elements = focusables(); const first = elements[0], last = elements.at(-1); if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } }
};
document.addEventListener("keydown", handler); return () => { document.removeEventListener("keydown", handler); oldFocus?.focus(); };
}, [!!transcriptDraft, helpOpen]);
}, [helpOpen]);
const callAnnotations = useMemo(() => annotations.filter(row => row.call_id === callId).sort((a, b) => a.decision_ms - b.decision_ms), [annotations, callId]);
const callTranscripts = useMemo(() => transcripts.map((row, index) => ({ row, index })).filter(({ row }) => row.call_id === callId).sort((a, b) => a.row.start_ms - b.row.start_ms), [transcripts, callId]);
const selected = callAnnotations.find(row => row.id === selectedId) || null;
const recording = recordings.find(row => row.call_id === callId) || null;
const durationMs = audioDuration || Math.max(10000, ...callTranscripts.map(({ row }) => row.end_ms + 1500), ...callAnnotations.map(row => row.decision_ms + 1500));
const boundaryMs = selected?.decision_ms ?? currentMs;
const selectedError = selected ? validateAnnotation(selected, annotations.filter(row => row.id !== selected.id)) : null;
const allCalls = useMemo(() => {
const groupNumbers = new Map<string, number>();
@@ -146,19 +150,17 @@ export default function Workbench() {
const visibleCalls = scopedCalls.filter(row => `${row.id} ${row.name}`.toLowerCase().includes(search.toLowerCase()) && (filter !== "pending" || row.pending > 0 || row.count === 0));
const callName = allCalls.find(row => row.id === callId)?.name || callId;
const completed = scopedAnnotations.filter(row => row.can_reply_now !== "").length;
const visibleTranscripts = callTranscripts.filter(({ row }) => showFuture || row.end_ms <= boundaryMs);
const hiddenCount = callTranscripts.length - visibleTranscripts.length;
const seek = useCallback((ms: number) => {
const next = Math.max(0, Math.min(durationMs, Math.round(ms))); setCurrentMs(next); playBoundary.current = null;
if (audioRef.current && recording && Number.isFinite(audioRef.current.duration)) audioRef.current.currentTime = next / 1000;
}, [durationMs, recording]);
const selectAnnotation = useCallback((id: string) => { const row = annotations.find(row => row.id === id); if (row) { setSelectedId(id); seek(row.decision_ms); setShowFuture(false); } }, [annotations, seek]);
const selectAnnotation = useCallback((id: string, seekToPoint = true) => { const row = annotations.find(row => row.id === id); if (row) { setTranscriptDraft(null); setSelectedId(id); if (seekToPoint) seek(row.decision_ms); } }, [annotations, seek]);
const chooseCall = (id: string) => {
if (id === callId) return;
audioRef.current?.pause(); playBoundary.current = null; setPlaying(false); setAudioDuration(0); setWaveform(null);
const initial = annotations.filter(row => row.call_id === id).sort((a, b) => a.decision_ms - b.decision_ms)[0];
setCallId(id); setSelectedId(initial?.id || null); setCurrentMs(initial?.decision_ms || 0); setShowFuture(false); setTranscriptDraft(null);
setCallId(id); setSelectedId(initial?.id || null); setCurrentMs(initial?.decision_ms || 0); setTranscriptDraft(null);
};
const changeDatasetScope = (scope: string) => {
setDatasetScope(scope); setSearch(""); setFilter("all");
@@ -173,7 +175,7 @@ export default function Workbench() {
if (!selected || !recording || !audioRef.current) { notify("关联录音后即可回听这个判断点", "info"); return; }
const audio = audioRef.current; const start = future ? selected.decision_ms : Math.max(0, selected.decision_ms - 4000);
audio.currentTime = Math.min(start, durationMs) / 1000; setCurrentMs(start); playBoundary.current = future ? Math.min(durationMs, start + 4000) : selected.decision_ms;
if (future) setShowFuture(true); try { await audio.play(); } catch { notify("录音暂时无法播放", "error"); }
try { await audio.play(); } catch { notify("录音暂时无法播放", "error"); }
};
useEffect(() => {
if (!playing) return; let frame = 0;
@@ -188,37 +190,86 @@ export default function Workbench() {
const controller = new AbortController(); let context: AudioContext | null = null;
void (async () => {
try { const response = await fetch(recording.url, { signal: controller.signal }); if (!response.ok) throw new Error("无法读取录音"); const bytes = await response.arrayBuffer(); context = new AudioContext(); const buffer = await context.decodeAudioData(bytes); if (controller.signal.aborted) return;
const samples = buffer.getChannelData(0), count = 640; const peaks = Array.from({ length: count }, (_, index) => { const begin = Math.floor(index * samples.length / count), end = Math.floor((index + 1) * samples.length / count); let peak = 0; for (let i = begin; i < end; i++) peak = Math.max(peak, Math.abs(samples[i])); return peak; });
const max = Math.max(.01, ...peaks); setWaveform(peaks.map(peak => peak / max));
const count = 3200;
const channels = Array.from({ length: buffer.numberOfChannels }, (_, channel) => {
const samples = buffer.getChannelData(channel);
return Array.from({ length: count }, (_, index) => {
const begin = Math.floor(index * samples.length / count), end = Math.floor((index + 1) * samples.length / count);
let peak = 0;
for (let i = begin; i < end; i++) peak = Math.max(peak, Math.abs(samples[i]));
return peak;
});
});
// Share the scale across channels so their relative levels remain visible.
const max = channels.reduce((largest, peaks) => Math.max(largest, ...peaks), .01);
setWaveform(channels.map(peaks => peaks.map(peak => peak / max)));
} catch (error) { if (!controller.signal.aborted) notify(error instanceof Error ? error.message : "波形读取失败", "error"); } finally { if (context) void context.close(); }
})(); return () => controller.abort();
}, [recording?.url, notify]);
useEffect(() => { if (audioRef.current) audioRef.current.playbackRate = rate; }, [rate, recording]);
const addAnnotation = useCallback(() => {
if (mode === "view" || loading) return; const time = Math.round(currentMs); const existing = callAnnotations.find(row => row.decision_ms === time);
if (existing) { notify("这个时间点已有标注,已为你选中", "info"); setSelectedId(existing.id); return; }
const row = createAnnotation(callId, time); commit({ transcripts, annotations: [...annotations, row] }); setSelectedId(row.id); setShowFuture(false); notify(`已在 ${formatTime(time)} 新增判断点`);
}, [mode, loading, currentMs, callAnnotations, callId, transcripts, annotations, commit, notify]);
const addAnnotationAt = useCallback((ms: number) => {
if (mode === "view" || loading) return;
setTranscriptDraft(null); setMenu(null);
const time = Math.max(0, Math.min(durationMs, Math.round(ms)));
const existing = callAnnotations.find(row => row.decision_ms === time);
if (existing) { setSelectedId(existing.id); seek(time); notify("这个时间点已有标注,已为你选中", "info"); return; }
const row = createAnnotation(callId, time);
const lastUser = callTranscripts.map(item => item.row).filter(item => item.role === "user" && item.end_ms <= time).at(-1);
row.user_text_at_decision = lastUser?.transcript || "";
commit({ transcripts, annotations: [...annotations, row] }); setSelectedId(row.id); seek(time); notify(`已在 ${formatTime(time)} 新增判断点`);
}, [mode, loading, durationMs, callAnnotations, callId, callTranscripts, transcripts, annotations, commit, notify, seek]);
const moveAnnotation = (id: string, ms: number) => {
if (mode !== "edit") return;
const time = Math.max(0, Math.min(durationMs, Math.round(ms)));
const row = callAnnotations.find(item => item.id === id);
if (!row || row.decision_ms === time) return;
if (callAnnotations.some(item => item.id !== id && item.decision_ms === time)) { notify("这个时间点已有标注,请选择另一个位置", "info"); return; }
updateAnnotation(id, { decision_ms: time });
};
const addAnnotation = useCallback(() => addAnnotationAt(currentMs), [addAnnotationAt, currentMs]);
const setLabel = useCallback((label: Annotation["can_reply_now"]) => {
if (!selected || mode === "view") return; const existing = reasonOptions.find(row => row.value === selected.reason && row.can_reply_now === label);
updateAnnotation(selected.id, { can_reply_now: label, reason: existing?.value || (label === "1" ? "completed_turn" : label === "0" ? "mid_turn_pause" : "uncertain") });
}, [selected, mode, updateAnnotation]);
const undo = useCallback(() => {
const previous = undoStack.at(-1); if (!previous) return;
versionRef.current++; setTranscripts(previous.transcripts); setAnnotations(previous.annotations); setUndoStack(stack => stack.slice(0, -1)); setDirty(true);
const travelHistory = useCallback((direction: "undo" | "redo") => {
if (mode !== "edit" || loading) return;
const current = historyRef.current;
const updated = direction === "undo" ? undoEdit(current) : redoEdit(current);
if (updated === current) return;
historyRef.current = updated; setHistory(updated);
const previous = updated.present;
versionRef.current++; setDirty(true); setSavedTime(null);
setTranscriptDraft(draft => draft && draft.index >= 0 && previous.transcripts[draft.index]
? { index: draft.index, row: { ...previous.transcripts[draft.index] } } : null);
const ids = new Set([...previous.annotations.map(row => row.call_id), ...previous.transcripts.map(row => row.call_id), ...recordings.map(row => row.call_id)]);
const firstInScope = [...ids].find(id => datasetScope === ALL_DATASETS || callDataset(id, recordings, originalGroupsRef.current) === datasetScope);
const nextCall = ids.has(callId) ? callId : firstInScope || previous.annotations[0]?.call_id || previous.transcripts[0]?.call_id || recordings[0]?.call_id || "demo";
const nextSelection = previous.annotations.find(row => row.id === selectedId && row.call_id === nextCall) || previous.annotations.find(row => row.call_id === nextCall);
setSelectedId(nextSelection?.id || null); setShowFuture(false);
setSelectedId(nextSelection?.id || null);
if (nextCall !== callId) {
const nextGroup = callDataset(nextCall, recordings, originalGroupsRef.current);
if (datasetScope !== ALL_DATASETS && datasetScope !== nextGroup) setDatasetScope(nextGroup);
setSearch(""); setFilter("all"); audioRef.current?.pause(); setCallId(nextCall); setCurrentMs(nextSelection?.decision_ms || 0); setAudioDuration(0);
}
notify("已撤销上一步修改", "info");
}, [undoStack, selectedId, callId, recordings, datasetScope, notify]);
notify(direction === "undo" ? "已撤销上一步修改" : "已重做上一步修改", "info");
}, [mode, loading, selectedId, callId, recordings, datasetScope, notify]);
const undo = useCallback(() => travelHistory("undo"), [travelHistory]);
const redo = useCallback(() => travelHistory("redo"), [travelHistory]);
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.isComposing || helpOpen || mode !== "edit" || loading || event.altKey || !(event.ctrlKey || event.metaKey)) return;
const target = event.target;
if (target instanceof HTMLElement && (target.closest("input, textarea, select") || target.isContentEditable)) return;
const key = event.key.toLowerCase();
if (key !== "z" && key !== "y") return;
event.preventDefault();
if (key === "y" || event.shiftKey) redo(); else undo();
};
// Capture only history shortcuts so timeline controls cannot swallow them.
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [undo, redo, mode, loading, helpOpen]);
const saveWorkspace = useCallback(async () => {
if (saving) return; const version = versionRef.current;
try { parseTranscripts(exportTranscripts(transcripts)); parseAnnotations(exportAnnotations(annotations)); if (audioDuration && callAnnotations.some(row => row.decision_ms > audioDuration)) throw new Error("当前判断点超出录音时长,请调整后保存"); setSaving(true);
@@ -228,7 +279,7 @@ export default function Workbench() {
}, [saving, transcripts, annotations, audioDuration, callAnnotations, notify]);
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.defaultPrevented || helpOpen || transcriptDraft) return;
if (event.defaultPrevented || helpOpen) return;
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { event.preventDefault(); void saveWorkspace(); return; }
const target = event.target as HTMLElement; if (target.closest("input, textarea, select, [contenteditable=true]") || event.ctrlKey || event.metaKey || event.altKey) return;
if (event.code === "Space") { if (target.closest("button, a, summary")) return; event.preventDefault(); void togglePlay(); } else if (event.key === "ArrowLeft") { event.preventDefault(); seek(currentMs - 1000); } else if (event.key === "ArrowRight") { event.preventDefault(); seek(currentMs + 1000); }
@@ -246,7 +297,7 @@ export default function Workbench() {
if (initialCall === callId && audioRef.current) audioRef.current.currentTime = 0;
if (initialCall !== callId) setAudioDuration(0);
setCallId(initialCall); setCurrentMs(0); setTranscriptDraft(null); setSelectedId(kind === "eot" ? (rows[0] as Annotation).id : annotations.find(row => row.call_id === initialCall)?.id || null);
} setShowFuture(false); notify(`已导入 ${rows.length} 条${kind === "asr" ? "对话转写" : "EOT 标注"},可撤销或保存到项目`);
} notify(`已导入 ${rows.length} 条${kind === "asr" ? "对话转写" : "EOT 标注"},可撤销或保存到项目`);
} catch (error) { notify(error instanceof Error ? error.message : "导入失败", "error"); }
};
const attachAudio = (event: ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; const url = URL.createObjectURL(file); objectUrls.current.add(url); setRecordings(rows => [...rows.filter(row => row.call_id !== callId), { call_id: callId, name: file.name, path: file.name, url }]); notify(`已关联录音:${file.name}。文件关联保留在当前页面`, "info"); };
@@ -260,7 +311,30 @@ export default function Workbench() {
notify(`已导出${scope === "current" ? datasetScope : "全部数据"}的 ${exportRows.length} 条${kind === "asr" ? "对话转写" : "EOT 标注"}`);
} catch (error) { notify(error instanceof Error ? error.message : "导出失败", "error"); }
};
const saveTranscript = () => { if (!transcriptDraft) return; try { const next = [...transcripts]; if (transcriptDraft.index === -1) next.push(transcriptDraft.row); else next[transcriptDraft.index] = transcriptDraft.row; parseTranscripts(exportTranscripts(next)); commit({ transcripts: next, annotations }); setTranscriptDraft(null); notify("对话片段已更新"); } catch (error) { notify(error instanceof Error ? error.message : "片段无效", "error"); } };
const addTranscriptAt = (role: Transcript["role"]) => {
if (loading || mode === "view") return;
setMenu(null); audioRef.current?.pause();
const start = Math.max(0, Math.min(Math.round(currentMs), durationMs - 1));
setTranscriptDraft({ index: -1, row: { call_id: callId, role, start_ms: start, end_ms: Math.min(durationMs, start + 1000), transcript: "" } });
};
const resizeTranscript = (index: number, startMs: number, endMs: number) => {
if (mode === "view" || !transcripts[index]) return;
if (startMs < 0 || endMs > durationMs || endMs <= startMs) { notify("片段时间必须在录音内,且结束时间晚于开始时间", "error"); return; }
const next = transcripts.map((row, i) => i === index ? { ...row, start_ms: startMs, end_ms: endMs } : row);
commit({ transcripts: next, annotations });
setTranscriptDraft(draft => draft?.index === index ? { ...draft, row: { ...draft.row, start_ms: startMs, end_ms: endMs } } : draft);
};
const saveTranscript = () => {
if (!transcriptDraft || mode === "view") return;
try {
const next = [...transcripts];
if (transcriptDraft.row.end_ms > durationMs) throw new Error("片段结束时间不能超过录音时长");
if (transcriptDraft.index === -1) next.push(transcriptDraft.row); else next[transcriptDraft.index] = transcriptDraft.row;
parseTranscripts(exportTranscripts(next)); commit({ transcripts: next, annotations });
setTranscriptDraft({ ...transcriptDraft, index: transcriptDraft.index === -1 ? next.length - 1 : transcriptDraft.index });
notify("对话片段已更新");
} catch (error) { notify(error instanceof Error ? error.message : "片段无效", "error"); }
};
return <div className="app-shell">
<input ref={asrImportRef} type="file" accept=".csv,text/csv" hidden onChange={event => void importCsv(event, "asr")} />
@@ -282,25 +356,21 @@ export default function Workbench() {
<main className="main-workspace"><div className="page-heading"><div><div className="breadcrumb">工作空间 <span>/</span> {callName}</div><h1>对话标注<span className="offline-badge">离线转写</span></h1><p title={callId}>{callId === "demo" ? "从一段对话开始,判断用户是否已经说完。" : callId}</p></div><div className="view-switch" aria-label="工作模式"><button className={mode === "edit" ? "active" : ""} onClick={() => setMode("edit")}><Icon name="edit" size={15} />标注</button><button className={mode === "view" ? "active" : ""} onClick={() => { setMode("view"); setTranscriptDraft(null); }}>查看</button></div></div>
<section className="audio-card panel"><div className="panel-heading"><div><h2><Icon name="headphones" />录音与时间轴</h2><p className="audio-filename" title={recording?.name}>{recording?.name || "示例尚未关联录音,可先查看转写与标注"}</p></div><button className="button small ghost" onClick={() => audioImportRef.current?.click()}><Icon name="upload" size={15} />{recording ? "更换录音" : "关联录音"}</button></div>
<div className="player"><div className="player-main"><button className="icon-button" aria-label="后退 3 秒" onClick={() => seek(currentMs - 3000)}><Icon name="back" /></button><button className="play-button" aria-label={playing ? "暂停录音" : "播放录音"} onClick={() => void togglePlay()} disabled={!recording}><Icon name={playing ? "pause" : "play"} size={19} /></button><button className="icon-button" aria-label="前进 3 秒" onClick={() => seek(currentMs + 3000)}><Icon name="forward" /></button><span className="time-display">{formatTime(currentMs)}<span> / {formatTime(durationMs)}</span></span></div><div className="player-secondary"><label className="speed-label">播放速度<select aria-label="播放速度" value={rate} onChange={event => setRate(Number(event.target.value))}>{[.75, 1, 1.25, 1.5, 2].map(value => <option key={value} value={value}>{value}×</option>)}</select></label>{mode === "edit" && <button className="button small subtle" onClick={addAnnotation} disabled={loading}><Icon name="plus" size={16} />在播放位置新增</button>}</div></div>
<Timeline durationMs={durationMs} currentMs={currentMs} decisionMs={selected?.decision_ms ?? null} transcripts={callTranscripts.map(({ row }) => ({ ...row, transcript: showFuture || row.end_ms <= boundaryMs ? row.transcript : row.start_ms <= boundaryMs ? "跨越判断点的片段" : "后续片段" }))} annotations={callAnnotations} selectedId={selectedId} waveform={waveform} onSeek={seek} onDecisionChange={ms => {
if (!selected || mode !== "edit") return;
const time = Math.max(0, Math.min(durationMs, Math.round(ms)));
if (callAnnotations.some(row => row.id !== selected.id && row.decision_ms === time)) { notify("这个时间点已有标注,请选择另一个位置", "info"); return; }
updateAnnotation(selected.id, { decision_ms: time });
}} onSelectAnnotation={selectAnnotation} readOnly={mode === "view"} />
<div className="timeline-legend"><span><i className="legend-dot agent" />机器人</span><span><i className="legend-dot user" />用户</span><span><i className="legend-line" />播放位置</span><span><i className="legend-dot decision" />EOT 判断点</span><span className="timeline-tip">{mode === "edit" ? "点击定位 · 拖动橙色判断点调整时间" : "点击时间轴或判断点查看"}</span></div>
<div className="player"><div className="player-main"><button className="icon-button" aria-label="后退 3 秒" onClick={() => seek(currentMs - 3000)}><Icon name="back" /></button><button className="play-button" aria-label={playing ? "暂停录音" : "播放录音"} onClick={() => void togglePlay()} disabled={!recording}><Icon name={playing ? "pause" : "play"} size={19} /></button><button className="icon-button" aria-label="前进 3 秒" onClick={() => seek(currentMs + 3000)}><Icon name="forward" /></button><span className="time-display">{formatTime(currentMs)}<span> / {formatTime(durationMs)}</span></span></div><div className="player-secondary"><label className="speed-label">播放速度<select aria-label="播放速度" value={rate} onChange={event => setRate(Number(event.target.value))}>{[.75, 1, 1.25, 1.5, 2].map(value => <option key={value} value={value}>{value}×</option>)}</select></label>{mode === "edit" && <div className="menu-wrap"><button className="button small subtle" onClick={() => setMenu(menu === "create" ? null : "create")} disabled={loading} aria-expanded={menu === "create"} aria-haspopup="menu"><Icon name="plus" size={16} />在播放位置新增<Icon name="chevron" size={13} /></button>{menu === "create" && <div className="dropdown" role="menu" aria-label="新增项目"><button role="menuitem" onClick={addAnnotation}>EOT 判断点</button><button role="menuitem" onClick={() => addTranscriptAt("agent")}>机器人转写</button><button role="menuitem" onClick={() => addTranscriptAt("user")}>用户转写</button></div>}</div>}</div></div>
<Timeline durationMs={durationMs} currentMs={currentMs} decisionMs={selected?.decision_ms ?? null} transcripts={callTranscripts.map(({ row, index }) => ({ ...row, index }))} annotations={callAnnotations} selectedId={selectedId} waveform={waveform} onSeek={seek} onMoveAnnotation={moveAnnotation} onSelectAnnotation={selectAnnotation} onSelectTranscript={index => {
const row = transcripts[index]; seek(row.start_ms);
audioRef.current?.pause(); setTranscriptDraft({ index, row: { ...row } });
}} onResizeTranscript={resizeTranscript} editingTranscriptIndex={transcriptDraft?.index ?? null} onAddAnnotation={addAnnotationAt} readOnly={mode === "view"} />
<div className="timeline-legend"><span><i className="legend-dot agent" />机器人</span><span><i className="legend-dot user" />用户</span><span><i className="legend-line" />播放位置</span><span><i className="legend-dot decision" />EOT 判断点</span><span className="timeline-tip">{mode === "edit" ? "拖动片段边界 · 点击块在右侧编辑" : "点击时间轴或判断点查看"}</span></div>
</section>
<CallReferencePanel references={callReferences.filter(row => row.call_id === callId)} />
<section className="transcript-panel panel"><div className="panel-heading"><div><h2>对话转写<span className="count-badge">{callTranscripts.length}</span></h2><p>当前展示截至 <strong>{formatTime(boundaryMs)}</strong> 的对话历史</p></div><div className="transcript-actions"><label className="toggle-label"><input type="checkbox" checked={showFuture} onChange={event => setShowFuture(event.target.checked)} /><span className="toggle-track" />显示后续</label>{mode === "edit" && <button className="icon-button" aria-label="新增对话片段" onClick={() => { const start = Math.max(0, Math.min(currentMs, durationMs - 1)); setTranscriptDraft({ index: -1, row: { call_id: callId, role: "user", start_ms: start, end_ms: Math.min(durationMs, start + 1000), transcript: "" } }); }}><Icon name="plus" /></button>}</div></div>
{showFuture && <div className="arbitration-note"><Icon name="help" size={15} />后续对话仅供辅助仲裁,不进入当前判断点的模型输入。</div>}
<div className="transcript-list">{visibleTranscripts.length ? visibleTranscripts.map(({ row, index }) => <div key={index} className={`transcript-row ${row.role} ${row.end_ms > boundaryMs ? "future" : ""} ${currentMs >= row.start_ms && currentMs <= row.end_ms ? "playing-row" : ""}`}><div className={`speaker-avatar ${row.role}`}>{row.role === "agent" ? "机" : "人"}</div><div className="transcript-content"><div className="speaker-meta"><strong>{row.role === "agent" ? "机器人" : "用户"}</strong><button className="transcript-time" onClick={() => seek(row.start_ms)}>{formatTime(row.start_ms)} — {formatTime(row.end_ms)}</button>{row.end_ms > boundaryMs && <span className="future-tag">判断之后</span>}</div><p>{row.transcript || <span className="muted">(暂无转写)</span>}</p></div>{mode === "edit" && <div className="row-tools"><button className="icon-button" aria-label={`编辑 ${formatTime(row.start_ms)} 对话片段`} onClick={() => setTranscriptDraft({ index, row: { ...row } })}><Icon name="edit" size={15} /></button>{row.role === "user" && row.end_ms <= boundaryMs && selected && <button className="text-insert" title="将这段文本填入当前标注" onClick={() => updateAnnotation(selected.id, { user_text_at_decision: row.transcript })}>填入</button>}</div>}</div>) : <div className="empty-transcript"><Icon name="file" size={28} /><h3>这里还没有判断前的转写</h3><p>导入对话转写 CSV,或添加并校对语音片段。</p>{mode === "edit" && <button className="button small ghost" onClick={() => asrImportRef.current?.click()}>导入转写</button>}</div>}</div>
{!showFuture && hiddenCount > 0 && <button className="hidden-history" onClick={() => setShowFuture(true)}><Icon name="clock" size={15} />{hiddenCount} 个后续或跨越判断点的片段已隐藏<span>展开辅助仲裁 →</span></button>}
{selected && <div className="current-text-preview"><span>当前用户文本</span><p>{selected.user_text_at_decision || "尚未填写,请按判断时刻的录音核对。"}</p><small>{formatTime(selected.decision_ms)} 时已经说出的内容</small></div>}
</section><div className="workspace-footer"><span><Icon name="check" size={14} />转写与标注使用同一录音时间轴</span><button onClick={undo} disabled={!undoStack.length || mode === "view"}><Icon name="undo" size={14} />撤销上一步</button></div>
<div className="workspace-footer"><span><Icon name="check" size={14} />转写与标注使用同一录音时间轴</span><div className="history-actions"><button onClick={undo} disabled={!history.past.length || mode === "view" || loading} title="撤销上一步(Ctrl / ⌘ + Z)" aria-keyshortcuts="Control+Z Meta+Z"><Icon name="undo" size={14} />撤销上一步</button><button onClick={redo} disabled={!history.future.length || mode === "view" || loading} title="重做上一步(Ctrl + Y / ⌘ + Shift + Z)" aria-keyshortcuts="Control+Y Meta+Y Control+Shift+Z Meta+Shift+Z"><Icon name="redo" size={14} />重做上一步</button></div></div>
</main>
<aside className="annotation-sidebar"><div className="editor-heading"><div><span className="eyebrow">{mode === "view" ? "标注详情" : "当前标注"}</span><h2>这个位置,可以接话吗?</h2></div><span className="editor-number">{selected ? String(callAnnotations.findIndex(row => row.id === selected.id) + 1).padStart(2, "0") : "—"}</span></div>
{transcriptDraft ? <TranscriptEditor draft={transcriptDraft} readOnly={mode === "view"}
onChange={row => setTranscriptDraft({ ...transcriptDraft, row })} onSave={saveTranscript} onClose={() => setTranscriptDraft(null)}
onDelete={() => { commit({ transcripts: transcripts.filter((_, index) => index !== transcriptDraft.index), annotations }); setTranscriptDraft(null); notify("已删除片段,可撤销", "info"); }}
/> : <aside className="annotation-sidebar"><div className="editor-heading"><div><span className="eyebrow">{mode === "view" ? "标注详情" : "当前标注"}</span><h2>这个位置,可以接话吗?</h2></div><span className="editor-number">{selected ? String(callAnnotations.findIndex(row => row.id === selected.id) + 1).padStart(2, "0") : "—"}</span></div>
{selected ? <div className="editor-body"><div className="decision-card"><span><i className="legend-dot decision" />判断时间</span><strong>{formatTime(selected.decision_ms)}</strong><div className="review-actions"><button disabled={!recording} onClick={() => void review(false)}><Icon name="back" size={14} />听判断前</button><button disabled={!recording} onClick={() => void review(true)}><Icon name="forward" size={14} />听后续</button></div></div>
<fieldset className="label-fieldset" disabled={mode === "view"}><legend>用户是否已完成当前表达?</legend>{([{ value: "1", title: "可以接话", detail: "当前表达已完成", icon: "check", number: "1", cls: "positive" }, { value: "0", title: "继续等待", detail: "现在接话会打断用户", icon: "clock", number: "2", cls: "negative" }, { value: "", title: "需要仲裁", detail: "证据不足,暂不确定", icon: "help", number: "3", cls: "pending" }] as const).map(choice => <button key={choice.number} className={`label-choice ${choice.cls} ${selected.can_reply_now === choice.value ? "active" : ""}`} aria-pressed={selected.can_reply_now === choice.value} onClick={() => setLabel(choice.value)}><span className="choice-symbol"><Icon name={choice.icon} size={18} /></span><span><strong>{choice.title}</strong><small>{choice.detail}</small></span><kbd>{choice.number}</kbd></button>)}</fieldset>
<label className="form-field"><span>判断时的用户文本 <small>截至当前橙色判断点</small></span><textarea aria-label="判断时的用户文本" value={selected.user_text_at_decision} placeholder="例如:我明天下午…" readOnly={mode === "view"} onChange={event => updateAnnotation(selected.id, { user_text_at_decision: event.target.value })} rows={3} /><small>只填写当时已说出的内容,可保留未完成的表达。</small></label>
@@ -308,12 +378,10 @@ export default function Workbench() {
<div className="form-two"><label className="form-field"><span>标注把握</span><select aria-label="标注把握" disabled={mode === "view"} value={selected.confidence} onChange={event => updateAnnotation(selected.id, { confidence: event.target.value as Annotation["confidence"] })}><option value="">未填写</option><option value="high">高</option><option value="medium">中</option><option value="low">低</option></select></label><label className="form-field"><span>标注员</span><input aria-label="标注员" readOnly={mode === "view"} placeholder="姓名或 ID" value={selected.annotator} onChange={event => updateAnnotation(selected.id, { annotator: event.target.value })} /></label></div>
{selectedError && <p className="field-error" role="alert">{selectedError}</p>}<div className="editor-bottom"><span><span className={`mini-dot ${labelClass(selected)}`} />{labelName(selected)}</span>{mode === "edit" && <button className="delete-button" onClick={() => { commit({ transcripts, annotations: annotations.filter(row => row.id !== selected.id) }); setSelectedId(callAnnotations.find(row => row.id !== selected.id)?.id || null); notify("已删除判断点,可撤销", "info"); }}><Icon name="trash" size={15} />删除</button>}</div>
</div> : <div className="empty-editor"><span className="empty-marker"><Icon name="plus" size={26} /></span><h3>创建第一个判断点</h3><p>在时间轴上定位,再新增标注,记录此刻是否适合接话。</p>{mode === "edit" && <button className="button primary" onClick={addAnnotation} disabled={loading}><Icon name="plus" />新增判断点</button>}</div>}
<div className="annotation-list-section"><div className="list-heading"><h3>本通话判断点<span>{callAnnotations.length}</span></h3>{mode === "edit" && <button className="icon-button" aria-label="新增判断点" onClick={addAnnotation} disabled={loading}><Icon name="plus" size={16} /></button>}</div><div className="annotation-list">{callAnnotations.map(row => <button key={row.id} className={`annotation-item ${row.id === selectedId ? "selected" : ""}`} onClick={() => selectAnnotation(row.id)}><span className={`mini-dot ${labelClass(row)}`} /><div><strong>{formatTime(row.decision_ms)}</strong><small>{row.user_text_at_decision || "尚未填写用户文本"}</small></div><span className={`label-pill ${labelClass(row)}`}>{labelName(row)}</span></button>)}{!callAnnotations.length && <p className="muted">暂无判断点</p>}</div></div>
</aside></div>
</aside>}</div>
<footer className="keyboard-footer"><span>EOT Studio <i />让每一次接话更自然</span><div><span><kbd>空格</kbd> 播放 / 暂停</span><span><kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> 标签</span><span><kbd>[</kbd><kbd>]</kbd> 切换判断点</span><button onClick={() => setHelpOpen(true)} aria-label="查看快捷键"><Icon name="help" size={15} /></button></div></footer>
{notice && <div className={`toast ${notice.kind}`} role={notice.kind === "error" ? "alert" : "status"}><Icon name={notice.kind === "success" ? "check" : "help"} /><span>{notice.message}</span><button aria-label="关闭消息" onClick={() => setNotice(null)}><Icon name="close" size={15} /></button></div>}
{menu && <button className="menu-scrim" aria-label="关闭菜单" onClick={() => setMenu(null)} />}
{transcriptDraft && <div className="modal-backdrop" onClick={() => setTranscriptDraft(null)}><section className="modal" role="dialog" aria-modal="true" aria-labelledby="transcript-dialog-title" onClick={event => event.stopPropagation()}><div className="modal-heading"><h2 id="transcript-dialog-title">{transcriptDraft.index === -1 ? "新增" : "编辑"}对话片段</h2><button className="icon-button" aria-label="关闭片段编辑" onClick={() => setTranscriptDraft(null)}><Icon name="close" /></button></div><label className="form-field"><span>说话人</span><select value={transcriptDraft.row.role} onChange={event => setTranscriptDraft({ ...transcriptDraft, row: { ...transcriptDraft.row, role: event.target.value as Transcript["role"] } })}><option value="user">用户</option><option value="agent">机器人</option></select></label><div className="form-two"><label className="form-field"><span>开始时间(毫秒)</span><input type="number" min="0" value={transcriptDraft.row.start_ms} onChange={event => setTranscriptDraft({ ...transcriptDraft, row: { ...transcriptDraft.row, start_ms: Number(event.target.value) } })} /></label><label className="form-field"><span>结束时间(毫秒)</span><input type="number" min="1" value={transcriptDraft.row.end_ms} onChange={event => setTranscriptDraft({ ...transcriptDraft, row: { ...transcriptDraft.row, end_ms: Number(event.target.value) } })} /></label></div><label className="form-field"><span>转写内容</span><textarea rows={4} value={transcriptDraft.row.transcript} onChange={event => setTranscriptDraft({ ...transcriptDraft, row: { ...transcriptDraft.row, transcript: event.target.value } })} autoFocus /></label><div className="modal-actions">{transcriptDraft.index >= 0 && <button className="delete-button" onClick={() => { commit({ transcripts: transcripts.filter((_, index) => index !== transcriptDraft.index), annotations }); setTranscriptDraft(null); notify("已删除片段,可撤销", "info"); }}><Icon name="trash" />删除片段</button>}<button className="button ghost" onClick={() => setTranscriptDraft(null)}>取消</button><button className="button primary" onClick={saveTranscript}>保存片段</button></div></section></div>}
{helpOpen && <div className="modal-backdrop" onClick={() => setHelpOpen(false)}><section className="modal help-modal" role="dialog" aria-modal="true" aria-labelledby="help-title" onClick={event => event.stopPropagation()}><div className="modal-heading"><h2 id="help-title">把判断留在正确的时刻</h2><button className="icon-button" aria-label="关闭说明" onClick={() => setHelpOpen(false)}><Icon name="close" /></button></div><p>橙色标记表示 EOT 判断时间,黑色细线表示播放位置。听判断之前的录音,再回答“机器人现在接话是否合适”。</p><ol><li><strong>可以接话:</strong>用户已完成当前表达,或已回答机器人问题。</li><li><strong>继续等待:</strong>用户只是停顿、犹豫或表达未完成。</li><li><strong>需要仲裁:</strong>录音或上下文不足,保留空标签。</li></ol><p>当前用户文本只写到橙色判断点。后续录音可辅助仲裁,展开的后续转写不会自动写入当前文本。</p><div className="shortcut-grid"><span><kbd>空格</kbd>播放 / 暂停</span><span><kbd>A</kbd>新增判断点</span><span><kbd>←</kbd><kbd>→</kbd>播放位置前后 1 秒</span><span><kbd>1</kbd><kbd>2</kbd><kbd>3</kbd>选择标签</span><span><kbd>[</kbd><kbd>]</kbd>上 / 下一个判断点</span><span><kbd>⌘ / Ctrl + S</kbd>保存到项目</span></div><p className="muted">编辑会保留为浏览器草稿。“保存到项目”写入工作数据目录;“导出”下载标准 CSV。上传录音的关联在页面关闭后需重新建立。</p><button className="button primary" onClick={() => setHelpOpen(false)}>开始标注</button></section></div>}
{helpOpen && <div className="modal-backdrop" onClick={() => setHelpOpen(false)}><section className="modal help-modal" role="dialog" aria-modal="true" aria-labelledby="help-title" onClick={event => event.stopPropagation()}><div className="modal-heading"><h2 id="help-title">把判断留在正确的时刻</h2><button className="icon-button" aria-label="关闭说明" onClick={() => setHelpOpen(false)}><Icon name="close" /></button></div><p>橙色标记表示 EOT 判断时间,黑色细线表示播放位置。听判断之前的录音,再回答“机器人现在接话是否合适”。</p><ol><li><strong>可以接话:</strong>用户已完成当前表达,或已回答机器人问题。</li><li><strong>继续等待:</strong>用户只是停顿、犹豫或表达未完成。</li><li><strong>需要仲裁:</strong>录音或上下文不足,保留空标签。</li></ol><p>当前用户文本只写到橙色判断点。时间轴展示完整对话用于校对,后续转写不会自动写入当前文本。</p><div className="shortcut-grid"><span><kbd>空格</kbd>播放 / 暂停</span><span><kbd>A</kbd>新增判断点</span><span><kbd>←</kbd><kbd>→</kbd>播放位置前后 1 秒</span><span><kbd>1</kbd><kbd>2</kbd><kbd>3</kbd>选择标签</span><span><kbd>[</kbd><kbd>]</kbd>上 / 下一个判断点</span><span><kbd>⌘ / Ctrl + S</kbd>保存到项目</span><span><kbd>⌘ / Ctrl + Z</kbd>撤销上一步</span><span><kbd>Ctrl + Y</kbd>重做上一步</span></div><p className="muted">最近 40 步修改可撤销和重做;撤销后作新修改会清空重做记录。文本输入框内保留原生文字撤销。Mac 也支持 ⌘ + Shift + Z 重做。刷新页面会清空撤销记录。编辑会保留为浏览器草稿。“保存到项目”写入工作数据目录;“导出”下载标准 CSV。上传录音的关联在页面关闭后需重新建立。</p><button className="button primary" onClick={() => setHelpOpen(false)}>开始标注</button></section></div>}
</div>;
}
@@ -1,21 +0,0 @@
.panel { margin-top: 18px; overflow: hidden; }
.heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 16px 19px 12px; }
.heading h2 { margin: 0 0 4px; color: #52663f; font-size: 14px; }
.heading p { margin: 0; color: #748167; font-size: 11px; }
.heading h2 { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
.details > summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; cursor: pointer; padding: 0 19px 15px; color: #597448; font-size: 12px; }
.details > summary::after { content: "+"; font-size: 18px; }
.details[open] > summary::after { content: "−"; }
.details > summary > span { margin-left: auto; color: #748167; font-size: 11px; }
.note { margin: 0 19px 12px; padding: 9px 12px; background: #faf5eb; border: 1px solid #eadbc5; border-radius: 7px; color: #89704e; font-size: 11px; }
.list { max-height: 380px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #d3ddc6 transparent; }
.record { padding: 13px 19px 16px; border-top: 1px solid #e8ecdf; }
.recordHeading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; color: #748167; font-size: 11px; }
.recordHeading strong { font-weight: 600; color: #647a50; }
.fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 24px; margin: 0; }
.fields > div { min-width: 0; }
.wide { grid-column: 1 / -1; }
.fields dt { margin-bottom: 4px; color: #748167; font-size: 11px; }
.fields dd { margin: 0; color: #4e603f; font-size: 13px; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; }
.source { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 4px 14px; padding: 10px 19px; border-top: 1px solid #e8ecdf; background: #fafbf5; color: #748167; font-size: 11px; overflow-wrap: anywhere; }
@media (max-width: 600px) { .fields { grid-template-columns: minmax(0, 1fr); } }
@@ -1,33 +0,0 @@
"use client";
import type { CallReference } from "@/lib/call-reference";
import styles from "./call-reference.module.css";
export default function CallReferencePanel({ references }: { references: CallReference[] }) {
if (!references.length) return null;
const source = references[0].source;
const sourceName = source.file.split("/").at(-1);
return <section className={`panel ${styles.panel}`} aria-label="原表校对参考">
<div className={styles.heading}>
<div><h2>原表校对参考 <span className="count-badge">{references.length} 个片段</span></h2><p>原表片段没有时间对齐,需回听录音定位后填写 EOT。</p></div>
</div>
<details className={styles.details} key={references[0].call_id}>
<summary>查看这通录音的原表片段 <span>供回听核对</span></summary>
<p className={styles.note}>原表的抢话结论保留作参考。第 5 列没有表头,有值时按原样显示;EOT 标签需针对具体判断时刻重新核对。</p>
<div className={styles.list}>
{references.map((reference, index) => <article key={`${reference.source.sheet}-${reference.source.row}`} className={styles.record}>
<div className={styles.recordHeading}><strong>片段 {String(index + 1).padStart(2, "0")}</strong><span>原表第 {reference.source.row} 行</span></div>
<dl className={styles.fields}>
{reference.fields.map((field, fieldIndex) => <div key={fieldIndex} className={field.label.includes("说话") || field.value.length > 40 ? styles.wide : undefined}>
<dt>{field.label}</dt><dd>{field.value || "未填写"}</dd>
</div>)}
</dl>
</article>)}
</div>
</details>
<div className={styles.source}>
<span>{sourceName}</span><span>{source.sheet} · 第 {references.map(row => row.source.row).join("、")} 行</span>
</div>
</section>;
}
@@ -14,7 +14,7 @@
padding: 18px 22px 15px;
}
.toolbarTitle, .tools, .zoom, .bottomBar, .timeReadout, .decisionControls, .decisionControls > label {
.toolbarTitle, .tools, .zoom, .bottomBar, .timeReadout {
display: flex;
align-items: center;
}
@@ -33,68 +33,67 @@
.zoom button:not(:disabled):hover { background: #edf0e6; }
.zoom > span { width: 30px; text-align: center; font-size: 11px; color: #646d5c; }
.trackLayout { display: grid; grid-template-columns: 88px minmax(0, 1fr); border-block: 1px solid #ecede5; }
.trackLayout { display: grid; grid-template-columns: 98px minmax(0, 1fr); border-block: 1px solid #ecede5; }
.trackNames { background: #fbfcf7; border-right: 1px solid #e9ebe2; font-size: 11px; color: #828b7b; user-select: none; }
.trackNames > div { padding-left: 21px; display: flex; align-items: center; gap: 7px; border-bottom: 1px solid #eceee6; }
.rulerName { height: 32px; font-size: 10px; }
.waveName { height: 76px; }
.speechName { height: 48px; }
.speechName { height: 64px; }
.markerName { height: 44px; border-bottom: 0 !important; }
.agentDot, .userDot, .playheadDot, .decisionDot { display: inline-block; flex-shrink: 0; width: 6px; height: 6px; border-radius: 50%; }
.agentDot, .userDot, .playheadDot { display: inline-block; flex-shrink: 0; width: 6px; height: 6px; border-radius: 50%; }
.agentDot { background: #b2bca8; }
.userDot { background: #d2b48d; }
.playheadDot { background: #566653; }
.decisionDot { background: #d66d40; }
.viewport { overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; scrollbar-color: #cbd1c0 #f8faf2; }
.viewport::-webkit-scrollbar { height: 7px; }
.viewport::-webkit-scrollbar-thumb { background: #cbd1c0; border-radius: 4px; }
.viewport::-webkit-scrollbar-track { background: #f8faf2; }
.canvas { width: var(--timeline-width); height: 248px; position: relative; outline: none; touch-action: pan-y; cursor: crosshair; }
.canvas { width: var(--timeline-width); position: relative; outline: none; touch-action: pan-y; cursor: crosshair; }
.canvas:focus-visible { box-shadow: inset 0 0 0 2px #9aa987; }
.ruler { position: relative; height: 32px; background: #fbfcf7; border-bottom: 1px solid #eceee6; overflow: hidden; }
.tick { position: absolute; top: 0; height: 32px; padding-top: 8px; padding-left: 6px; color: #8b9384; font-family: var(--font-geist-mono, monospace); font-size: 10px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.tick::after { content: ""; position: absolute; left: 0; bottom: 0; width: 1px; height: 5px; background: #cbd1c1; }
.waveTrack { height: 76px; position: relative; background: #fdfef9; border-bottom: 1px solid #eceee6; }
.waveform { display: block; width: 100%; height: 76px; color: #a3ad98; pointer-events: none; }
.waveform { display: block; width: 100%; height: 76px; color: inherit; pointer-events: none; }
.waveEmpty { position: sticky; left: 0; height: 100%; display: flex; align-items: center; justify-content: center; gap: 9px; color: #929b88; font-size: 11px; }
.waveEmpty svg { color: #b2baa9; }
.speechTrack { position: relative; height: 48px; border-bottom: 1px solid #eceee6; background: #fffefb; }
.speechBlock { position: absolute; top: 9px; height: 29px; overflow: hidden; border: 1px solid; border-radius: 5px; transition: filter .12s; }
.speechTrack { position: relative; height: 64px; border-bottom: 1px solid #eceee6; background: #fffefb; }
.speechBlock { position: absolute; top: 10px; height: 44px; border: 1px solid; border-radius: 5px; transition: filter .12s; }
.speechBlock:hover { filter: brightness(.96); }
.speechBlock > span { display: block; overflow: hidden; padding: 6px 9px; white-space: nowrap; text-overflow: ellipsis; font-size: 11px; line-height: 15px; }
.blockBody { width: 100%; height: 100%; padding: 0 5px; border: 0; border-radius: inherit; background: transparent; color: inherit; text-align: left; cursor: pointer; overflow: hidden; }
.blockBody > span { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 11px; line-height: 15px; }
.resizeHandle { position: absolute; z-index: 4; top: 0; bottom: 0; width: 9px; padding: 0; border: 0; background: transparent; cursor: ew-resize; touch-action: none; }
.resizeHandle::after { content: ""; position: absolute; width: 2px; top: 8px; bottom: 8px; left: 3px; border-radius: 2px; background: currentColor; opacity: .6; }
.startHandle { left: -4px; }
.endHandle { right: -4px; }
.resizeHandle:hover::after, .resizeHandle:focus-visible::after { opacity: 1; width: 3px; }
.resizeHandle:focus-visible { outline: 2px solid #758962; border-radius: 4px; }
.resizingBlock, .editingBlock { box-shadow: 0 0 0 2px #96a983; z-index: 5; }
.resizeReadout { position: absolute; bottom: calc(100% + 5px); left: 50%; transform: translateX(-50%); padding: 4px 6px; color: #fff; background: #526a42; border-radius: 4px; font-size: 10px; white-space: nowrap; pointer-events: none; }
.agentBlock { background: #eef2e5; border-color: #dfe6d2; color: #697854; }
.userBlock { background: #f8f0e5; border-color: #eadcc6; color: #8d7353; }
.annotationTrack { height: 44px; position: relative; background: #fbfcf7; }
.annotation { z-index: 4; position: absolute; top: 13px; transform: translateX(-50%); display: grid; place-items: center; width: 22px; height: 23px; border-radius: 5px; border: 1px solid; cursor: pointer; font-family: var(--font-geist-mono, monospace); font-size: 11px; font-weight: 650; }
.annotationTrack:focus-visible, .speechBlock:focus-visible { outline: 2px solid #758962; outline-offset: -2px; }
.annotation { z-index: 4; position: absolute; top: 13px; transform: translateX(-50%); display: grid; place-items: center; width: 22px; height: 23px; border-radius: 5px; border: 1px solid; cursor: ew-resize; touch-action: none; font-family: var(--font-geist-mono, monospace); font-size: 11px; font-weight: 650; }
.annotation:hover { filter: brightness(.94); }
.annotationStem { position: absolute; width: 1px; height: 11px; top: -12px; left: 50%; background: currentColor; opacity: .4; }
.positive { background: #e8efdb; color: #62764a; border-color: #d4dfc2; }
.negative { background: #f6e4d9; color: #ab7150; border-color: #edcfbc; }
.pending { background: #f0f1eb; color: #87917c; border-color: #dce0d3; }
.selectedLabel { position: absolute; z-index: 3; top: 13px; transform: translateX(-50%); padding: 4px 9px; border: 1px solid #efc7b4; border-radius: 5px; background: #fcf0e8; color: #b86038; font-size: 10px; white-space: nowrap; }
.playhead { position: absolute; z-index: 5; top: 25px; bottom: 0; width: 1px; background: #6b7a5c; pointer-events: none; }
.playhead > span { position: absolute; top: -5px; left: -3px; width: 7px; height: 7px; border-radius: 50%; background: #6b7a5c; }
.selectedLabel { position: absolute; z-index: 3; top: 13px; transform: translateX(-50%); padding: 4px 9px; border: 1px solid #efc7b4; border-radius: 5px; background: #fcf0e8; color: #b86038; font-size: 10px; white-space: nowrap; cursor: ew-resize; touch-action: none; }
.playhead { position: absolute; z-index: 7; top: 25px; bottom: 0; width: 1px; background: #6b7a5c; pointer-events: none; }
.playheadHandle { position: absolute; top: -6px; left: -10px; width: 21px; height: 18px; padding: 0; border: 0; background: transparent; cursor: ew-resize; pointer-events: auto; touch-action: none; }
.playheadHandle > span { display: block; margin: auto; width: 10px; height: 9px; background: #526a42; clip-path: polygon(0 0, 100% 0, 50% 100%); }
.playheadHandle:focus-visible { outline: 2px solid #526a42; border-radius: 3px; }
.snapGuide { position: absolute; z-index: 5; top: 32px; bottom: 0; width: 2px; background: #78aa88; box-shadow: 0 0 0 2px #78aa8820; pointer-events: none; }
.decisionMarker { position: absolute; z-index: 6; top: 0; bottom: 0; width: 0; pointer-events: none; }
.decisionLine { position: absolute; top: 23px; bottom: 0; left: -1px; border-left: 1.5px dashed #d66d40; }
.decisionHandle { position: absolute; left: -14px; top: 1px; display: grid; place-items: center; width: 28px; height: 30px; padding: 0; border: 0; background: transparent; cursor: ew-resize; pointer-events: auto; touch-action: none; }
.decisionHandle > span { width: 14px; height: 19px; background: #d66d40; clip-path: polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%); border-radius: 2px; transition: transform .15s; }
.decisionHandle:hover > span { transform: scale(1.13); }
.decisionHandle:focus-visible { outline: 2px solid #b95b32; outline-offset: 1px; border-radius: 4px; background: #faeadf; }
.annotation:focus-visible, .tools button:focus-visible { outline: 2px solid #758962; outline-offset: 3px; }
.annotation:focus-visible, .selectedLabel:focus-visible, .tools button:focus-visible { outline: 2px solid #758962; outline-offset: 3px; }
.bottomBar { justify-content: space-between; gap: 12px; padding: 11px 22px 7px; }
.bottomBar { justify-content: space-between; gap: 12px; padding: 11px 22px 14px; }
.timeReadout { gap: 6px; color: #8b9382; font-size: 10px; }
.timeReadout strong { margin-left: 3px; font-family: var(--font-geist-mono, monospace); color: #66735b; font-size: 11px; font-weight: 500; font-variant-numeric: tabular-nums; }
.help { font-size: 10px; color: #9aa18f; }
.decisionControls { gap: 12px; padding: 5px 22px 15px; }
.decisionControls > label:first-child { gap: 6px; color: #947158; font-size: 10px; white-space: nowrap; }
.decisionControls > input { flex: 1; min-width: 40px; height: 16px; accent-color: #d66d40; cursor: pointer; }
.decisionControls > input:disabled { opacity: .35; cursor: default; }
.numberInput { width: 99px; gap: 4px; padding: 4px 7px; border: 1px solid #e7e8df; border-radius: 6px; background: #fcfcf7; }
.numberInput:focus-within { border-color: #d6a58b; box-shadow: 0 0 0 2px #f9eadf; }
.numberInput input { width: 100%; min-width: 0; border: 0; background: transparent; outline: none; color: #8c6952; text-align: right; font-family: var(--font-geist-mono, monospace); font-size: 11px; -moz-appearance: textfield; }
.numberInput input::-webkit-inner-spin-button, .numberInput input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.numberInput span { color: #a6ad9d; font-size: 10px; }
@media (max-width: 640px) {
.toolbar { gap: 7px; padding: 14px 12px 12px; }
@@ -103,14 +102,18 @@
.duration { font-size: 10px; }
.tools { gap: 6px; }
.locate { padding: 0; font-size: 10px !important; }
.trackLayout { grid-template-columns: 65px minmax(0, 1fr); }
.trackLayout { grid-template-columns: 82px minmax(0, 1fr); }
.trackNames > div { padding-left: 10px; gap: 5px; font-size: 10px; }
.bottomBar { padding-inline: 12px; }
.help { max-width: 160px; text-align: right; font-size: 9px; }
.decisionControls { gap: 8px; padding-inline: 12px; }
.numberInput { width: 80px; }
}
@media (prefers-reduced-motion: reduce) {
.speechBlock, .decisionHandle > span { transition: none; }
.speechBlock { transition: none; }
}
.leftChannel { color: #6c917c; background: #f6faf5; }
.rightChannel { color: #658cab; background: #f4f8fb; }
.leftDot, .rightDot { flex-shrink: 0; width: 6px; height: 6px; border-radius: 50%; }
.leftDot { background: #6c917c; }
.rightDot { background: #658cab; }
+208 -118
View File
@@ -11,8 +11,10 @@ import {
type PointerEvent,
} from "react";
import styles from "./timeline.module.css";
import { resizeTranscriptRange, snapTimelineTime, type SnapPoint } from "@/lib/timeline-edit";
export interface TimelineTranscript {
index: number;
role: "agent" | "user";
start_ms: number;
end_ms: number;
@@ -33,11 +35,15 @@ export interface TimelineProps {
transcripts: TimelineTranscript[];
annotations: TimelineAnnotation[];
selectedId: string | null;
waveform: number[] | null;
editingTranscriptIndex: number | null;
waveform: number[][] | null;
readOnly?: boolean;
onSeek: (ms: number) => void;
onDecisionChange: (ms: number) => void;
onSelectAnnotation: (id: string) => void;
onMoveAnnotation: (id: string, ms: number) => void;
onSelectAnnotation: (id: string, seekToPoint?: boolean) => void;
onSelectTranscript: (index: number) => void;
onResizeTranscript: (index: number, startMs: number, endMs: number) => void;
onAddAnnotation: (ms: number) => void;
}
const ZOOM_LEVELS = [1, 2, 4, 8];
@@ -66,20 +72,32 @@ export default function Timeline({
transcripts,
annotations,
selectedId,
editingTranscriptIndex,
waveform,
readOnly = false,
onSeek,
onDecisionChange,
onMoveAnnotation,
onSelectAnnotation,
onSelectTranscript,
onResizeTranscript,
onAddAnnotation,
}: TimelineProps) {
const helpId = useId();
const rangeId = useId();
const viewportRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef<{ kind: "seek" | "decision"; pointerId: number } | null>(null);
const draggingRef = useRef<
| { kind: "seek"; pointerId: number }
| { kind: "decision"; pointerId: number; id: string; start: number; next: number }
| { kind: "resize"; pointerId: number; index: number; edge: "start" | "end"; start: number; end: number; nextStart: number; nextEnd: number }
| null
>(null);
const zoomAnchorRef = useRef<number | null>(null);
const suppressAnnotationClickRef = useRef<string | null>(null);
const [viewportWidth, setViewportWidth] = useState(800);
const [zoom, setZoom] = useState(1);
const [resizePreview, setResizePreview] = useState<{ index: number; start_ms: number; end_ms: number } | null>(null);
const [snappedPoint, setSnappedPoint] = useState<SnapPoint | null>(null);
const [decisionPreview, setDecisionPreview] = useState<{ id: string; ms: number } | null>(null);
const duration = Math.max(
1_000,
@@ -91,9 +109,17 @@ export default function Timeline({
const canvasWidth = Math.max(360, viewportWidth) * zoom;
const clamp = (value: number) => Math.round(Math.max(0, Math.min(duration, finite(value))));
const current = clamp(currentMs);
const decision = decisionMs === null ? null : clamp(decisionMs);
const decision = decisionPreview?.id === selectedId ? decisionPreview.ms : decisionMs === null ? null : clamp(decisionMs);
const position = (ms: number) => (clamp(ms) / duration) * canvasWidth;
const selectedAnnotation = annotations.find((item) => item.id === selectedId);
const activeSnap = snappedPoint && (resizePreview || decisionPreview || current === snappedPoint.ms || decision === snappedPoint.ms) ? snappedPoint : null;
const displayedTranscripts = transcripts.map(item => resizePreview?.index === item.index ? { ...item, ...resizePreview } : item);
const snapPoints = useMemo(() => [
...annotations.map(item => ({ ms: item.decision_ms, label: "EOT 判断点", annotationId: item.id })),
...transcripts.flatMap(item => [
{ ms: item.start_ms, label: `${item.role === "agent" ? "机器人" : "用户"}片段起点`, index: item.index },
{ ms: item.end_ms, label: `${item.role === "agent" ? "机器人" : "用户"}片段终点`, index: item.index },
]),
], [annotations, transcripts]);
useEffect(() => {
const viewport = viewportRef.current;
@@ -119,23 +145,23 @@ export default function Timeline({
return result;
}, [duration, canvasWidth]);
const waveformPath = useMemo(() => {
if (!waveform?.length) return "";
const waveformPaths = useMemo(() => (waveform || []).map(channel => {
if (!channel.length) return "";
// Each visible bar is the maximum of a real sample bucket. No generated peaks.
const barCount = Math.min(waveform.length, Math.ceil(canvasWidth / 3), 3_200);
const bucketSize = waveform.length / barCount;
const barCount = Math.min(channel.length, Math.ceil(canvasWidth / 3), 3_200);
const bucketSize = channel.length / barCount;
let path = "";
for (let bar = 0; bar < barCount; bar++) {
const start = Math.floor(bar * bucketSize);
const end = Math.min(waveform.length, Math.ceil((bar + 1) * bucketSize));
const end = Math.min(channel.length, Math.ceil((bar + 1) * bucketSize));
let peak = 0;
for (let index = start; index < end; index++) peak = Math.max(peak, Math.min(1, Math.abs(finite(waveform[index]))));
for (let index = start; index < end; index++) peak = Math.max(peak, Math.min(1, Math.abs(finite(channel[index]))));
const x = ((bar + 0.5) / barCount) * canvasWidth;
const halfHeight = Math.max(0.75, peak * 28);
path += `M${x.toFixed(2)},${(38 - halfHeight).toFixed(2)}v${(halfHeight * 2).toFixed(2)}`;
}
return path;
}, [waveform, canvasWidth]);
}), [waveform, canvasWidth]);
function changeZoom(nextZoom: number) {
const viewport = viewportRef.current;
@@ -148,29 +174,69 @@ export default function Timeline({
viewportRef.current.scrollTo({ left: position(decision) - viewportWidth / 2, behavior: "smooth" });
}
function pointerTime(event: PointerEvent<HTMLElement>) {
function pointerTime(event: { clientX: number }) {
const bounds = canvasRef.current?.getBoundingClientRect();
if (!bounds) return current;
return clamp(((event.clientX - bounds.left) / bounds.width) * duration);
}
function beginDrag(event: PointerEvent<HTMLElement>, kind: "seek" | "decision") {
if (event.button !== 0 || (kind === "decision" && (decision === null || readOnly))) return;
function magneticTime(event: { clientX: number; altKey: boolean }, excludeIndex?: number, excludeAnnotationId?: string) {
const points: SnapPoint[] = snapPoints.filter(point =>
!(excludeIndex !== undefined && "index" in point && point.index === excludeIndex) &&
!(excludeAnnotationId !== undefined && "annotationId" in point && point.annotationId === excludeAnnotationId));
if (excludeIndex !== undefined || excludeAnnotationId !== undefined) points.push({ ms: current, label: "播放位置" });
const result = snapTimelineTime(pointerTime(event), points, duration, canvasWidth, event.altKey);
setSnappedPoint(result.point);
return result.ms;
}
function beginDrag(event: PointerEvent<HTMLElement>) {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
event.currentTarget.setPointerCapture(event.pointerId);
draggingRef.current = { kind, pointerId: event.pointerId };
if (kind === "seek") onSeek(pointerTime(event));
draggingRef.current = { kind: "seek", pointerId: event.pointerId };
onSeek(magneticTime(event));
}
function beginDecisionDrag(event: PointerEvent<HTMLElement>, item: TimelineAnnotation) {
event.stopPropagation();
if (readOnly || event.button !== 0) return;
event.preventDefault();
event.currentTarget.focus({ preventScroll: true });
event.currentTarget.setPointerCapture(event.pointerId);
suppressAnnotationClickRef.current = null;
draggingRef.current = { kind: "decision", pointerId: event.pointerId, id: item.id, start: item.decision_ms, next: item.decision_ms };
setDecisionPreview({ id: item.id, ms: item.decision_ms });
setSnappedPoint(null);
onSelectAnnotation(item.id, false);
}
function beginResize(event: PointerEvent<HTMLElement>, item: TimelineTranscript, edge: "start" | "end") {
if (readOnly || event.button !== 0) return;
event.preventDefault(); event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
event.currentTarget.setPointerCapture(event.pointerId);
draggingRef.current = { kind: "resize", pointerId: event.pointerId, index: item.index, edge, start: item.start_ms, end: item.end_ms, nextStart: item.start_ms, nextEnd: item.end_ms };
setResizePreview({ index: item.index, start_ms: item.start_ms, end_ms: item.end_ms });
}
function moveDrag(event: PointerEvent<HTMLElement>) {
const drag = draggingRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
event.stopPropagation();
const ms = pointerTime(event);
if (drag.kind === "seek") onSeek(ms);
else onDecisionChange(ms);
if (drag.kind === "resize") {
const ms = magneticTime(event, drag.index);
const range = resizeTranscriptRange(drag.start, drag.end, drag.edge, ms, duration);
drag.nextStart = range.start_ms; drag.nextEnd = range.end_ms;
setResizePreview({ index: drag.index, ...range });
if ((drag.edge === "start" ? range.start_ms : range.end_ms) !== ms) setSnappedPoint(null);
} else if (drag.kind === "seek") onSeek(magneticTime(event));
else {
drag.next = magneticTime(event, undefined, drag.id);
setDecisionPreview({ id: drag.id, ms: drag.next });
}
// Keep the pointer near the visible edge usable while a zoomed track scrolls.
const viewport = viewportRef.current;
if (viewport) {
@@ -181,15 +247,45 @@ export default function Timeline({
}
function endDrag(event: PointerEvent<HTMLElement>) {
if (draggingRef.current?.pointerId !== event.pointerId) return;
const drag = draggingRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
draggingRef.current = null;
if (drag.kind === "resize" && (drag.start !== drag.nextStart || drag.end !== drag.nextEnd)) onResizeTranscript(drag.index, drag.nextStart, drag.nextEnd);
if (drag.kind === "decision" && drag.start !== drag.next) {
suppressAnnotationClickRef.current = drag.id;
onMoveAnnotation(drag.id, drag.next);
}
setResizePreview(null);
setDecisionPreview(null);
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
}
function keyboardMove(event: KeyboardEvent<HTMLElement>, kind: "seek" | "decision") {
if (kind === "decision" && readOnly) return;
const base = kind === "seek" ? current : decision;
if (base === null) return;
function cancelDrag() { if (!draggingRef.current) return; draggingRef.current = null; setResizePreview(null); setDecisionPreview(null); setSnappedPoint(null); }
function keyboardAnnotation(event: KeyboardEvent<HTMLElement>, item: TimelineAnnotation) {
event.stopPropagation();
if (readOnly || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const step = event.shiftKey ? 1000 : event.altKey ? 10 : 100;
const ms = event.key === "Home" ? 0 : event.key === "End" ? duration : item.decision_ms + (["ArrowLeft", "ArrowDown"].includes(event.key) ? -step : step);
setSnappedPoint(null);
onSelectAnnotation(item.id, false);
onMoveAnnotation(item.id, clamp(ms));
}
function keyboardResize(event: KeyboardEvent<HTMLElement>, item: TimelineTranscript, edge: "start" | "end") {
event.stopPropagation();
if (readOnly || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const step = event.shiftKey ? 1000 : event.altKey ? 10 : 100;
const base = edge === "start" ? item.start_ms : item.end_ms;
const value = event.key === "Home" ? 0 : event.key === "End" ? duration : base + (event.key === "ArrowLeft" ? -step : step);
const range = resizeTranscriptRange(item.start_ms, item.end_ms, edge, value, duration);
if (range.start_ms !== item.start_ms || range.end_ms !== item.end_ms) onResizeTranscript(item.index, range.start_ms, range.end_ms);
}
function keyboardMove(event: KeyboardEvent<HTMLElement>) {
const base = current;
const increment = event.shiftKey ? 1_000 : event.altKey ? 10 : 100;
let next: number | null = null;
if (["ArrowLeft", "ArrowDown", "-"].includes(event.key)) next = base - increment;
@@ -199,18 +295,19 @@ export default function Timeline({
if (next === null) return;
event.preventDefault();
event.stopPropagation();
if (kind === "seek") onSeek(clamp(next));
else onDecisionChange(clamp(next));
onSeek(clamp(next));
}
const trackStyle = { "--timeline-width": `${canvasWidth}px` } as CSSProperties;
const channelCount = waveform?.length || 1;
const channelLabel = (index: number) => channelCount === 1 ? "单声道" : channelCount === 2 ? index === 0 ? "左声道 L" : "右声道 R" : `声道 ${index + 1}`;
const trackStyle = { "--timeline-width": `${canvasWidth}px`, height: 204 + 76 * channelCount } as CSSProperties;
return (
<section className={styles.timeline} aria-label="对话时间轴">
<div className={styles.toolbar}>
<div className={styles.toolbarTitle}>
<span className={styles.title}>对话时间轴</span>
<span className={styles.duration}>{timeLabel(duration)} <span>总时长</span></span>
<span className={styles.duration}>{timeLabel(duration)} <span>总时长{waveform && ` · ${channelCount === 2 ? "双声道" : `${channelCount} 声道`}`}</span></span>
</div>
<div className={styles.tools}>
<button className={styles.locate} type="button" onClick={locateDecision} disabled={decision === null} title="将当前判断点移到视图中央">
@@ -227,7 +324,7 @@ export default function Timeline({
<div className={styles.trackLayout}>
<div className={styles.trackNames} aria-hidden="true">
<div className={styles.rulerName}>时间</div>
<div className={styles.waveName}>录音</div>
{Array.from({ length: channelCount }, (_, index) => <div className={styles.waveName} key={index}><span className={index % 2 ? styles.rightDot : styles.leftDot} />{waveform ? channelLabel(index) : "录音"}</div>)}
<div className={styles.speechName}><span className={styles.agentDot} />机器人</div>
<div className={styles.speechName}><span className={styles.userDot} />用户</div>
<div className={styles.markerName}>判断点</div>
@@ -240,83 +337,104 @@ export default function Timeline({
tabIndex={0}
role="group"
aria-label={`播放位置 ${timeLabel(current, true)}:点击或拖动时间轴定位,方向键微调,Home 到开头,End 到结尾`}
onPointerDown={(event) => beginDrag(event, "seek")}
onPointerDown={(event) => beginDrag(event)}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onLostPointerCapture={() => { draggingRef.current = null; }}
onKeyDown={(event) => keyboardMove(event, "seek")}
onPointerCancel={cancelDrag}
onLostPointerCapture={cancelDrag}
onKeyDown={keyboardMove}
>
<div className={styles.ruler} aria-hidden="true">
{ticks.map((tick) => <span key={tick.time} className={styles.tick} style={{ left: position(tick.time) }}>{tick.label}</span>)}
</div>
<div className={styles.waveTrack}>
{waveformPath ? (
<svg className={styles.waveform} viewBox={`0 0 ${canvasWidth} 76`} preserveAspectRatio="none" role="img" aria-label="录音真实振幅波形">
<path d={waveformPath} fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
) : (
<div className={styles.waveEmpty} style={{ width: viewportWidth }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 10v4m4-7v10m4-13v16m4-13v10m4-7v4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>
<span>载入录音后显示波形</span>
</div>
)}
</div>
{Array.from({ length: channelCount }, (_, index) => (
<div key={index} className={`${styles.waveTrack} ${index % 2 ? styles.rightChannel : styles.leftChannel}`}>
{waveformPaths[index] ? (
<svg className={styles.waveform} viewBox={`0 0 ${canvasWidth} 76`} preserveAspectRatio="none" role="img" aria-label={`${channelLabel(index)}真实振幅波形`}>
<line x1="0" y1="38" x2={canvasWidth} y2="38" stroke="currentColor" opacity=".15" />
<path d={waveformPaths[index]} fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
) : (
<div className={styles.waveEmpty} style={{ width: viewportWidth }}><span>载入录音后显示各声道波形</span></div>
)}
</div>
))}
{(["agent", "user"] as const).map((role) => (
<div className={styles.speechTrack} key={role} aria-label={role === "agent" ? "机器人发言" : "用户发言"}>
{transcripts.filter((item) => item.role === role).map((item, index) => {
<div className={styles.speechTrack} key={role} aria-label={role === "agent" ? "机器人发言" : "用户发言"}
role="group"
onPointerDown={event => event.stopPropagation()}
onClick={event => { event.stopPropagation(); if (event.target === event.currentTarget) onSeek(magneticTime(event)); }}
title="点击片段在右侧编辑;拖动片段左右边界调整时间">
{displayedTranscripts.filter((item) => item.role === role).map((item) => {
const start = position(item.start_ms);
const end = position(item.end_ms);
return <span
key={`${role}-${item.start_ms}-${index}`}
className={`${styles.speechBlock} ${role === "agent" ? styles.agentBlock : styles.userBlock}`}
return <div
key={`${role}-${item.index}`}
className={`${styles.speechBlock} ${role === "agent" ? styles.agentBlock : styles.userBlock} ${resizePreview?.index === item.index ? styles.resizingBlock : ""} ${editingTranscriptIndex === item.index ? styles.editingBlock : ""}`}
style={{ left: start, width: Math.max(3, end - start) }}
title={`${role === "agent" ? "机器人" : "用户"} · ${timeLabel(item.start_ms, true)}—${timeLabel(item.end_ms, true)}\n${item.transcript}`}
><span>{item.transcript}</span></span>;
>
<button type="button" className={styles.blockBody}
title={`${role === "agent" ? "机器人" : "用户"} · ${timeLabel(item.start_ms, true)}—${timeLabel(item.end_ms, true)}\n${item.transcript}`}
aria-label={`${readOnly ? "查看" : "编辑"}${role === "agent" ? "机器人" : "用户"}片段 ${timeLabel(item.start_ms, true)} ${item.transcript}`}
onPointerDown={event => event.stopPropagation()}
onKeyDown={event => event.stopPropagation()}
onDoubleClick={event => event.stopPropagation()}
onClick={event => { event.stopPropagation(); onSelectTranscript(item.index); }}
><span>{item.transcript}</span></button>
{!readOnly && (["start", "end"] as const).map(edge => <button type="button" key={edge}
className={`${styles.resizeHandle} ${edge === "start" ? styles.startHandle : styles.endHandle}`}
aria-label={`调整${role === "agent" ? "机器人" : "用户"}片段${edge === "start" ? "起点" : "终点"} ${timeLabel(item.start_ms, true)}`}
title="拖动调整边界;方向键微调,按住 Alt 暂停吸附"
onPointerDown={event => beginResize(event, item, edge)} onPointerMove={moveDrag} onPointerUp={endDrag}
onPointerCancel={cancelDrag} onLostPointerCapture={cancelDrag}
onKeyDown={event => keyboardResize(event, item, edge)} onClick={event => event.stopPropagation()}
/>)}
{resizePreview?.index === item.index && <span className={styles.resizeReadout}>{timeLabel(item.start_ms, true)} — {timeLabel(item.end_ms, true)}</span>}
</div>;
})}
</div>
))}
<div className={styles.annotationTrack} aria-label="已保存的 EOT 标注">
{annotations.filter((item) => item.id !== selectedId).map((item, index) => (
<button
type="button"
key={item.id}
className={`${styles.annotation} ${item.can_reply_now === "1" ? styles.positive : item.can_reply_now === "0" ? styles.negative : styles.pending}`}
style={{ left: position(item.decision_ms) }}
title={`标注 ${index + 1} · ${timeLabel(item.decision_ms, true)} · ${annotationLabel(item.can_reply_now, item.reason)}`}
aria-label={`选择 ${timeLabel(item.decision_ms, true)} 的标注,${annotationLabel(item.can_reply_now, item.reason)}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onSelectAnnotation(item.id); }}
onKeyDown={(event) => event.stopPropagation()}
><span className={styles.annotationStem} aria-hidden="true" /><span>{item.can_reply_now === "" ? "·" : item.can_reply_now}</span></button>
))}
{decision !== null && <span className={styles.selectedLabel} style={{ left: position(decision) }}>
{selectedAnnotation ? annotationLabel(selectedAnnotation.can_reply_now, selectedAnnotation.reason) : "当前判断"}
</span>}
<div className={styles.annotationTrack} aria-label="已保存的 EOT 标注"
role="group" tabIndex={readOnly ? -1 : 0}
title={readOnly ? "点击判断点查看" : "点击空白处新增判断点;Enter 在播放位置新增"}
onPointerDown={event => event.stopPropagation()}
onClick={event => { event.stopPropagation(); if (!readOnly) onAddAnnotation(magneticTime(event)); }}
onKeyDown={event => { event.stopPropagation(); if (!readOnly && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); onAddAnnotation(current); } }}>
{annotations.map((item, index) => {
const ms = decisionPreview?.id === item.id ? decisionPreview.ms : item.decision_ms;
const isSelected = item.id === selectedId;
return <button type="button" key={item.id}
className={isSelected ? styles.selectedLabel : `${styles.annotation} ${item.can_reply_now === "1" ? styles.positive : item.can_reply_now === "0" ? styles.negative : styles.pending}`}
style={{ left: position(ms), cursor: readOnly ? "pointer" : undefined }}
title={`标注 ${index + 1} · ${timeLabel(ms, true)} · ${annotationLabel(item.can_reply_now, item.reason)}${readOnly ? "" : " · 拖动调整时间,Alt 暂停吸附"}`}
aria-label={`${isSelected ? "当前" : "选择"}标注 ${timeLabel(ms, true)},${annotationLabel(item.can_reply_now, item.reason)}${readOnly ? "" : ",可拖动"}`}
aria-describedby={helpId}
onPointerDown={event => beginDecisionDrag(event, item)} onPointerMove={moveDrag} onPointerUp={endDrag}
onPointerCancel={cancelDrag} onLostPointerCapture={cancelDrag}
onClick={event => {
event.stopPropagation();
if (suppressAnnotationClickRef.current === item.id) { suppressAnnotationClickRef.current = null; return; }
onSelectAnnotation(item.id);
}}
onKeyDown={event => keyboardAnnotation(event, item)}
><span className={styles.annotationStem} aria-hidden="true" /><span>{isSelected ? annotationLabel(item.can_reply_now, item.reason) : item.can_reply_now === "" ? "·" : item.can_reply_now}</span></button>;
})}
</div>
<div className={styles.playhead} style={{ left: position(current) }} aria-hidden="true"><span /></div>
{activeSnap && <div className={styles.snapGuide} style={{ left: position(activeSnap.ms) }} aria-hidden="true" />}
<div className={styles.playhead} style={{ left: position(current) }}>
<button type="button" className={styles.playheadHandle} aria-label={`拖动播放头 ${timeLabel(current, true)}`}
title="拖动播放头,自动吸附片段边界和判断点;Alt 暂停吸附"
onPointerDown={event => beginDrag(event)} onPointerMove={moveDrag} onPointerUp={endDrag}
onPointerCancel={cancelDrag} onLostPointerCapture={cancelDrag}
onKeyDown={keyboardMove}><span /></button>
</div>
{decision !== null && <div className={styles.decisionMarker} style={{ left: position(decision) }}>
<div className={styles.decisionLine} aria-hidden="true" />
<button
type="button"
className={styles.decisionHandle}
title={readOnly ? "当前判断时间(查看模式)" : "拖动调整判断时间;方向键微调 100 ms,Shift 微调 1 秒"}
aria-label={`当前判断点 ${timeLabel(decision, true)}${readOnly ? ",查看模式" : ",拖动或使用方向键调整"}`}
aria-describedby={helpId}
aria-disabled={readOnly}
style={readOnly ? { cursor: "default" } : undefined}
onPointerDown={(event) => { event.stopPropagation(); if (!readOnly) beginDrag(event, "decision"); }}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onLostPointerCapture={(event) => { event.stopPropagation(); draggingRef.current = null; }}
onKeyDown={(event) => { event.stopPropagation(); keyboardMove(event, "decision"); }}
><span aria-hidden="true" /></button>
</div>}
</div>
</div>
@@ -324,35 +442,7 @@ export default function Timeline({
<div className={styles.bottomBar}>
<div className={styles.timeReadout}><span className={styles.playheadDot} /> 播放 <strong>{timeLabel(current, true)}</strong></div>
<span id={helpId} className={styles.help}>{readOnly ? "点击定位播放 · 选择标记查看详情" : "点击定位播放 · 拖动橙色标记调整判断点"}</span>
</div>
<div className={styles.decisionControls}>
<label htmlFor={rangeId}><span className={styles.decisionDot} />判断时间</label>
<input
id={rangeId}
type="range"
min={0}
max={duration}
step={1}
value={decision ?? 0}
disabled={decision === null || readOnly}
aria-valuetext={decision === null ? "未选择判断点" : timeLabel(decision, true)}
onChange={(event) => onDecisionChange(clamp(Number(event.currentTarget.value)))}
/>
<label className={styles.numberInput}>
<input
type="number"
aria-label="判断时间,单位毫秒"
min={0}
max={duration}
step={1}
value={decision ?? ""}
placeholder="—"
disabled={decision === null || readOnly}
onChange={(event) => { if (event.currentTarget.value !== "") onDecisionChange(clamp(Number(event.currentTarget.value))); }}
/>
<span>ms</span>
</label>
<span id={helpId} className={styles.help}>{resizePreview ? "拖动边界调整片段 · 松开保存 · Alt 暂停吸附" : activeSnap ? `已吸附:${activeSnap.label} ${timeLabel(activeSnap.ms, true)}` : "拖动判断点或播放头 · 自动吸附 · Alt 暂停吸附"}</span>
</div>
</section>
);
@@ -0,0 +1,26 @@
"use client";
import type { Transcript } from "@/lib/annotations";
import { formatTime } from "@/lib/annotations";
export default function TranscriptEditor({ draft, readOnly, onChange, onSave, onClose, onDelete }: {
draft: { index: number; row: Transcript };
readOnly: boolean;
onChange: (row: Transcript) => void;
onSave: () => void;
onClose: () => void;
onDelete: () => void;
}) {
const row = draft.row;
return <aside className="annotation-sidebar" aria-label="对话转写编辑栏">
<div className="editor-heading"><div><span className="eyebrow">{draft.index === -1 ? "新增对话转写" : readOnly ? "转写详情" : "编辑对话转写"}</span><h2>{row.role === "agent" ? "机器人" : "用户"}发言</h2></div><button className="icon-button" aria-label="关闭转写编辑" onClick={onClose}>×</button></div>
<div className="editor-body">
<div className="decision-card"><span>片段时间</span><strong>{formatTime(row.start_ms)} — {formatTime(row.end_ms)}</strong><small>也可拖动时间轴上片段的左右边界。</small></div>
<label className="form-field"><span>说话人</span><select aria-label="转写说话人" disabled={readOnly} value={row.role} onChange={event => onChange({ ...row, role: event.target.value as Transcript["role"] })}><option value="agent">机器人</option><option value="user">用户</option></select></label>
<div className="form-two"><label className="form-field"><span>开始时间(毫秒)</span><input aria-label="转写开始时间" type="number" min="0" readOnly={readOnly} value={row.start_ms} onChange={event => onChange({ ...row, start_ms: Number(event.target.value) })} /></label><label className="form-field"><span>结束时间(毫秒)</span><input aria-label="转写结束时间" type="number" min="1" readOnly={readOnly} value={row.end_ms} onChange={event => onChange({ ...row, end_ms: Number(event.target.value) })} /></label></div>
<label className="form-field"><span>转写文本</span><textarea aria-label="转写文本" rows={7} readOnly={readOnly} placeholder="填写这段发言的内容" value={row.transcript} onChange={event => onChange({ ...row, transcript: event.target.value })} /></label>
{!readOnly && <div className="transcript-editor-actions"><button className="button primary" onClick={onSave}>{draft.index === -1 ? "添加片段" : "应用修改"}</button><button className="button ghost" onClick={onClose}>关闭</button></div>}
{!readOnly && draft.index >= 0 && <div className="editor-bottom"><span>修改后保存到项目</span><button className="delete-button" onClick={onDelete}>删除片段</button></div>}
</div>
</aside>;
}
@@ -1,904 +0,0 @@
[
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 2
},
"fields": [
{
"label": "用户说话",
"value": "贵州省遵义市仁怀市。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您现在在哪个城市呢?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 3
},
"fields": [
{
"label": "用户说话",
"value": "投诉建议。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您是要业务咨询还是投诉建议?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 4
},
"fields": [
{
"label": "用户说话",
"value": "型号是24年380大众帕萨特星空龙腾版。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "抱歉给您带来不好的体验了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 5
},
"fields": [
{
"label": "用户说话",
"value": "24年8月份。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "购车时间和行驶里程方便说一下吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 6
},
"fields": [
{
"label": "用户说话",
"value": "行驶3万公里。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "行驶里程大概是多少呢?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 7
},
"fields": [
{
"label": "用户说话",
"value": "恒泰轮胎前门两条轮胎开裂,东西比较大。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "辛苦您仔细讲一讲想要反馈投诉的具体事情哈。"
},
{
"label": "用户是否被抢话",
"value": "是"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 8
},
"fields": [
{
"label": "用户说话",
"value": "对。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "好,您反映的问题我全都记下了,我这边尽快安排专员给您回电沟通,您看可以吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 9
},
"fields": [
{
"label": "用户说话",
"value": "尽快,已经影响行车安全了。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "好,您反映的问题我全都记下了,我这边尽快安排专员给您回电沟通,您看可以吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 10
},
"fields": [
{
"label": "用户说话",
"value": "宣传。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您现在在哪个城市呢?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 11
},
"fields": [
{
"label": "用户说话",
"value": "投诉建议。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您是要业务咨询还是投诉建议?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 12
},
"fields": [
{
"label": "用户说话",
"value": "爱车型号是大众帕萨特,购车时间2023年10月份,行驶里程24000多公里。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "抱歉给您带来不好的体验了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 13
},
"fields": [
{
"label": "用户说话",
"value": "轮胎裂纹。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "辛苦您仔细讲一讲想要反馈投诉的具体事情哈。"
},
{
"label": "用户是否被抢话",
"value": "是"
},
{
"label": "原表第5列(无表头)",
"value": "否"
}
]
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 14
},
"fields": [
{
"label": "用户说话",
"value": "轮胎裂纹同意更换新轮胎。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "方便说话吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 15
},
"fields": [
{
"label": "用户说话",
"value": "但是20。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "好,您反映的问题我全都记下了。"
},
{
"label": "用户是否被抢话",
"value": "是"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 16
},
"fields": [
{
"label": "用户说话",
"value": "可以。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "好,您反映的问题我全都记下了,我这边尽快安排专员给您回电沟通,您看可以吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "872a1e67-873a-4c49-a598-b38a670afcad",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 17
},
"fields": [
{
"label": "用户说话",
"value": "我姓宋。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "刚才没太听懂您的意思,能不能再跟我说?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "872a1e67-873a-4c49-a598-b38a670afcad",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 18
},
"fields": [
{
"label": "用户说话",
"value": "郑州市,河南郑州市。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您现在在哪个城市呢?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "872a1e67-873a-4c49-a598-b38a670afcad",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 19
},
"fields": [
{
"label": "用户说话",
"value": "我的轮胎开裂了。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您是要业务咨询还是投诉建议?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "872a1e67-873a-4c49-a598-b38a670afcad",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 20
},
"fields": [
{
"label": "用户说话",
"value": "帕萨特L2025款2025年3月购车流水流程29000公里。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "抱歉,您的问题我已经了解了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。"
},
{
"label": "用户是否被抢话",
"value": "是"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
},
{
"call_id": "872a1e67-873a-4c49-a598-b38a670afcad",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 21
},
"fields": [
{
"label": "用户说话",
"value": "29000公里。29000公里。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "行驶里程大概是多少呢?另外,辛苦。行驶里程大概是多少呢?另外辛苦您仔细讲。"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "872a1e67-873a-4c49-a598-b38a670afcad",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 22
},
"fields": [
{
"label": "用户说话",
"value": "可以。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "好的,您反映的问题我全都记下了,我这边尽快安排给您回电沟通,您看可以吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "a266b9ef-5e47-4434-a04c-ce4eacd52c19",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 23
},
"fields": [
{
"label": "用户说话",
"value": "天津市。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您现在在哪个城市呢?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "a266b9ef-5e47-4434-a04c-ce4eacd52c19",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 24
},
"fields": [
{
"label": "用户说话",
"value": "投诉。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您是要业务咨询还是投诉建议?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "a266b9ef-5e47-4434-a04c-ce4eacd52c19",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 25
},
"fields": [
{
"label": "用户说话",
"value": "迈腾2023款330豪华版2.0T。出现的问题是轮购车时间是2024年5月份,里程是6851公里。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "抱歉给您带来不好的体验了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。"
},
{
"label": "用户是否被抢话",
"value": "是"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
},
{
"call_id": "a266b9ef-5e47-4434-a04c-ce4eacd52c19",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 26
},
"fields": [
{
"label": "用户说话",
"value": "轮胎质量问题规律,排水槽横向竖向规律。轮胎质量有问题。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "辛苦您仔细想一想想要反馈投诉的具体事情哈。"
},
{
"label": "用户是否被抢话",
"value": "否"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
},
{
"call_id": "a266b9ef-5e47-4434-a04c-ce4eacd52c19",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 27
},
"fields": [
{
"label": "用户说话",
"value": "可以。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "好,您反映的问题我全都记下了,我这边尽快安排专员给您回电沟通,您看可以吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 28
},
"fields": [
{
"label": "用户说话",
"value": "辽宁葫芦岛。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您现在在哪个城市呢?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 29
},
"fields": [
{
"label": "用户说话",
"value": "投诉建议。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您是要业务咨询还是投诉建议?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 30
},
"fields": [
{
"label": "用户说话",
"value": "我是2023年、2024年1月份购置的迈腾3302023款轿车,目前已行驶28000公里。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "抱歉给您带来不好的体验了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。"
},
{
"label": "用户是否被抢话",
"value": "否"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 31
},
"fields": [
{
"label": "用户说话",
"value": "我的轮胎平时28000公里。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "辛苦您仔细想一想,想要反馈投诉的具体事情哈。"
},
{
"label": "用户是否被抢话",
"value": "是"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 32
},
"fields": [
{
"label": "用户说话",
"value": "轮胎开裂,行驶28000公里,轮胎开裂、纵纹开裂、横纹开裂。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "辛苦您仔细讲一讲想要反馈投诉的具体事情哈。"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 33
},
"fields": [
{
"label": "用户说话",
"value": "对的。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "好,您反映的问题我全都记下了,我这边尽快安排,您看可以吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 34
},
"fields": [
{
"label": "用户说话",
"value": "请尽快帮我处理这件事。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "好,您反映的问题我全都记下了,我这边尽快安排,您看可以吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 35
},
"fields": [
{
"label": "用户说话",
"value": "好的。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "好的,感谢您的配合,祝您生活愉快,再见。"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "e304f740-85d4-427e-9fd6-c5474e9cd737",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 36
},
"fields": [
{
"label": "用户说话",
"value": "苏州昆山。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您现在在哪个城市呢?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "e304f740-85d4-427e-9fd6-c5474e9cd737",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 37
},
"fields": [
{
"label": "用户说话",
"value": "投诉。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您是要业务咨询还是投诉建议?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "e304f740-85d4-427e-9fd6-c5474e9cd737",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 38
},
"fields": [
{
"label": "用户说话",
"value": "帕萨特24年11月份。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "抱歉给您带来不好的体验了,麻烦告知一下您的爱车型号、购车时间,还有行驶里程。"
},
{
"label": "用户是否被抢话",
"value": "否"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
},
{
"call_id": "e304f740-85d4-427e-9fd6-c5474e9cd737",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 39
},
"fields": [
{
"label": "用户说话",
"value": "24年11月份购买的帕萨特,15000公里左右。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "购车时间和行驶里程方便说一下吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "e304f740-85d4-427e-9fd6-c5474e9cd737",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 40
},
"fields": [
{
"label": "用户说话",
"value": "四条轮胎,最近我在。查看的时候发现全部开裂。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "辛苦您仔细讲一讲想要反馈投诉的具体事情哈。"
},
{
"label": "用户是否被抢话",
"value": "是"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
}
]
-13
View File
@@ -1,13 +0,0 @@
/** Read-only source-table evidence, separate from timed transcripts and EOT labels. */
export interface CallReference {
call_id: string;
source: {
file: string;
sheet: string;
row: number;
};
fields: {
label: string;
value: string;
}[];
}
+21
View File
@@ -0,0 +1,21 @@
export interface EditHistory<T> { past: T[]; present: T; future: T[] }
const LIMIT = 40;
export function createEditHistory<T>(present: T): EditHistory<T> {
return { past: [], present, future: [] };
}
export function commitEdit<T>(history: EditHistory<T>, present: T): EditHistory<T> {
if (present === history.present) return history;
return { past: [...history.past, history.present].slice(-LIMIT), present, future: [] };
}
export function undoEdit<T>(history: EditHistory<T>): EditHistory<T> {
if (!history.past.length) return history;
return { past: history.past.slice(0, -1), present: history.past.at(-1)!, future: [history.present, ...history.future] };
}
export function redoEdit<T>(history: EditHistory<T>): EditHistory<T> {
if (!history.future.length) return history;
return { past: [...history.past, history.present].slice(-LIMIT), present: history.future[0], future: history.future.slice(1) };
}
+25
View File
@@ -0,0 +1,25 @@
export interface SnapPoint { ms: number; label: string }
/** Keep magnetic selection consistent with zoom without attracting distant audio. */
export function snapTimelineTime(ms: number, points: SnapPoint[], duration: number, width: number, bypass = false) {
const bounded = Math.round(Math.max(0, Math.min(duration, ms)));
const radius = Math.min(500, duration / Math.max(1, width) * 8);
let point: SnapPoint | null = null;
let distance = radius;
if (!bypass) {
for (const candidate of points) {
if (candidate.ms < 0 || candidate.ms > duration) continue;
const delta = Math.abs(candidate.ms - bounded);
if (delta <= distance && (!point || delta < distance)) { point = candidate; distance = delta; }
}
}
return { ms: point?.ms ?? bounded, point };
}
/** A resized segment must stay in the recording and retain a positive duration. */
export function resizeTranscriptRange(start: number, end: number, edge: "start" | "end", ms: number, duration: number) {
const value = Math.round(ms);
return edge === "start"
? { start_ms: Math.max(0, Math.min(end - 1, value)), end_ms: end }
: { start_ms: start, end_ms: Math.max(start + 1, Math.min(duration, value)) };
}
+1
View File
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
devIndicators: false,
allowedDevOrigins: ["127.0.0.1"],
// 本地标注与录音接口始终读取当前工作文件。
};
+143
View File
@@ -0,0 +1,143 @@
"""Convert Hantai XLSX rows to an editable, approximate CSV draft.
Requires ffmpeg and numpy. Never writes the source workbook or 工作数据.
"""
import csv
import json
import math
import subprocess
import uuid
import zipfile
from collections import defaultdict
from pathlib import Path
from xml.etree import ElementTree as ET
import numpy as np
ROOT = Path(__file__).resolve().parents[3]
SOURCE = ROOT / "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx"
OUTPUT = ROOT / "labels/EOT标注/韩泰初稿"
NS = {"s": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
def source_rows():
with zipfile.ZipFile(SOURCE) as archive:
strings = []
if "xl/sharedStrings.xml" in archive.namelist():
strings = ["".join(item.itertext()) for item in ET.fromstring(archive.read("xl/sharedStrings.xml"))]
rows = []
for row in ET.fromstring(archive.read("xl/worksheets/sheet1.xml")).findall(".//s:row", NS):
values = {}
for cell in row.findall("s:c", NS):
value = cell.find("s:v", NS)
text = value.text if value is not None else ""
if cell.get("t") == "s":
text = strings[int(text)]
elif cell.get("t") == "inlineStr":
text = "".join(cell.find("s:is", NS).itertext())
values[cell.get("r").rstrip("0123456789")] = text or ""
try:
uuid.UUID(values.get("A", ""))
except ValueError:
continue
rows.append({"row": int(row.get("r")), **values})
return rows
def speech_spans(audio):
raw = subprocess.check_output(["ffmpeg", "-v", "error", "-i", str(audio), "-f", "f32le", "-ac", "2", "-ar", "8000", "pipe:1"])
samples = np.frombuffer(raw, dtype=np.float32).reshape(-1, 2)
frames = len(samples) // 160
rms = np.sqrt((samples[:frames * 160].reshape(frames, 160, 2) ** 2).mean(axis=1))
channels = []
for channel in range(2):
threshold = max(.004, float(np.quantile(rms[:, channel], .95)) * .16)
runs = []
for index in np.flatnonzero(rms[:, channel] > threshold):
if runs and index - runs[-1][1] < 35:
runs[-1][1] = int(index)
else:
runs.append([int(index), int(index)])
channels.append([(a * 20, (b + 1) * 20) for a, b in runs if b - a >= 12])
return channels, round(len(samples) / 8)
def match_prompts(groups, spans):
# Greeting is absent from the source. Match remaining prompts in order,
# allowing extra robot speech (e.g. a retry) to be skipped.
candidates = spans[1:]
cache = {}
def solve(i, j):
if i == len(groups):
return .35 * (len(candidates) - j), []
if len(candidates) - j < len(groups) - i:
return float("inf"), []
if (i, j) in cache:
return cache[i, j]
expected = max(.6, len(groups[i][0]["C"]) / 6.7)
actual = (candidates[j][1] - candidates[j][0]) / 1000
score, rest = solve(i + 1, j + 1)
best = (score + abs(math.log(actual / expected)), [candidates[j]] + rest)
if len(candidates) - j > len(groups) - i:
score, rest = solve(i, j + 1)
if score + .35 < best[0]:
best = score + .35, rest
cache[i, j] = best
return best
matches = solve(0, 0)[1]
if len(matches) != len(groups):
raise ValueError("Not enough robot speech spans; inspect the recording before conversion.")
return matches
def main():
grouped = defaultdict(list)
for row in source_rows():
grouped[row["A"]].append(row)
transcripts, annotations, provenance = [], [], []
for call, rows in grouped.items():
channels, duration = speech_spans(ROOT / f"data/韩泰机器人抢话/音频/{call}.mp3")
groups = []
for row in rows:
if groups and groups[-1][0]["C"] == row["C"]:
groups[-1].append(row)
else:
groups.append([row])
prompts = match_prompts(groups, channels[1])
for index, (group, (start, end)) in enumerate(zip(groups, prompts)):
transcripts.append([call, "agent", start, end, group[0]["C"]])
window_end = prompts[index + 1][0] if index + 1 < len(prompts) else duration
user_spans = [(a, b) for a, b in channels[0] if a >= end - 300 and a < window_end]
user_start = user_spans[0][0] if user_spans else min(end + 300, window_end - 1)
user_end = user_spans[-1][1] if user_spans else window_end
# Multiple answers to the same robot prompt share its response window.
# These boundaries are provisional, not word-level forced alignment.
weights = [max(1, len(row["B"])) for row in group]
cursor = user_start
for offset, row in enumerate(group):
finish = user_end if offset == len(group) - 1 else user_start + round((user_end - user_start) * sum(weights[:offset + 1]) / sum(weights))
finish = max(cursor + 1, min(duration, finish))
transcripts.append([call, "user", cursor, finish, row["B"]])
conflict = row.get("E") in ("是", "否") and row["E"] != row["D"]
label = "" if conflict else "0" if row["D"] == "是" else "1" if row["D"] == "否" else ""
reason = "uncertain" if label == "" else "unfinished_expression" if label == "0" else "completed_answer"
annotations.append([call, finish, row["B"], label, "low", reason, "xlsx_draft"])
provenance.append({"call_id": call, "source_row": row["row"], "decision_ms": finish, "original_interrupted": row["D"], "unheaded_column_e": row.get("E", ""), "conflict": conflict})
cursor = finish
OUTPUT.mkdir(parents=True, exist_ok=True)
for name, header, records in [
("asr_history.csv", ["call_id", "role", "start_ms", "end_ms", "transcript"], sorted(transcripts, key=lambda r: (r[0], r[2], r[1]))),
("eot_labels.csv", ["call_id", "decision_ms", "user_text_at_decision", "can_reply_now", "confidence", "reason", "annotator"], annotations),
]:
with (OUTPUT / name).open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.writer(file)
writer.writerow(header)
writer.writerows(records)
(OUTPUT / "source_rows.json").write_text(json.dumps(provenance, ensure_ascii=False, indent=2) + "\n")
print(json.dumps({"calls": len(grouped), "transcripts": len(transcripts), "annotations": len(annotations), "conflicts": sum(row["conflict"] for row in provenance)}))
if __name__ == "__main__":
main()
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-expect-error Node's native TypeScript runner requires the explicit extension.
import { createEditHistory, commitEdit, undoEdit, redoEdit } from "../lib/edit-history.ts";
test("ASR and EOT snapshots restore together through multiple undo and redo steps", () => {
const initial = { transcripts: ["原转写"], points: [1000] };
const asr = { ...initial, transcripts: ["修正转写"] };
const eot = { ...asr, points: [1200, 1800] };
let history = commitEdit(commitEdit(createEditHistory(initial), asr), eot);
history = undoEdit(history);
assert.deepEqual(history.present, asr);
history = undoEdit(history);
assert.deepEqual(history.present, initial);
history = redoEdit(history);
assert.deepEqual(history.present, asr);
history = redoEdit(history);
assert.deepEqual(history.present, eot);
assert.equal(history.future.length, 0);
assert.deepEqual(initial, { transcripts: ["原转写"], points: [1000] });
});
test("a new edit after undo abandons the old redo branch", () => {
let history = commitEdit(commitEdit(createEditHistory(0), 1), 2);
history = commitEdit(undoEdit(history), 3);
assert.equal(history.future.length, 0);
assert.equal(redoEdit(history).present, 3);
assert.equal(undoEdit(history).present, 1);
});
test("empty and unchanged operations preserve history, and forty steps can be restored", () => {
const initial = createEditHistory(0);
assert.equal(undoEdit(initial), initial);
assert.equal(redoEdit(initial), initial);
assert.equal(commitEdit(initial, 0), initial);
let history = initial;
for (let i = 1; i <= 60; i++) history = commitEdit(history, i);
assert.equal(history.past.length, 40);
for (let i = 0; i < 40; i++) history = undoEdit(history);
assert.equal(history.present, 20);
assert.equal(undoEdit(history), history);
for (let i = 0; i < 40; i++) history = redoEdit(history);
assert.equal(history.present, 60);
});
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-expect-error Node's native TypeScript runner requires the explicit extension.
import { resizeTranscriptRange, snapTimelineTime } from "../lib/timeline-edit.ts";
test("playhead chooses the nearest boundary or EOT and Alt allows free positioning", () => {
const points = [{ ms: 2000, label: "片段结束" }, { ms: 2150, label: "判断点" }];
assert.equal(snapTimelineTime(2110, points, 10000, 400).ms, 2150);
assert.equal(snapTimelineTime(2030, points, 10000, 400).ms, 2000);
assert.equal(snapTimelineTime(2110, points, 10000, 400, true).ms, 2110);
assert.equal(snapTimelineTime(4000, points, 10000, 400).point, null);
});
test("zoom narrows magnetic distance and long calls cannot pull the head by seconds", () => {
const points = [{ ms: 2000, label: "片段" }];
assert.equal(snapTimelineTime(2100, points, 10000, 400).ms, 2000);
assert.equal(snapTimelineTime(2100, points, 10000, 3200).ms, 2100);
assert.equal(snapTimelineTime(2800, points, 600000, 400).ms, 2800);
});
test("resizing never crosses the opposite boundary or the recording ends", () => {
assert.deepEqual(resizeTranscriptRange(2000, 4000, "start", 4500, 10000), { start_ms: 3999, end_ms: 4000 });
assert.deepEqual(resizeTranscriptRange(2000, 4000, "end", 1500, 10000), { start_ms: 2000, end_ms: 2001 });
assert.deepEqual(resizeTranscriptRange(2000, 4000, "start", -500, 10000), { start_ms: 0, end_ms: 4000 });
assert.deepEqual(resizeTranscriptRange(2000, 4000, "end", 15000, 10000), { start_ms: 2000, end_ms: 10000 });
});