Metadata-Version: 2.4
Name: minicpm-o5-sdk
Version: 0.0.6a0
Summary: MiniCPMO5 training data protocol toolkit
Author-email: Weiyue Sun <sunweiyue@modelbest.cn>
Maintainer-email: Weiyue Sun <sunweiyue@modelbest.cn>
License-Expression: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: jsonschema<5,>=4.20
Requires-Dist: pydantic<3,>=2.11
Requires-Dist: soundfile<1,>=0.14
Requires-Dist: torch>=2.0
Requires-Dist: tokenizers<1,>=0.20.4
Requires-Dist: transformers<6,>=5.0
Requires-Dist: watchfiles>=1.0
Provides-Extra: dev
Requires-Dist: pytest>=9.0; extra == "dev"
Requires-Dist: tinycss2>=1.5; extra == "dev"
Dynamic: license-file

# MiniCPMO5 SDK

MiniCPMO5 SDK 是面向 **MiniCPM-O 系列双工（duplex）训练数据协议**的 Python 工具包：把"一通对话"的多轨观测序列化成可训练的 token 流，并提供电子书式的可视化教程。

```text
你写的对话 / 观测
      ↓        构造五轨 TrainingData（schema 强校验）
O5DuplexTrainingData
      ↓        arrange()  把 start_trigger 解到 unit 网格
O5DuplexArrangement
      ↓        tokenize(tokenizer)  五轨 slot 模板展开
O5TokenizedDuplexData
      ↓        可视化（四主视图）
Guidebook / Dataset Viewer
```

> 当前版本 **0.0.3**：只覆盖**数据 + token 协议 + 质检 + 可视化**。
> 训练支持在 v0.1，推理支持在 v0.2。详见 [CHANGELOG.md](CHANGELOG.md)。

## 安装

```bash
pip install minicpm-o5-sdk
# 或从源码 editable 安装：
pip install -e .
```

依赖：python ≥ 3.10、pydantic ≥ 2.11、torch ≥ 2.0、transformers ≥ 5.0、tokenizers ≥ 0.20.4、jsonschema、watchfiles。

### 查询安装包构建来源

wheel / sdist 构建时会注入只读 build info；查询结果包含 distribution version、完整
Git commit、SDK 子树 Git tree、dirty 标志和 UTC 构建时间：

```python
from minicpm_o5_sdk import O5BuildInfoSource, get_build_info

build_info = get_build_info()
print(build_info.model_dump(mode="json"))

if build_info.source is O5BuildInfoSource.BUILD_ARTIFACT:
    assert build_info.git_commit is not None
    assert build_info.git_tree is not None
    assert build_info.dirty is False  # 公开 release 必须成立
```

直接运行源码或 editable 安装时不会伪造构建来源：
`source="editable_fallback"`，Git、dirty 和构建时间字段均为 `None`；`version`
来自已安装 distribution metadata，纯 `PYTHONPATH=src` 且未安装时为 `0+unknown`。

构建默认拒绝 dirty SDK release-owned 路径；范围仅包括运行时源码、package config、
README/CHANGELOG 和 release 文档，不受其他模块、测试或 DevMeta state 变化影响。
`build/`、`dist/`、`*.egg-info/`、`__pycache__/` 和 `*.pyc`/`*.pyo` 属于构建产物，
不参与 dirty 判定，也不会进入 wheel/sdist。
只有内部开发构建可显式设置
`MINICPM_O5_INTERNAL_ALLOW_DIRTY_BUILD=1`，生成的信息会如实记录 `dirty=true`；
公开发布必须设置 `MINICPM_O5_PUBLIC_RELEASE=1`，并且禁止同时使用 dirty 覆盖参数。

## 快速上手

### 五轨概览（每个 unit 内严格按此顺序展开 token）

| Track | 类型 | 说明 |
|---|---|---|
| `user_video` | timed | 用户视频帧（当前 0.0.x 不实现 content tokenize） |
| `user_audio` | timed | 用户语音 + 可选字级 alignment |
| `input_event` | text | 外部观测事件：tool_response / tool_created（框架注入）/ standalone event |
| `ai_spoken` | timed | AI 语音输出，**字级 `alignment` 必填**且 schema 校验拼接一致 |
| `ai_non_spoken` | text | AI 思考 / 工具调用（text + think + tool_call） |

### 直接加载内置 Case

SDK 随 wheel 分发一组 Guidebook 原子案例，可直接导入为 `O5DuplexTrainingData`：

```python
from minicpm_o5_sdk import O5CaseID, O5TokenizerID, load_case

training_data = load_case(O5CaseID.CASE_1_02)
result = training_data.tokenize(tokenizer_id=O5TokenizerID.O5)
```

脚本场景也可以用字符串 ID：

```python
from minicpm_o5_sdk import load_case

training_data = load_case("case_1_02")
```

可用 ID 通过 `list_case_ids()` 查看；当前开发线内置 12 个 active case。

Case 可以包含多个按教学顺序演进的 Variant。旧入口继续加载 canonical；需要指定变体时：

```python
from minicpm_o5_sdk import list_case_variants, load_case

print([
    variant.variant_id
    for variant in list_case_variants("case_1_02")
])
training_data = load_case(
    "case_1_02",
    variant_id="inter_round_pause",
)
```

源码仓库中另有 `examples/load_builtin_case.ipynb` 作为交互式教程；notebook
不随 wheel 打包，pip 用户可直接使用上面的代码片段或启动 Guidebook。

### 构造一条最小 TrainingData（用户问 → AI 答）

```python
import torch
from minicpm_o5_sdk import (
    O5AISpokenSegment, O5AISpokenTrack, O5AINonSpokenTrack,
    O5Alignment, O5DuplexTrainingData, O5DuplexTrainingTracks,
    O5GlobalTime, O5InputEventTrack, O5MediaSegmentTime,
    O5LazyAudio, O5SystemContent, O5SystemTextSegment,
    O5StartTrigger, O5UnitPolicy, O5UserAudioSegment,
    O5UserAudioTrack, O5WordInterval,
)

def alignment_for(text: str, dur: float) -> O5Alignment:
    return O5Alignment(word_intervals=[O5WordInterval(text=text, start_sec=0.0, end_sec=dur)])

training_data = O5DuplexTrainingData(
    unit_policy=O5UnitPolicy(
        unit_sec=1.0,
        non_spoken_budgets_while_listening=[100],
        non_spoken_budgets_while_speaking=[100],
    ),
    system=O5SystemContent(segments=[O5SystemTextSegment(text="你是一个友好的中文 AI 助手。")]),
    tracks=O5DuplexTrainingTracks(
        user_video=None,
        user_audio=O5UserAudioTrack(segments=[
            O5UserAudioSegment(
                audio=O5LazyAudio(
                    duration_sec=1.2,
                    get_tensor_fn=lambda: torch.zeros(int(1.2 * 16000), dtype=torch.float32),
                ),
                start_trigger=O5StartTrigger(refs=[O5GlobalTime()]),
                transcript="你好",
            ),
        ]),
        input_event=O5InputEventTrack(segments=[]),
        ai_spoken=O5AISpokenTrack(segments=[
            O5AISpokenSegment(
                audio=O5LazyAudio(
                    duration_sec=0.8,
                    get_tensor_fn=lambda: torch.zeros(int(0.8 * 16000), dtype=torch.float32),
                ),
                start_trigger=O5StartTrigger(refs=[
                    O5MediaSegmentTime(
                        track="user_audio",
                        segment_index=0,
                        at="end",
                        offset_sec=0.3,
                    ),
                ]),
                text="你好",
                alignment=alignment_for("你好", 0.8),
            ),
        ]),
        ai_non_spoken=O5AINonSpokenTrack(segments=[]),
    ),
)
```

### Tokenize

```python
from minicpm_o5_sdk import O5TokenizerID

result = training_data.tokenize(tokenizer_id=O5TokenizerID.O5)

assert result.tokenized_data.tokenizer_target == "o5"
for t in result.tokenized_data.token_provenance[:30]:
    print(t.token_text, t.track, t.unit_index, t.trainable)
```

### Arrangement 前估计 Token 下限

明显超长样本可先走无需 `arrange()` 的确定性下限：

```python
from minicpm_o5_sdk import O5TokenizerID

estimate = training_data.estimate_tokenization_lower_bound(
    tokenizer_id=O5TokenizerID.O5,
)
print(estimate.total_token_lower_bound)
print(estimate.content_tokens)
print(estimate.omitted_items)
```

`total_token_lower_bound` 保证不大于同一 tokenizer/serializer 下最终可成功生成的
`input_ids` 长度。它不是最终 tokenized length，也不决定训练最大长度；
Trigger/budget/Unit 落点等无法在编排前证明的增量会列在 `omitted_items`。
需要函数式组合时，可从顶层导入
`estimate_tokenization_lower_bound(training_data, tokenizer=...)`。

调用方已经完成 Layer1 Arrangement 时，可通过显式 keyword-only 参数复用，避免
`tokenize()` 再次 arrange：

```python
from minicpm_o5_sdk import O5TokenizerID, load_builtin_tokenizer

tokenizer = load_builtin_tokenizer(O5TokenizerID.O5)
arrangement = training_data.arrange(tokenizer=tokenizer)
result = training_data.tokenize(
    tokenizer=tokenizer,
    arrangement=arrangement,
)
```

`arrangement` 只接受 `arrange()` 的直接输出，不接受 `result.arrangement` 这类已经附加
token slices 的派生产物。SDK 会 fail-closed 校验 TrainingData 语义 SHA-256、tokenizer
target/完整 fingerprint、tool serializer、Arrangement 载荷 SHA-256、phase 和 Layer1
validation report；任一错配都拒绝。省略 `arrangement` 时，默认行为与原 API 完全一致。

**Tokenizer target**：`O5` 和 `O45_FC` 是当前 active line；`O45` 仅作为 deprecated shim 保留。两者基础词表和 token id 不可互换，调用方必须显式选 target：

```python
from minicpm_o5_sdk import O5TokenizerID

result = training_data.tokenize(tokenizer_id=O5TokenizerID.O45_FC)
```

### 推理代码指导示例

如果你要在推理代码里拼 system/tool prefill、解析 tool_call、写回 tool_response，
可参考源码仓库中的 `examples/inference_runtime_guide.ipynb`；该 notebook 不随 wheel
打包。

用户可见流式文本必须通过 SDK ordinary text decode stream 增量解码，不能逐
token 调用 `decode_ordinary([token_id])`：

```python
from minicpm_o5_sdk import O5TokenizerID, load_builtin_tokenizer

tokenizer = load_builtin_tokenizer(O5TokenizerID.O5)
stream = tokenizer.create_ordinary_text_decode_stream()

for token_id in generated_ordinary_token_ids:
    text_delta = stream.step(token_id)
    if text_delta is not None:
        emit_text_delta(text_delta)
```

每条连续文本流使用独立 stream；control token 不进入 stream。完整接入边界见
[`docs/engineering-practices/runtime-integration.md`](docs/engineering-practices/runtime-integration.md)。

### Token 查表与资料卡

如果你要确认当前 tokenizer target 下某个 token 的 id、语义 key、track、loss
或 `hf_added_special`，可参考源码仓库中的 `examples/token_lookup_guide.ipynb`；该
notebook 不随 wheel 打包。

它演示：

```python
tok.token_to_id("<|speak|>")
tok.id_to_token(248103)
tok.tokens_to_ids(["<ai_spoken_slot>", "<|speak|>"])
tok.ids_to_tokens([248159, 248103])
tok.token_info("<tool_call>")
tok.list_special_tokens(track="ai_non_spoken")
```

0.0.5 起，24 个 SDK extension token 已连续排列：O45_FC 为
`151748..151771`（模型至少 `151772` 行），O5 为 `248144..248167`
（模型至少 `248168` 行）。0.0.5a0/a1 的旧 tokenized artifact 与 checkpoint
不兼容新布局，必须重新 tokenize 或重新训练；SDK 不提供旧 ID remap/alias。

### 启动 Guidebook（电子书式教程，随 wheel 分发）

```bash
python -m minicpm_o5_sdk.visualizers.duplex.guidebook \
    --host 0.0.0.0 --port 10035 \
    --tokenizer-id o5
```

推荐并默认使用 `--tokenizer-id o5`。Guidebook 正文中的 token 数、budget 边界和 Unit
切分均以 O5 tokenizer 为发布基准。`o45_fc` 使用不同的 base tokenizer/BPE，只用于
兼容性验证；结果仍应 valid，但精确数量不保证与正文一致。

浏览器打开 `http://localhost:10035`。涵盖：
- Ch1 为什么需要双工
- Ch2 Token 协议
- Ch3 TrainingData 五轨
- Ch5 原子案例（当前 12 个 active case，含真实 TTS+CTC alignment）

### 启动 Dataset Viewer（任意数据集浏览）

```bash
python -m minicpm_o5_sdk.visualizers.duplex.dataset_viewer \
    /path/to/your/cases \
    --port 10036 --tokenizer-id o5 \
    --case-cache-size 100
```

每个 case 提供四个主视图：Interaction Timeline / Unit Grid / Causal Graph /
Trace Replay。Raw TrainingData、tokenized/provenance 与 issues 仍可在 Raw Sources、
详情和 fail-loud 告警中审计。`--tokenizer-id` 默认是 `o5`；如需按 O45_FC token id
查看，改为 `--tokenizer-id o45_fc`。

## 协议要点

- **`unit_sec` 协议自由度**：协议层允许任意值，默认 timing/budget profile 只保证
  **O5 训练规范主线 1s**；改用 0.5s 等其它值时必须同时提供匹配的 timing/budget。
- **start_trigger 时序声明**：每个 segment 用 `O5StartTrigger(refs=[...])` 表达启动条件；
  多 ref 是 AND 语义。AI target 在离散 `(unit, phase, position)` Gate 上解析，用户媒体
  target 保留精确用户时间，input_event 同时保留 raw user time 与 perceived Gate。
- **alignment 必填**：`ai_spoken.alignment` 是 schema 强 invariant，且 `"".join(wi.text for wi in word_intervals) == text` 严格相等（中文天然字级，英文把空格放进 `wi.text`）。
- **ai_spoken 采用 composite turn**：同 output unit，或 activation Gate 仍落在当前
  waveform output span 内的 source segment，保留 provenance 并顺延合并为一个 turn；
  prelook 不得跨 source activation Gate 泄漏未来文本。source 独立 encode 与 merged
  text 整体 encode 必须 BPE 等价，否则 fail-fast。
- **末 output unit 不丢失**：即使 prelook 后没有正文，仍生成
  `<|speak|> <|spoken_turn_eos|> <|spoken_slot_eos|>`；只有中间空 unit 使用
  `<|tts_pad|>`。
- **AI non-spoken 使用 unit-local decode cursor**：EOS 占 decode budget；
  `O5NoBudgetLimit` 不施加隐藏 token cap，timing 超窗会独立 fail-fast。
- **token 模板每 unit 五轨 slot 固定顺序**：`user_video → user_audio → input_event → ai_spoken → ai_non_spoken`。AI 两个输出 slot 必出现（空时分别走 `<|listen|>` / `<|no_action|>`）。

## 开发（贡献者）

```bash
PYTHONPATH=omni_agent_research/minicpm_o5_sdk/src \
  .venv/minicpmo5/bin/python -m pytest \
  omni_agent_research/minicpm_o5_sdk/tests/protocols/duplex -q
# 624 passed（0.0.6a0，2026-07-28）

PYTHONPATH=omni_agent_research/minicpm_o5_sdk/src \
  .venv/minicpmo5/bin/python -m mypy \
  omni_agent_research/minicpm_o5_sdk/src/minicpm_o5_sdk/

# Arranger 50/100/200/500 segment 扩展性基准
PYTHONPATH=omni_agent_research/minicpm_o5_sdk/src \
  .venv/minicpmo5/bin/python \
  omni_agent_research/minicpm_o5_sdk/scripts/benchmark_arranger_scalability.py \
  --scenario append --sizes 50,100,200,500 --repeats 3

# Trigger 因果链与 spoken dirty-suffix 基准
PYTHONPATH=omni_agent_research/minicpm_o5_sdk/src \
  .venv/minicpmo5/bin/python \
  omni_agent_research/minicpm_o5_sdk/scripts/benchmark_arranger_scalability.py \
  --scenario spoken_reflow --sizes 50,100,200,500 --repeats 3

# 真正受 spoken occupancy 影响的 non-spoken 局部后缀
PYTHONPATH=omni_agent_research/minicpm_o5_sdk/src \
  .venv/minicpmo5/bin/python \
  omni_agent_research/minicpm_o5_sdk/scripts/benchmark_arranger_scalability.py \
  --scenario spoken_dirty_suffix --sizes 50,100,200,500 --repeats 3

.venv/minicpmo5/bin/python -m build \
  --wheel --outdir omni_agent_research/minicpm_o5_sdk/dist \
  omni_agent_research/minicpm_o5_sdk
```

Arranger benchmark 的工作量指标、父提交对照与 profiler 结论见
[docs/engineering-practices/arranger-performance.md](docs/engineering-practices/arranger-performance.md)。

发版前检查：[docs/before_release_checklist.md](docs/before_release_checklist.md)。
当前源码与 package metadata 均为 `0.0.6a0`；该 alpha wheel / sdist 只用于
0.0.6 发布前验证，不作为 PyPI 或飞书公开推荐版本。构建说明见
[docs/releases/0.0.6a0.md](docs/releases/0.0.6a0.md)。
0.0.5 状态路由：[docs/0.0.5-milestone.md](docs/0.0.5-milestone.md)（仅索引；
实时状态查询 DevMeta Release Manifest / Issue）。
0.0.5 历史 Milestone：
[docs/archive/0.0.5-milestone.md](docs/archive/0.0.5-milestone.md)（只读，不再是状态源）。
内部 0.0.5 wheel 交接：[docs/0.0.5-local-wheel-handoff.md](docs/0.0.5-local-wheel-handoff.md)。
飞书 latest 使用入口本地源：[docs/feishu/latest_usage_entry.md](docs/feishu/latest_usage_entry.md)。
0.0.1 历史里程碑：[docs/0.0.1-milestone.md](docs/0.0.1-milestone.md)。
变更日志：[CHANGELOG.md](CHANGELOG.md)。
v0.0.1 之后的剩余 follow-up：[docs/future-work.md](docs/future-work.md)。

## 当前不支持

- 多模态 event content（音频/图像）序列化：当前 0.0.x 入口抛 `NotImplementedError`。
- 用户音频 tensor 合成：`O5DuplexTrainingData.build_user_audio_tensor()` 返回按全局 unit timeline 补 0 后的 `O5UserAudioTensor`，供训练 adapter 从同一 canonical waveform 中切 unit/window。
- 训练 / 推理脚本：分别在 v0.1 / v0.2 引入。
- abort（pending lifecycle）：当前 0.0.x 保留 `<|non_spoken_abort|>` token 和
  `abort_trigger` schema，但暂不在 arranger/tokenization 中生产 abort token。

## 类型提示

wheel 内包含 `py.typed`，IDE / pyright / mypy 可以识别 SDK 自带的 inline type hints。常用 public API 推荐从顶层导入，例如 `from minicpm_o5_sdk import O5DuplexTrainingData, load_case`；内部实现路径后续重构时会通过顶层 facade 保持用户侧 import 稳定。

## 版权与许可

Author / maintainer: Weiyue Sun <sunweiyue@modelbest.cn>.

Copyright (c) 2026 ModelBest Inc.

本 SDK 使用 MIT License 发布。详见 [LICENSE](LICENSE)。
