<title>剧本解析角色匹配</title>

# 剧本解析角色匹配功能

## 更新日期

- 2026年6月9日：新增角色数据库匹配功能
- 2026年6月9日：修复前端未使用数据库角色匹配信息的问题
- 2026年6月9日：修复角色名称不一致问题（模型生成名称 vs 数据库名称）
- 2026年6月9日：优化分镜节点角色显示，将标签改为提示词区域的图片形式
- 2026年6月9日：新增在线缩略图服务，优化角色头像加载性能

## 问题背景

在剧本解析过程中，大语言模型会根据剧本文字自行生成角色名称（如"布冯"、"C罗"、"梅西"），但数据库中的角色名称可能是不同格式（如"阿方索戴维斯_AlphonsoDavies"）。这导致解析出的角色无法与数据库中的角色正确关联。

**根本原因**：剧本解析模块中加载了数据库场景和道具列表传给大语言模型做匹配，但没有加载数据库角色列表。

## 解决方案

### 1. 加载数据库角色列表

在剧本解析模块中，新增加载数据库角色的逻辑：

- 若提供了 `world_id`（或已存在角色契约），则分页读取该世界的完整角色列表，构建不可变的角色契约快照（不再限定50个）
- 每个角色格式化为「ID / 名称 / 描述」，描述拼接 身份/外貌/性格 多个维度（无值则跳过）
- 汇总为"数据库已有角色列表"文本，提供给大语言模型：如果剧本中的角色与数据库中的角色相同或相似，请在返回的角色对象中设置 `character_db_id` 字段为对应的数据库角色ID

### 2. 更新提示词

在系统提示词中增加角色匹配要求：每个角色对象必须包含 `character_db_id` 字段；匹配到数据库已有角色时填入对应ID，新角色必须为 `null`；匹配时考虑角色名称和描述的相似性，不需要完全一致；严禁编造不存在的ID。（提示词原文从略）

在用户提示词中附上上述"数据库已有角色列表"文本。

更新 JSON 格式示例，添加 `character_db_id` 字段：

```JSON
"characters": [
    {
      "id": "char_001",
      "name": "人物名称",
      "character_db_id": 123,
      "role": "主角/配角/群演",
      "description": "外貌和特征描述",
      "gender": "男/女",
      "age_range": "年龄范围"
    }
]
```

### 3. 服务端后处理

早期版本服务端在剧本解析接口中有角色匹配后处理逻辑（内部实现）：为每个镜头中出现的角色匹配数据库角色，匹配成功时在镜头数据中补充 `db_character_info` 数组（含 `character_id`、`db_character_id`、`db_character_pic`、`db_character_name`）。

**当前状态**：剧本拆分链路的后端已不再向镜头数据填充 `db_character_info`（shot 级后处理目前仅保留场景字段回填）；角色与数据库角色的一致性改由角色契约（注册表）+ 提示词硬约束 + 拆分质检校验保证。`db_character_info` 仅作为前端对历史解析数据的兼容读取字段保留（见"前端实现"）。

## 数据流

```Plaintext
剧本内容
    ↓
剧本解析模块
    ↓
加载数据库角色列表，构建角色契约快照（内部实现，完整角色列表）
    ↓
格式化为文本传给大语言模型
    ↓
大语言模型解析剧本，生成 characters 数组（包含 character_db_id）
    ↓
角色契约 + 提示词约束 + 拆分质检（内部实现）
    ↓
校验 character_db_id 与角色名称和数据库角色一致
    ↓
返回解析结果（历史解析数据中可能包含 db_character_info，前端兼容读取）
```

## 返回数据示例

> 注：`db_character_info` 为历史解析数据中的字段，当前拆分链路后端不再填充；新解析结果中该字段可能缺失，前端按缺省处理（回退到提示词提取匹配）。

```JSON
{
  "code": 0,
  "message": "解析成功",
  "data": {
    "characters": [
      {
        "id": "char_001",
        "name": "布冯",
        "character_db_id": 4651,
        "role": "主角",
        "description": "...",
        "gender": "男",
        "age_range": "45-50"
      }
    ],
    "shot_groups": [
      {
        "group_id": "grp_001",
        "group_name": "米兰公寓开场",
        "shots": [
          {
            "shot_id": "s001",
            "characters_present": ["char_001"],
            "db_character_info": [
              {
                "character_id": "char_001",
                "db_character_id": 4651,
                "db_character_pic": "http://...",
                "db_character_name": "阿方索戴维斯_AlphonsoDavies"
              }
            ]
          }
        ]
      }
    ]
  }
}
```

## 与场景/道具匹配的对比

| 数据类型 | 数据库加载 | 模型匹配字段 | 后处理 | 返回字段 |
|-|-|-|-|-|
| 场景(Location) | ✅ 对应数据表（内部实现） | `location_db_id` | 内部匹配逻辑 | `db_location_id`, `db_location_pic`, `location_name` |
| 道具(Props) | ✅ 对应数据表（内部实现） | `props_db_id` | 无 | 无 |
| **角色(Character)** | ✅ 对应数据表，分页读取完整列表构建角色契约快照（内部实现） | `character_db_id` | 无 shot 级后处理（由角色契约+提示词约束+质检校验保证） | 无（历史数据含 `db_character_info`，前端兼容读取） |

## 前端实现

### 问题描述

原来的前端实现存在以下问题：

1. 完全忽略后端返回的 `db_character_info`
2. 只从提示词文本中提取 `【【角色名】】` 模式
3. 通过精确名称匹配当前世界的角色列表，如果名称不匹配则无法关联

### 解决方案

在前端节点脚本中修改了角色匹配逻辑：

**1. 新增"基于数据库匹配结果获取角色"逻辑（内部实现）**

优先使用后端返回的 `db_character_info` 匹配角色：

- 遍历 `db_character_info`，若某项有 `db_character_id` 和 `db_character_name`（后端已匹配到数据库角色），直接使用 `db_character_name`
- 否则回退：用 `character_id` 到解析结果的 `scriptData.characters` 中查找名称，仅当该名称存在于当前世界角色列表时才采纳
- 去重后返回匹配到的角色名数组，无匹配则返回 null

**2. 修改初始匹配逻辑**

- 优先使用数据库匹配结果（`db_character_info`）
- 未匹配到时，按输入模式选取提示词来源（多参考图模式取视频提示词，否则取图片提示词），从中提取 `【【角色名】】`

**3. 修改刷新引用逻辑（内部实现）**

- 刷新引用时采用与初始匹配相同的优先级逻辑
- 匹配完成后重新渲染场景、道具、角色引用区

### 匹配流程

```Plaintext
镜头数据中的 db_character_info
    ↓
检查 db_character_id 和 db_character_name
    ↓
如果存在 → 直接使用 db_character_name
    ↓
如果不存在 → 从解析结果 characters 中查找名称
    ↓
匹配当前世界角色列表（按名称）
    ↓
渲染角色标签
```

## 角色名称一致性修复

### 问题描述

大语言模型生成的角色名称（如"布冯"）与数据库中的实际名称（如"阿方索戴维斯_AlphonsoDavies"）不一致，导致：

1. 前端无法通过名称匹配到当前世界角色列表
2. 角色标签显示不正确

### 解决方案

#### 1. 提示词优化

在剧本解析模块的系统/用户提示词中增加要求：当角色与数据库匹配时（`character_db_id` 不为 null），返回的角色 `name` 字段以及提示词中的 `【【角色名】】` 标记必须使用数据库中的角色名称。（提示词原文从略）

#### 2. 名称一致性保障机制（已演进）

早期版本曾在服务端后处理中将大语言模型生成的角色名称替换为数据库中的实际名称（原名称存入 `llm_name` 备用字段）。

**当前状态**：后端不再做名称替换，`llm_name` 备用字段已移除；名称一致性改由提示词硬约束 + 角色契约注册表 + 拆分质检校验保证——系统/用户提示词要求匹配到数据库角色时 `name` 与 `【【角色名】】` 必须使用数据库中的角色名称，拆分质检按注册表校验角色名称与 `character_db_id` 的一致性，不一致时触发修正轮次。

### 修复后的数据流

```Plaintext
剧本内容："布冯瘫在沙发里..."
    ↓
大语言模型解析
    ↓
生成 characters: [{ id: "char_001", name: "布冯", character_db_id: 4649 }]
    ↓
大语言模型按提示词约束直接输出数据库名称
    ↓
角色契约/质检校验名称与 character_db_id 一致性（内部实现）
    ↓
返回: [{ id: "char_001", name: "阿方索戴维斯_AlphonsoDavies", character_db_id: 4649 }]
```

## 分镜节点角色显示优化

### UI 变化

**旧布局**：角色以文本标签形式显示在左侧"基础信息"区域的"场景/道具/角色"引用区

**新布局**：角色以 inline 图片形式嵌入提示词文本中，将【【角色名】】替换为角色头像标签

```Plaintext
┌─────────────────────────────────────────┐
│ 2 提示词编辑                             │
│ ┌─────────────────────────────────────┐ │
│ │ 图片提示词                           │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ 中景：┌🧑┐深陷沙发，左手持爆米花 │ │ │
│ │ │ 桶，右手拿披萨...               │ │ │
│ │ └─────────────────────────────────┘ │ │
│ ├─────────────────────────────────────┤ │
│ │ 视频提示词                           │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ ┌🧑┐瘫在沙发上，电视光映在脸上  │ │ │
│ │ └─────────────────────────────────┘ │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘

其中 🧑 表示内联角色头像标签（圆形小头像 + 角色名）
```

### 实现细节

**1. 移除角色标签行**

从基础信息区域的角色引用行（`shot-ref-row`）被移除

**2. 替换 textarea 为 div**

将 readonly textarea 替换为 div 容器，用于渲染带内联角色标签的提示词：

```HTML
<div class="shot-prompt-display shot-frame-image-prompt-display"></div>
<div class="shot-prompt-display shot-frame-video-prompt-display"></div>
```

**3. 内联角色渲染逻辑（内部实现，节选）**

将提示词文本中的【【角色名】】替换为内联角色头像标签：

```JavaScript
// 解析【【角色名】】并渲染为内联头像标签（内部实现，节选）
const pattern = /【【([^】]+)】】/g;
let lastIndex = 0;
let match;

while((match = pattern.exec(promptText)) !== null) {
  if(match.index > lastIndex) {
    displayEl.appendChild(document.createTextNode(promptText.substring(lastIndex, match.index)));
  }

  const charName = match[1].trim();
  const wc = worldChars.find(c => c.name === charName);
  const imgUrl = selectedUrl || wc.reference_image;

  // 创建内联角色标签（头像 + 名称）
  const chip = document.createElement('span');
  chip.className = 'shot-inline-char-chip';

  const avatar = document.createElement('img');
  avatar.className = 'shot-inline-char-avatar';
  avatar.src = imgUrl;
  chip.appendChild(avatar);

  const nameSpan = document.createElement('span');
  nameSpan.className = 'shot-inline-char-name';
  nameSpan.textContent = charName;
  chip.appendChild(nameSpan);

  // 点击打开角色图片选择器（内部实现）
  chip.addEventListener('click', (e) => {
    e.stopPropagation();
    打开角色图片选择器(wc, charName);
  });

  displayEl.appendChild(chip);
  lastIndex = match.index + match[0].length;
}

if(lastIndex < promptText.length) {
  displayEl.appendChild(document.createTextNode(promptText.substring(lastIndex)));
}
```

**4. CSS 样式**

```CSS
/* 内联角色标签 */
.shot-inline-char-chip {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  padding: 1px 6px 1px 1px;
  background: #ede9fe;
  border: 1px solid #ddd6fe;
  border-radius: 12px;
  cursor: pointer;
  vertical-align: middle;
  margin: 0 2px;
}

/* 内联角色头像 */
.shot-inline-char-avatar {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  object-fit: cover;
}

/* 内联角色名称 */
.shot-inline-char-name {
  font-size: 11px;
  color: #5b21b6;
  white-space: nowrap;
}
```

### 交互行为

1. **点击提示词区域**：打开提示词编辑模态框
2. **点击角色头像标签**：打开角色图片选择下拉框（内部实现）
3. **选择图片后**：更新已选角色参考图（内部状态），刷新提示词显示，头像右上角显示 ✓ 标记
4. **无参考图时**：显示默认用户图标（👤）

### 数据流

```Plaintext
提示词文本："中景：【【阿方索戴维斯_AlphonsoDavies】】深陷沙发..."
    ↓
内联角色渲染（内部实现）
    ↓
解析【【角色名】】模式
    ↓
与当前世界角色列表匹配获取头像
    ↓
渲染为：中景：<span class="shot-inline-char-chip">🧑 阿方索戴维斯</span>深陷沙发...
    ↓
点击头像标签 → 角色图片选择器 → 选择图片
    ↓
更新已选角色参考图 → 刷新提示词显示
```

## 在线缩略图服务

### API 接口

```Plaintext
GET /api/thumbnail?url={图片URL}&size={尺寸}
```

| 参数 | 说明 | 默认值 |
|-|-|-|
| `url` | 原始图片 URL（必填） | - |
| `size` | 缩略图尺寸（px） | 200 |

### 缓存策略

- **缓存目录**：本地缩略图缓存目录
- **文件命名**：`{size}_{URL的MD5哈希前12位}.jpg`
- **缓存时间**：1 年（Cache-Control: immutable）

### 多进程安全

使用原子写入避免多进程冲突（内部实现，节选）：

```Python
# 1. 检查缩略图是否已存在，存在则直接返回
# 2. 生成缩略图并写入唯一命名的临时文件（含进程号与时间戳）
# 3. 原子重命名临时文件为正式文件
```

**安全保障**：

- 进程崩溃：临时文件残留，不影响下次请求
- 多进程同时写：各自写各自的临时文件，原子重命名保证一致性
- 临时文件清理：超过 5 分钟的临时文件自动清理

### 前端调用

```JavaScript
// 获取缩略图URL（内部实现）
function getThumbnailUrl(imageUrl, size) {
    size = size || 40;
    if (!imageUrl) return '';
    if (imageUrl.startsWith('data:') || imageUrl.startsWith('blob:')) return imageUrl;
    return '/api/thumbnail?url=' + encodeURIComponent(imageUrl) + '&size=' + size;
}

// 使用示例：角色头像
avatar.src = getThumbnailUrl(imgUrl, 40);
```

### 性能优化

- 内联角色头像使用 40px 缩略图（原图可能几MB）
- 下拉选择器中的图片使用 40px 缩略图
- 添加 `loading="lazy"` 延迟加载

## 不存在角色的处理

### 问题背景

大语言模型生成的剧本中可能包含数据库中不存在的角色名称（如 `【【意大利教练】】`、`【【意大利球员】】`）。如果将这些不存在的角色也渲染成带图标的标签样式，会误导用户以为这些角色已被识别和匹配。

### 解决方案

在内联角色渲染逻辑中增加判断：

- **存在的角色**（当前世界角色列表中有匹配）→ 渲染为带头像的标签，支持点击选择图片
- **不存在的角色** → 直接显示纯文本 `【【角色名】】`，不渲染为标签样式

### 实现代码

```JavaScript
const wc = worldChars.find(c => c.name === charName);

// 如果角色不存在于数据库中，直接显示纯文本
if(!wc) {
  const textNode = document.createTextNode(match[0]);
  displayEl.appendChild(textNode);
  lastIndex = match.index + match[0].length;
  continue;
}
```

### 视觉效果对比

| 场景 | 之前 | 之后 |
|-|-|-|
| 存在的角色 | 🖼️ 角色名（带头像标签） | 🖼️ 角色名（带头像标签） |
| 不存在的角色 | 👤 角色名（带默认图标） | 【【角色名】】（纯文本） |

## 注意事项

1. **角色名称格式**：系统通过提示词硬约束与角色契约质检，要求大语言模型直接使用数据库中的实际名称（早期版本的服务端名称替换已移除）
2. **新角色处理**：如果剧本中的角色在数据库中不存在，`character_db_id` 为 `null`，`name` 使用大语言模型生成的名称
3. **匹配精度**：匹配基于大语言模型的判断，可能存在误匹配，建议在前端提供手动修正功能
4. **性能考虑**：角色列表通过分页读取完整列表后构建不可变契约快照（道具列表仍最多 50 个）
5. **名称备份**：`llm_name` 备用字段已移除；名称一致性由提示词约束与角色契约质检保障
6. **不存在的角色**：数据库中不存在的角色显示为纯文本 `【【角色名】】`，不会渲染成带图标的标签，避免误导用户
