# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> 根 [CLAUDE.md](../../../../CLAUDE.md) 描述整体架构(lego 框架、反射注册路由、pb 生成);本文件只补充 `echomeet` 模块自身。
## 模块职责
`echomeet` 是「会议记录」模块:上传音频 → **转写(Transcribe)** → **翻译(Translate)** → **AI 总结(Summary)**,产出 `overview`(短总结)+ `summary`(按模板的完整纪要)。由 `home` 服务装载运行([services/home/main.go](../../services/home/main.go)),不是独立服务。HTTP 接口经反射注册为 `echomeet_<方法名小写>`,前缀路由示例 `/api/home/echomeet_addrecord`。
## 能力下发(客户端界面数据来源)
客户端不写死语言表和模型表,一律拉 `echomeet_getcapabilities`([api_getcapabilities.go](api_getcapabilities.go)):
- `transcribe_languages` / `translate_languages`:后台编排里在用的识别/翻译服务 `languages` 字段并集,按 Priority 顺序去重;对应 `*_any_language=true` 时该段不限语言(有服务没配 `languages`),列表只是"明确声明过的",不能当白名单用。
- `summary_services` / `client_choice`:同 `echomeet_getsummaryservices`(旧接口保留兼容,是本接口的子集)。
数据口径与选路同源(都读 `resolvedSvc.Languages`,见 [providers.go](providers.go) 的 `langsOf` 与 `svcSupportsLanguage`),所以下发的语言必定选得出服务。后台改配置经 NATS `ConfigChanged` 即时重载,10 分钟 cron 兜底。
同一份数据还经 `comm.IEchomeet`([module.go](module.go) 的 `MeetCapabilities`)给同进程的 user 模块,
拼进 `user_getappconfig_v3` 的 `meet` 字段——客户端启动只拉一次 v3 即可,无需再单独调本模块接口。
改这里的口径要记得两处出口是同一份实现,不会漂移。
## 核心设计:一会一服务商,三段同源
每条会议记录在 [providers.go](providers.go) 的 `ResolveServiceType` 解析出**唯一服务商**,转写/翻译/总结三步都走它,结果记在 `record.ServiceType`。解析优先级:
1. 请求显式 `service_type` →
2. 模块配置 `DefaultService` →
3. 兜底按源语言:字节支持该语言走字节,否则阿里。
四套服务商,每套三件套(见对应 `provider_*.go`):
| ServiceType | 转写 | 翻译 | 总结(Summarizer) |
|---|---|---|---|
| ByteDance | `sys/bytedance/audomodel` | 火山翻译 | 豆包 `sys/doubao` |
| Ali | DashScope `sys/aliyun/filetrans` | 阿里 MT | 通义千问(`sys/openai` 兼容端点)|
| Google | Cloud Speech | Cloud Translation | Gemini `sys/google/gemini` |
| Microsoft | Azure Speech | Azure Translator | Azure OpenAI(`sys/openai` 兼容端点)|
三类能力抽象为 `Transcriber` / `Translator` / `Summarizer` 接口(providers.go),运行期 `GetTranscriber/GetTranslator/GetSummarizer(svcType, language)` 按 ServiceType 路由。新增服务商 = 写一个 `provider_xxx.go` 实现三接口 + 在 `providersComp.Init` 注册进三张 map。
## 组件与异步流水线
`module.go` 装配组件:`api`(HTTP) / `model`(gorm) / `cache`(模板缓存) / `providers` / `tasks`。
[tasks.go](tasks.go) 是异步引擎:**Redis 队列 + ants 协程池 + cron 每秒调度**。
- 两条流水线,各有「等待队列 / 在执行集合」两个 list:短音频转写(`keyShortAwait/Proc`)、AI 总结(`keyAIAwait/Proc`)。队列 key 用 `service.GetTag()` + `redissys.RKey`(应用分组前缀)命名空间隔离,多应用共用一个 Redis 不串扰。
- `TaskScheduling`(cron `*/1 * * * * ?`)每秒把等待队列搬进执行集合(受 `MaxShortAudioProcess`/`MaxAIAwaitProcess` 限流),再 `pool.Invoke` 真正处理。
- **转写完成三条路径**:异步回调(字节 [api_backcall.go](api_backcall.go) / 阿里 [api_alibackcall.go](api_alibackcall.go))+ 客户端拉取时主动轮询(`PollTranscribe`,由 [api_getrecord.go](api_getrecord.go)/[api_getrecords.go](api_getrecords.go) 在状态为 Transcribing 时驱动,`TranscribeQueryMinInterval` 限频)+ **cron 兜底扫描**(`SweepStuckTasks`,每分钟)。
⚠️ 兜底是 2026-09-06 补的,之前只有前两条:回调没到 + 客户端不轮询时记录会**永远卡在 Transcribing**,没有任何自愈(真机上卡了 8 分钟,直到用户杀 App 重开)。业务在服务端跑,不该由客户端在不在线决定它推不推进。
兜底做两件事:① 转写中且 60s 无人查询 → 主动 `PollTranscribe`;超过 2h 未完成判 `TranscribeFail`(否则第三方丢单的记录会被无限查下去)。② 停在待总结/总结中超 5 分钟且**不在 AI 队列里** → 重新入队;「不在队列里」这个前提不能省,正在跑的重复入队 = 两次大模型计费且后者覆盖前者。
- **两处返回中间态的坑**:`PollTranscribe` 成功后先把 State 推到 `AwaitSummarizing` 落库,再 `go finishTranscribe(...)` 在后台写 `Translate`(刻意不共用指针,避免与 HTTP 响应序列化产生数据竞争)。于是 getrecord/getrecords 手上那个待序列化的 rec **必然**是「状态说转写完了、Translate 还是空」的中间态——不是偶发竞态,是每次首轮必中。客户端据此 `jsonDecode(translate)` 会抛异常。三个拉取入口统一用 `hideHalfDoneTranscribe` 在响应侧降级回 `Transcribing`(只改响应不落库,且只对本次真正轮询过的记录降级,否则 finishTranscribe 出错时记录会被永久钉住)。
- 短音频走字节 flash 同步通道(`ShortAudioProcess`);长音频/其他服务商走异步 submit+query。
状态机见 `DBEchoMeetRecordState`(proto):Unknow→AwaitTranscribing→Transcribing→AwaitSummarizing→Summarizing→Completed→Readed,失败 TranscribeFail/SummarizFail。转写失败会**退还** `Meetintegral` 时长并冲正统计埋点。
## 语言码映射(core.go,易错)
输入统一是客户端 BCP-47(`zh-CN`/`en-US`…)。每个平台格式不同,core.go 逐家维护 map。**铁律:未识别语言一律返回原值,绝不 fallback 成 `zh`**——否则「未知语言→中文」会藏在调用栈深处。翻译时在 `TranslateProcess` 直接传 BCP-47,由各 provider 内部转换,不要提前转(会被二次转换误判)。
## AI 总结输入约定(含图片)
`AIProcess`(tasks.go)拼 `originalText`:把 `translate`(已翻译的分句)按 `[说话人]:内容` 拼接;若 `record.Remark`(用户创建时填的备注)非空,用语言中立的结构化标签包裹:`…\n\n\n…\n`——**不要硬编码某语言的提示词**,避免污染多语言总结。模板 `template.Template`/`template.Outline` 作为 system prompt,分别产出 `summary`/`overview`(两路并发)。
**图片注释(支持多张)**:请求 `EchomeetAddRecordReq.imageurls` 是 `repeated string`,DB `DBEchoMeetRecord.imageurls` 是 **string 列存 JSON 数组**(编码方式同 `original`:`utils.ToString` 写、`json.Unmarshal` 读)。`AIProcess` 解析出 url 列表后,作为多模态内容随 user 消息一并发给总结模型(要求所配模型支持视觉)。链路:`ChatMessage.Images []string` → 各 `Summarizer.Chat` → sys 层(豆包/通义/Azure 用 OpenAI 风格 `image_url` 直传;Gemini 在 `sys/google/gemini` 内逐张下载转 base64 内联 `inlineData`,单张失败仅跳过)。列表为空时完全走原文本路径,不影响既有行为。
## 重新生成 pb
改了 `apps/proto/echomeet/*.proto` 后需重生成(protoc-gen-go v1.36.6,已安装),并复刻源项目 `pb.py` 的 `@go_tags` 注入(覆盖 json 去掉 omitempty、追加 gorm/自定义 tag):
```bash
cd apps/services
protoc --go_out=pb --go_opt=paths=import -I ../proto ../proto/echomeet/echomeet_db.proto
protoc --go_out=pb --go_opt=paths=import -I ../proto ../proto/echomeet/echomeet_msg.proto
# 再对生成的 pb/echomeet_*.pb.go 执行 @go_tags 注入(见根 CLAUDE.md / 源项目 pb.py)
```
注意:HTTP 层用 jsoniter 按 **json tag** 收发、DB 用 **gorm tag**,protobuf 二进制 wire 不参与这些 API,所以字段的 json/gorm tag 必须正确。