feat(asr): 本地语音转写接入为一级路由 + 并行工作流合并提交

按用户指示做**一包提交**,不按工作流拆分。本提交刻意混合了多条并行线:

  · 本地 ASR 接管:audio 成为与 chat/embed/image/video 同等的路由类别
    (IsLocalRoute 单一判据、audio 健康探测、default_audio_route、
    auto 占位、GET /api/ai/routes/audio、回退云端时界面明示「音频已出网」)
  · LLM 调用层:ctx 贯穿、ToolCall/ToolSchema、EmptyCompletionError /
    TransientUpstreamError(按错误类型而非文案判重试)
  · 编排 Agent:general_assistant orchestrate/persistence/spec_driver
  · 联网搜索:internal/search(playwright)
  · 网盘:backend + 前端
  · 前端 UI:导航/路由/工作台若干页
  · 交付文档:DELIVERY.md / AR04 / 部署文档的「无 Python」表述据实改写,
    新增 eai_agentplatform-asr.service、asr.env、clonezilla-cleanup 清 ~/asr-poc

不分拆的原因:dev 早期,粒度不该打断工作节奏。且实测过——这些改动
**在编译上是同一个单元**(llm.go 的 ctx 签名变更牵动 12 个调用点,
chat_message.go 的 ctx 改动又与编排重写同处一个 hunk),拆出来的中间态编不过。
详见 TOP_CODING_RULES.md G14.5 与 bugs_and_errors.md E09。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-26 22:21:39 +08:00
co-authored by Claude Code
parent e73169df50
commit c1af86c934
192 changed files with 18046 additions and 392 deletions
@@ -0,0 +1,283 @@
package audiotranscribe
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"eai_agentplatform/backend/internal/config"
)
// 这一组用例钉的是「主路由不行时换一条,但不是什么情况都换」。
//
// 为什么值得单独写:回退是这个功能里**唯一会把音频多送一次出门**的地方。
// 判定写宽了,一个格式不对的文件会被白送一次到公网 ASR(隐私代价换不到成功率);
// 写窄了,本地 ASR 一挂用户就拿到硬失败,而「本地不可用时自动回退云端」
// 正是当初选这个方案的全部理由。
//
// 全程留在本机:链是注入的(transcribeChainFn),两个终点都是 httptest。
// 真实配置里的回退目标是公网 ASR,拿它来测等于把用户的录音当测试数据发出去。
// audioOKBody 是后端认得的 ASR 响应形状(transcribe.go 顶部注释里那份)。
const audioOKBody = `{"duration":12.5,"text":"说话人0:测试转写内容",
"segments":[{"speaker":"0","start":0,"end":12.5,"text":"测试转写内容"}],
"usage":{"type":"duration","seconds":12.5}}`
// stubRoute 造一条指向本机 httptest 的路由。
// BaseURL 取服务地址(127.0.0.1:port),于是 IsLocalRoute 判为本地 ——
// 这既省掉密钥要求,也让「音频没出本机」这条断言同时也是对 IsLocalRoute 的检验。
func stubRoute(id string, srv *httptest.Server) *config.RouteConfig {
return &config.RouteConfig{
RouteID: id,
Provider: "local_asr",
Model: "large-v3",
BaseURL: srv.URL,
Endpoint: "/audio/transcriptions",
FullURL: srv.URL + "/audio/transcriptions",
Category: "audio",
TimeoutSeconds: 10,
}
}
// withChain 把 TranscribeBytes 用的链路换成给定的几条,并在用例结束后还原。
func withChain(t *testing.T, routes ...*config.RouteConfig) {
t.Helper()
prev := transcribeChainFn
transcribeChainFn = func(string) ([]*config.RouteConfig, error) { return routes, nil }
t.Cleanup(func() { transcribeChainFn = prev })
}
// newStub 起一个桩:按 status 作答,200 时回 audioOKBody;delay > 0 则先睡一下
// (用来造超时)。hits 非 nil 时累计被打次数。
func newStub(t *testing.T, status int, hits *int32, delay time.Duration) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if hits != nil {
atomic.AddInt32(hits, 1)
}
if delay > 0 {
time.Sleep(delay)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if status == http.StatusOK {
_, _ = w.Write([]byte(audioOKBody))
return
}
_, _ = w.Write([]byte(`{"error":"stub"}`))
}))
t.Cleanup(srv.Close)
return srv
}
// deadRoute 造一条「连不上」的路由:起一个 httptest 再立刻关掉,端口就没人听了。
// 这比随便挑一个端口可靠 —— 那个端口可能真的被别的东西占着。
func deadRoute(t *testing.T, id string) *config.RouteConfig {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
url := srv.URL
srv.Close()
return &config.RouteConfig{
RouteID: id, Provider: "local_asr", Model: "large-v3",
BaseURL: url, Endpoint: "/audio/transcriptions",
FullURL: url + "/audio/transcriptions", Category: "audio",
TimeoutSeconds: 5,
}
}
func TestTranscribeFallsBackWhenPrimaryIsUnreachable(t *testing.T) {
var secondaryHits int32
secondary := newStub(t, http.StatusOK, &secondaryHits, 0)
primary := deadRoute(t, "audio_route_dead_local")
withChain(t, primary, stubRoute("audio_route_backup", secondary))
res, err := TranscribeBytes(0, []byte("fake-audio"), "a.mp3", "mp3", "zh", "")
if err != nil {
t.Fatalf("主路由连不上时应该回退并成功,却报错:%v", err)
}
if res.RouteID != "audio_route_backup" {
t.Errorf("RouteID = %q,期望回退到 audio_route_backup", res.RouteID)
}
if !res.FellBack {
t.Error("FellBack = false,但这次确实换了路由 —— 界面据它提示用户,不能漏")
}
if res.PrimaryRouteID != "audio_route_dead_local" {
t.Errorf("PrimaryRouteID = %q,期望 audio_route_dead_local", res.PrimaryRouteID)
}
if res.FallbackReason == "" {
t.Error("FallbackReason 为空 —— 主路由为什么不行必须留痕,否则没法排障")
}
if !res.IsLocal {
t.Error("IsLocal = false,但回退到的那条是回环地址 —— 判据应取实际服务的那条路由")
}
if atomic.LoadInt32(&secondaryHits) != 1 {
t.Errorf("回退路由被打了 %d 次,期望 1 次", secondaryHits)
}
}
func TestTranscribeFallsBackOnServerError(t *testing.T) {
var secondaryHits int32
secondary := newStub(t, http.StatusOK, &secondaryHits, 0)
primary := newStub(t, http.StatusServiceUnavailable, nil, 0)
withChain(t, stubRoute("audio_route_503", primary), stubRoute("audio_route_backup", secondary))
res, err := TranscribeBytes(0, []byte("fake-audio"), "a.mp3", "mp3", "zh", "")
if err != nil {
t.Fatalf("主路由 503 时应回退,却报错:%v", err)
}
if !res.FellBack || res.RouteID != "audio_route_backup" {
t.Errorf("期望回退到 backup,实际 route=%s fell_back=%v", res.RouteID, res.FellBack)
}
}
// TestTranscribeDoesNotFallBackOnBadRequest 是这一组里最重要的一条。
//
// 400 意味着「这份文件本身不行」。换一条路由重发同一个文件不会变好,
// 唯一确定的后果是这份录音又出本机一次 —— 拿隐私换不到任何成功率。
func TestTranscribeDoesNotFallBackOnBadRequest(t *testing.T) {
var secondaryHits int32
secondary := newStub(t, http.StatusOK, &secondaryHits, 0)
primary := newStub(t, http.StatusBadRequest, nil, 0)
withChain(t, stubRoute("audio_route_400", primary), stubRoute("audio_route_backup", secondary))
_, err := TranscribeBytes(0, []byte("fake-audio"), "a.mp3", "mp3", "zh", "")
if err == nil {
t.Fatal("400 应当直接报错,不该回退")
}
if !strings.Contains(err.Error(), "400") {
t.Errorf("错误里应带上原始状态码,实际:%v", err)
}
if n := atomic.LoadInt32(&secondaryHits); n != 0 {
t.Errorf("回退路由被打了 %d 次 —— 400 时音频不该再出一次本机", n)
}
}
// TestTranscribeDoesNotFallBackOnEmptyText 空白音频由主路由判定即可。
// 换一条大概率还是同一句话,而代价是把音频再送一次出去。
func TestTranscribeDoesNotFallBackOnEmptyText(t *testing.T) {
var secondaryHits int32
secondary := newStub(t, http.StatusOK, &secondaryHits, 0)
empty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"duration":0,"text":"","segments":[]}`))
}))
t.Cleanup(empty.Close)
withChain(t, stubRoute("audio_route_empty", empty), stubRoute("audio_route_backup", secondary))
if _, err := TranscribeBytes(0, []byte("fake-audio"), "a.mp3", "mp3", "zh", ""); err == nil {
t.Fatal("空文本应当报错")
}
if n := atomic.LoadInt32(&secondaryHits); n != 0 {
t.Errorf("回退路由被打了 %d 次 —— 空文本不该触发回退", n)
}
}
// TestTranscribeDoesNotFallBackOnTimeout 超时不吃回退。
//
// 一次超时意味着主路由的预算是整段耗尽的;接着把同一份长音频发给下一条,
// 结果多半是用户那边先超时(前端 axios 15 分钟),而音频已经出门了。
func TestTranscribeDoesNotFallBackOnTimeout(t *testing.T) {
var secondaryHits int32
secondary := newStub(t, http.StatusOK, &secondaryHits, 0)
slow := newStub(t, http.StatusOK, nil, 1500*time.Millisecond)
primary := stubRoute("audio_route_slow", slow)
primary.TimeoutSeconds = 1
withChain(t, primary, stubRoute("audio_route_backup", secondary))
start := time.Now()
if _, err := TranscribeBytes(0, []byte("fake-audio"), "a.mp3", "mp3", "zh", ""); err == nil {
t.Fatal("超时应当报错")
}
if n := atomic.LoadInt32(&secondaryHits); n != 0 {
t.Errorf("回退路由被打了 %d 次 —— 超时不该触发回退", n)
}
if elapsed := time.Since(start); elapsed > 1400*time.Millisecond {
t.Errorf("耗时 %v:超时后还在等别的路由", elapsed)
}
}
// TestTranscribeReportsEveryRouteTriedWhenAllFail 全部失败时,错误要列出试过哪些。
// 只说「服务不可达」而不说试过哪几条,排障时看不出回退链有没有真的跑起来。
func TestTranscribeReportsEveryRouteTriedWhenAllFail(t *testing.T) {
withChain(t,
deadRoute(t, "audio_route_dead_a"),
deadRoute(t, "audio_route_dead_b"),
)
_, err := TranscribeBytes(0, []byte("fake-audio"), "a.mp3", "mp3", "zh", "")
if err == nil {
t.Fatal("两条都连不上时应当报错")
}
for _, want := range []string{"audio_route_dead_a", "audio_route_dead_b"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("错误里没提到试过的路由 %s:%v", want, err)
}
}
}
// TestTranscribeLocalRouteNeedsNoAPIKey 本地回环路由不该因为「没有密钥」被拦下。
// 这是接入本地 ASR 时必须松掉的那一处硬检查(原实现是 route.APIKey == "" 直接报错)。
func TestTranscribeLocalRouteNeedsNoAPIKey(t *testing.T) {
srv := newStub(t, http.StatusOK, nil, 0)
r := stubRoute("audio_route_local_nokey", srv)
if r.APIKey != "" {
t.Fatal("用例前提:这条路由不该有密钥")
}
withChain(t, r)
res, err := TranscribeBytes(0, []byte("fake-audio"), "a.mp3", "mp3", "zh", "")
if err != nil {
t.Fatalf("本地回环路由无密钥应当放行,却报错:%v", err)
}
if !res.IsLocal {
t.Error("IsLocal = false,但这条路由的 base_url 是回环地址")
}
// 顺带确认响应被真的解析了,而不是「没报错所以算过」。
if res.Text == "" || len(res.Segments) == 0 {
t.Errorf("转写结果没解析出来:text=%q segments=%d", res.Text, len(res.Segments))
}
}
// TestTranscribeRemoteRouteStillNeedsAPIKey 非本地路由缺密钥仍要提前报错:
// 打过去只会拿到 401,提前报错比让用户等一轮网络往返好。
func TestTranscribeRemoteRouteStillNeedsAPIKey(t *testing.T) {
r := &config.RouteConfig{
RouteID: "audio_route_remote_nokey", Provider: "siliconflow", Model: "x",
BaseURL: "https://api.example.com/v1", Endpoint: "/audio/transcriptions",
FullURL: "https://api.example.com/v1/audio/transcriptions",
Category: "audio", TimeoutSeconds: 10,
}
withChain(t, r)
_, err := TranscribeBytes(0, []byte("fake-audio"), "a.mp3", "mp3", "zh", "")
if err == nil {
t.Fatal("非本地路由缺密钥应当报错")
}
if !strings.Contains(err.Error(), "API Key") {
t.Errorf("错误应说明缺密钥,实际:%v", err)
}
}
// 保证 audioOKBody 与解析结构一致:改了响应形状而没改这里,上面那些用例
// 会以「解析失败」的形式红,而不是悄悄变绿。
func TestAudioOKBodyParses(t *testing.T) {
var out struct {
Text string `json:"text"`
Segments []struct {
Speaker string `json:"speaker"`
} `json:"segments"`
}
if err := json.Unmarshal([]byte(audioOKBody), &out); err != nil {
t.Fatalf("桩响应不是合法 JSON:%v", err)
}
if out.Text == "" || len(out.Segments) == 0 || out.Segments[0].Speaker == "" {
t.Fatalf("桩响应缺少 text/segments/speaker:%+v", out)
}
}
@@ -0,0 +1,40 @@
package audiotranscribe
import (
"fmt"
"path/filepath"
"strings"
"eai_agentplatform/backend/internal/config"
"eai_agentplatform/backend/internal/model"
)
// MediaFilePath 按素材审批状态返回磁盘物理路径。
//
// 与 internal/api/media.go 的 mediaPathFor 同口径(approved / pending / rejected
// 三个子目录)。没有直接复用是因为那边不导出、且属于 HTTP 层;
// 两处若将来分叉,读到的就是「审批后消失」的素材,这里刻意保持同构。
func MediaFilePath(m model.MediaFile) (string, error) {
name := strings.TrimSpace(m.StoredName)
if name == "" {
// 老记录可能只写了 StoredPath;上传时两者写的是同一个值(media.go 的上传分支)。
name = strings.TrimSpace(m.StoredPath)
}
if name == "" {
return "", fmt.Errorf("素材 %d 没有记录存储文件名", m.ID)
}
// 只接受纯基名:StoredName 来自数据库,正常不含分隔符,但拼路径前必须挡一道,
// 否则一条脏记录就能让接口读到 KBDataDir 之外的文件。
if filepath.Base(name) != name || name == "." || name == ".." {
return "", fmt.Errorf("素材 %d 的存储文件名非法:%q", m.ID, name)
}
sub := "pending"
switch strings.TrimSpace(m.Status) {
case "approved":
sub = "approved"
case "rejected":
sub = "rejected"
}
return filepath.Join(config.Load().KBDataDir, sub, name), nil
}
@@ -0,0 +1,367 @@
package audiotranscribe
import (
"context"
"errors"
"fmt"
"strings"
"time"
"eai_agentplatform/backend/internal/ai"
"eai_agentplatform/backend/internal/config"
)
// Kind 逐字稿之后的两个 LLM 加工步骤。
type Kind string
const (
// KindStructure 第 3 步:整理段落与重点 → 结构化纪要
KindStructure Kind = "structure"
// KindMinutes 第 4 步:提炼可交付纪要 → 纪要 + 行动项
KindMinutes Kind = "minutes"
)
// llmChunkChars 单次喂给模型的逐字稿字数上限。
//
// 一小时中文会议逐字稿约两万字,整段塞进去既慢又容易撞上游上下文上限;
// 按段落切块逐块加工,多块时再做一次归并。
//
// 这个数字由**实测的完成预算**倒推,不是拍脑袋定的。测量对象是当前的默认对话路由
// (LMUAI / deepseek-v4-flash)—— 它是个推理模型,思考与正文共用 max_tokens,
// 而**思考的长度跟输入几乎不成比例**,这是整件事最反直觉的地方:
//
// 输入 max_tokens 思考 正文 finish_reason
// 2500 字 4096 7187 字 0 字 length
// 2500 字 4096 7332 字 0 字 length
// 1200 字 4096 7169 字 56 字 length
// 1200 字 4096 4509 字 1161 字 stop
// 600 字 4096 3972 字 612 字 stop
// 600 字 4096 5667 字 583 字 stop
// 1200 字 8192 6030 字 1209 字 stop
// 2500 字 8192 11286 字 2528 字 stop
//
// 三条结论,都反直觉,写在这里免得后人重走一遍:
//
// 1. **把块切小救不了预算**。输入从 2500 字砍到 1200 字,思考仍是 7169 字 ——
// 思考有个约 4000 字的地板,而且会随预算水涨船高(给到 8192 就涨到 11286 字)。
// 所以「失败就把这一块对半切了重试」在这种模型上是白费调用,不要加。
// 2. **4096 的预算根本不够**:六次里只有一次写出正文,其余全被思考吃光,
// 响应里连 content 字段都没有。这类步骤要产出一整篇文档,必须给到 8192 以上。
// 3. **1200 是按当时 8192 的天花板定的,不是这个模型的性质**:1200 字连打 11 段,
// 完成 token 落在 3699–6861(均值 4928),最坏一次吃掉 8192 的 84%,只剩 16% 余量;
// 而思考的实测跨度有近两倍(4942–10913 字),16% 的余量挡不住下一次波动。
// 2500 字在 8192 下曾用掉 7722(94%),离截断只差一次思考波动。
// —— 后来归并那一步逼着把天花板抬到了 32768(见 kindSpec.joinOnly),
// 同一批 11 段在 32768 下重测:用时 19.2s、completion 均 5094,与 8192 档
// (18.6s / 4928)基本一致 —— **上限是天花板不是配额**,抬高它不会让每次调用变贵。
// 也就是说现在余量足了,llmChunkChars 若要调大是可以的;本轮没动它,
// 是为了让「抬高上限」这一个改动单独可验。真要调,请连 32768 一起复测。
//
// 所以:**调小这个数之前,先确认路由的 max_tokens 够**(要 16384 以上:归并那一步
// 比逐块加工更吃预算),否则拿到的是断稿,或者干脆什么都没有。
//
// 还有一条与预算无关的耦合:**这个数直接决定前端要等多久** —— 块数 = 逐字稿字数 /
// llmChunkChars,而第 3、4 步是同步 HTTP,前端只能干等。一次真实的 26:41 录音
// (11936 字 → 10 块)跑完四步共 589.54s,其中第 3、4 步合计 532s;前端的超时上限
// 因此从 5 分钟放宽到了 10 分钟(见 frontend/src/api/audioSkill.js)。
// 把这个数改小会让块数变多、总时长变长,改大则相反 —— 两头都会动到那条上限。
const llmChunkChars = 1200
// kindSpec 一个加工步骤的文案(分块提示词 + 归并提示词)。
//
// joinOnly 决定多块时怎么收口,这是**按步骤性质**分的,不是省一次调用:
// - 结构化稿:输出与输入等长。让模型归并 N 段的结果,等于要它一次吐出整篇
// 逐字稿那么长的正文 —— 26 分钟的会议稿约 9500 字,光正文就要约 5700 token,
// 再叠加思考,8192 的预算根本不够。而这些分段结果**本身已经是结构化的**,
// 直接按序拼起来就是一份完整稿,信息一点不少 —— 代价只是跨块的同一个议题
// 可能各起了一个小标题,属于观感问题,远好过截断。
// - 纪要:输出远短于输入,归并正是它的价值(跨段去重、按时序排序),
// 所以照旧让模型归并。
//
// 但「纪要输出短」推不出「归并这一步不费预算」—— 费的是**输入**:归并的输入是
// N 段结果全文拼接,是全流程最长的一次。实测 14 段(8628 字输入):
//
// 归并一次(14 段拼接,8628 字输入)
// max_tokens=8192 finish=length 思考 14678 字 正文 0 字 ← 整步就此失败
// max_tokens=16384 finish=stop 思考 12576 字 正文 5294 字
// max_tokens=32768 finish=stop 思考 3139 字 正文 6927 字 ← 更快、更省
//
// 这就是「第 3 步能过、第 4 步必挂」的原因:第 3 步是 joinOnly 不收口,
// 第 4 步要收口,而收口那一次撞在旧天花板上。天花板已按这张表抬到 32768。
type kindSpec struct {
system string
instruction string
joinOnly bool
mergeSystem string
mergeRequest string
}
func specFor(kind Kind) (kindSpec, error) {
switch kind {
case KindStructure:
return kindSpec{
system: "你是会议音频整理助手。你会收到一段语音转写的逐字稿,它的断句和标点可能有误。",
instruction: `请把这段逐字稿整理成可阅读的结构化稿。要求:
1. 按议题或话题重新分段,每段起一个简短小标题;
2. 保留结论、数字、人名、产品名、时间等关键信息,不要概括掉;
3. 可以修正明显的同音错别字(人名、术语),但不得添加原文没有的内容;
4. 直接输出整理后的 Markdown 正文,不要写「以下是整理结果」这类开场说明。`,
joinOnly: true,
}, nil
case KindMinutes:
return kindSpec{
system: "你是会议纪要助手。你会收到一份会议/访谈的整理稿。",
instruction: `请把它提炼成可以直接分发的会议纪要。要求:
1. 开头用「## 核心结论」列出 3-5 条最重要的结论;
2. 用「## 决议事项」逐条列出已达成的决定;
3. 用「## 行动项」输出 Markdown 复选列表,格式为「- [ ] 事项 —— 负责人 —— 截止时间」;
原文没有提到负责人或时间时写「待定」,**不要编造**;
4. 只输出 Markdown 正文,不要写开场说明。`,
mergeSystem: "你是会议纪要助手。你会收到同一场会议分段提炼出的多份纪要草稿。",
mergeRequest: "请把它们合并成一份最终纪要:核心结论去重后按重要性排序,决议事项与行动项合并去重。「行动项」保持 Markdown 复选列表格式。只输出 Markdown 正文。",
}, nil
default:
return kindSpec{}, fmt.Errorf("未知的加工步骤:%q", kind)
}
}
// ChunkTranscript 按行把逐字稿切成不超过 maxChars 的块。
//
// 优先在行边界切(逐字稿每段一行),保证不会把一个说话人的半句话劈开;
// 单行本身超长(没有分段信息的纯文本)时才硬切。
func ChunkTranscript(transcript string, maxChars int) []string {
text := strings.TrimSpace(transcript)
if text == "" {
return nil
}
if maxChars <= 0 {
maxChars = llmChunkChars
}
if len([]rune(text)) <= maxChars {
return []string{text}
}
chunks := make([]string, 0, 4)
var current strings.Builder
currentLen := 0
flush := func() {
if currentLen > 0 {
chunks = append(chunks, strings.TrimSpace(current.String()))
current.Reset()
currentLen = 0
}
}
for _, line := range strings.Split(text, "\n") {
line = strings.TrimRight(line, "\r")
lineLen := len([]rune(line))
if lineLen == 0 {
continue
}
if lineLen > maxChars {
// 单行就超限:先结算已攒的内容,再把这一行硬切成若干块。
flush()
runes := []rune(line)
for start := 0; start < len(runes); start += maxChars {
end := start + maxChars
if end > len(runes) {
end = len(runes)
}
chunks = append(chunks, string(runes[start:end]))
}
continue
}
if currentLen+lineLen+1 > maxChars {
flush()
}
if currentLen > 0 {
current.WriteString("\n")
currentLen++
}
current.WriteString(line)
currentLen += lineLen
}
flush()
// 归并过程会把内容重新组织,空块没有意义。
out := chunks[:0]
for _, chunk := range chunks {
if strings.TrimSpace(chunk) != "" {
out = append(out, chunk)
}
}
return out
}
// RunLLMStep 对逐字稿做一次 LLM 加工(整理 / 纪要)。
//
// 短稿一次调用;长稿按块加工后再归并一次,避免把两万字的稿子整段塞进单次请求。
// kind 只影响提示词,路由与回退由调用方通过 route 决定。
func RunLLMStep(ctx context.Context, kind Kind, transcript string, route *config.RouteConfig) (string, error) {
spec, err := specFor(kind)
if err != nil {
return "", err
}
if route == nil {
return "", fmt.Errorf("没有可用的对话路由,无法执行 %s 步骤", kind)
}
chunks := ChunkTranscript(transcript, llmChunkChars)
if len(chunks) == 0 {
return "", fmt.Errorf("逐字稿为空,无法执行 %s 步骤", kind)
}
if len(chunks) == 1 {
text, err := callModel(ctx, route, spec.system, spec.instruction+"\n\n逐字稿:\n"+chunks[0])
if err != nil {
return "", fmt.Errorf("%s 步骤调用模型失败:%w", kind, err)
}
return text, nil
}
partials := make([]string, 0, len(chunks))
for index, chunk := range chunks {
header := fmt.Sprintf("这是逐字稿的第 %d/%d 段。\n\n", index+1, len(chunks))
text, err := callModel(ctx, route, spec.system, header+spec.instruction+"\n\n逐字稿:\n"+chunk)
if err != nil {
return "", fmt.Errorf("%s 步骤第 %d/%d 段调用模型失败:%w", kind, index+1, len(chunks), err)
}
partials = append(partials, text)
}
if spec.joinOnly {
// 按序拼起来就算完成,不再让模型过一遍(理由见 kindSpec.joinOnly)。
// 各段自带小标题,所以只留空行分隔,不加「【第 N 段】」这类是给人添乱、
// 又会被后续加工当成正文的标签。
return strings.Join(partials, "\n\n"), nil
}
var merged strings.Builder
merged.WriteString(spec.mergeRequest)
merged.WriteString("\n\n")
for index, partial := range partials {
fmt.Fprintf(&merged, "【第 %d 段】\n%s\n\n", index+1, partial)
}
// 归并的输入是各段全文拼接,比单段更长,最容易撞预算;这里同样只收完整输出。
text, err := callModel(ctx, route, spec.mergeSystem, merged.String())
if err != nil {
return "", fmt.Errorf("%s 步骤归并失败:%w", kind, err)
}
return text, nil
}
// maxModelAttempts 单次调用在**可重试**失败下的最大尝试次数。
//
// 为什么非要有重试:第 3、4 步各自要连打 8–11 次模型(逐字稿按 llmChunkChars 分块),
// **其中任何一次抖动都会让整步前功尽弃** —— 就算每次只有 5% 的抖动量,11 次下来
// 整步失败率也有 43%。这不是假设:实测第 3 步就在第 4/11 段挂过一次,网关回了
// HTTP 200、报文里却没有 choices,前三段的成果一起作废。
//
// **只重试 ai.TransientUpstreamError**,见 ai.IsTransient。预算不够
// (finish_reason=length)是确定性的,重发只是再花一次钱拿同一个结果 ——
// 那种要把「预算不够」讲给用户听,不是重试。
const maxModelAttempts = 3
// modelRetryBackoff 重试间隔的基数,第 n 次重试等 n 倍。
// 抖动多半是上游的瞬时状态,隔几秒再打比立刻重打有用。
const modelRetryBackoff = 3 * time.Second
// callModel 调一次模型,对**瞬时抖动**做有限重试。
//
// 重试与「把这一块对半切了重试」是两回事:后者已被实测否定
// (思考的长度不随输入变小,见 llmChunkChars 结论 1)。这里重发的是**同一个**请求。
func callModel(ctx context.Context, route *config.RouteConfig, system, user string) (string, error) {
return withTransientRetry(ctx, modelRetryBackoff, func(ctx context.Context) (string, error) {
return callModelOnce(ctx, route, system, user)
})
}
// withTransientRetry 是上面那条重试策略本身,不掺网络调用。
//
// 单独拎出来是为了能直接测:这条策略两种错法代价都很实在 —— 该重试的没重试,
// 整步白跑(11 段里挂 1 段就全废);不该重试的反复重发,每次都在花钱,
// 而预算类失败重发一百次也是同一个结果。没有理由只靠线上观察来验它。
func withTransientRetry(ctx context.Context, backoff time.Duration, once func(context.Context) (string, error)) (string, error) {
var lastErr error
for attempt := 1; attempt <= maxModelAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
// 上层已经取消了就别再等:重试不是无视上层超时的理由。
return "", fmt.Errorf("重试等待时被取消:%w", ctx.Err())
case <-time.After(backoff * time.Duration(attempt-1)):
}
}
text, err := once(ctx)
if err == nil {
return text, nil
}
lastErr = err
if !ai.IsTransient(err) {
// 确定性失败:再试也是同一个结果,立刻把原因交出去。
return "", err
}
}
return "", fmt.Errorf("同一段重试 %d 次仍是瞬时失败:%w", maxModelAttempts, lastErr)
}
// callModelOnce 调一次模型,并把「输出预算被用光」当成失败。
//
// 为什么预算问题必须显式判:这些是推理模型,思考与正文共用 max_tokens
// (实测数据见 llmChunkChars)。预算不够时上游照样回 HTTP 200,只是:
// - 思考还没写完 → 响应里**连 content 字段都没有**,finish_reason=length;
// - 正文写了一半 → 正文被拦腰截断,finish_reason=length。
//
// 两种都「看着像做完了」:前者落库成一个点开什么都没有的产物,后者落库成一份
// 断在半句话上的纪要。这比报错更糟 —— 报错至少知道没成。
//
// GenerateFullWithFallback 而不是 GenerateWithFallback:只有前者把 finish_reason
// 带出来。两者都走同一条回退链,行为一致。
func callModelOnce(ctx context.Context, route *config.RouteConfig, system, user string) (string, error) {
res, hit, err := ai.GenerateFullWithFallback(ctx, route, []ai.Message{
{Role: "system", Content: system},
{Role: "user", Content: user},
})
routeID, maxTokens := route.RouteID, route.MaxTokens
if hit != nil {
routeID, maxTokens = hit.RouteID, hit.MaxTokens
}
if err != nil {
// 上游把「思考吃光预算、正文一个字没写」也归进「空正文」,而那句
// 文案会把人引去查模型名和密钥。这里换成讲得清病根的那一条
// (原因见 ai.EmptyCompletionError),两条路径的说法也就此统一。
var empty *ai.EmptyCompletionError
if errors.As(err, &empty) {
return checkCompletion(empty.FinishReason, "", routeID, maxTokens)
}
return "", err
}
return checkCompletion(res.FinishReason, res.Content, routeID, maxTokens)
}
// checkCompletion 是上面那道判断的纯函数部分,单独拎出来是为了能直接测 ——
// 它只依赖几个字符串和一个整数,没有理由非要连一次网络才能验。
//
// 预算优先于空正文:预算耗尽时正文可能是空的、也可能是半截的,报「预算不够」
// 比报「空正文」更接近真实原因(后者会让人去查模型和密钥)。
func checkCompletion(finishReason, content, routeID string, maxTokens int) (string, error) {
text := strings.TrimSpace(content)
if strings.EqualFold(strings.TrimSpace(finishReason), "length") {
// 「只写出 N 字」比「被截断」更有说服力:0 字和 1161 字是同一个病根
// (思考吃光了预算),只是严重程度不同,报出字数一眼就能看出这一次是哪种。
return "", fmt.Errorf(
"模型输出预算不够(路由 %s,max_tokens=%d,finish_reason=length):"+
"这是推理模型,思考与正文共用这份预算,而思考的长度跟输入几乎不成比例"+
"(实测 1200 字的输入,思考照样能写到 7169 字)。本次正文只写出 %d 字。"+
"把该路由的 max_tokens 提到 16384 以上(不是 8192:纪要那一步最后要把各段结果"+
"归并成一份,那是全流程最长的一次输入,实测 8192 下正文 0 字),或者换一条预算够大的路由",
routeID, maxTokens, len([]rune(text)))
}
if text == "" {
// 空内容落库会变成一个点开什么都没有的产物,比报错更难排查。
return "", fmt.Errorf("模型返回空正文(路由 %s,finish_reason=%q)", routeID, finishReason)
}
return text, nil
}
@@ -0,0 +1,165 @@
package audiotranscribe
import (
"context"
"errors"
"strings"
"testing"
"time"
"eai_agentplatform/backend/internal/ai"
)
// transientErr 造一个「这一次没成、但重发有机会成」的失败。
func transientErr() error {
return &ai.TransientUpstreamError{
StatusCode: 200,
Message: "LLM 返回空正文(响应里没有 choices):{\"error\":\"upstream timeout\"}",
}
}
// budgetErr 造一个确定性的失败:预算被思考吃光,重发一百次也是同一个结果。
func budgetErr() error {
return errors.New("模型输出预算不够(路由 chat_route_test,max_tokens=8192,finish_reason=length):本次正文只写出 0 字")
}
// TestWithTransientRetryRetriesOnlyTransientFailures 盯住重试策略的两半。
//
// 两半都得验,因为两种错法的代价都很实在:漏重试 → 11 段里挂 1 段整步白跑;
// 多重重试 → 每次调用都在花钱,而预算类失败重发一百次也一样。
func TestWithTransientRetryRetriesOnlyTransientFailures(t *testing.T) {
// 退避压到 1ms:这条测试验的是「打几次」,不是「等多久」。
const backoff = time.Millisecond
cases := []struct {
name string
// failTimes 前几次返回 failErr,之后返回成功。
failTimes int
failErr error
wantCalls int
wantText string
wantErr bool
}{
{
name: "一次就成,不多打",
failTimes: 0,
wantCalls: 1,
wantText: "正文",
},
{
name: "瞬时失败一次后成功",
failTimes: 1,
failErr: transientErr(),
wantCalls: 2,
wantText: "正文",
},
{
name: "瞬时失败两次后成功(用满重试额度)",
failTimes: 2,
failErr: transientErr(),
wantCalls: 3,
wantText: "正文",
},
{
name: "瞬时失败一直不停,打满就收手",
failTimes: 99,
failErr: transientErr(),
wantCalls: maxModelAttempts,
wantErr: true,
},
{
// 这条是重点:确定性失败**一次都不许多打**。
name: "确定性失败立刻收手",
failTimes: 99,
failErr: budgetErr(),
wantCalls: 1,
wantErr: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
calls := 0
text, err := withTransientRetry(context.Background(), backoff,
func(context.Context) (string, error) {
calls++
if calls <= tc.failTimes {
return "", tc.failErr
}
return "正文", nil
})
if calls != tc.wantCalls {
t.Errorf("调用次数 = %d,期望 %d", calls, tc.wantCalls)
}
if tc.wantErr {
if err == nil {
t.Fatal("期望失败,实际成功")
}
return
}
if err != nil {
t.Fatalf("期望成功,实际:%v", err)
}
if text != tc.wantText {
t.Errorf("正文 = %q,期望 %q", text, tc.wantText)
}
})
}
}
// TestTransientRetryKeepsDeterministicCauseVerbatim 确定性失败必须**原样**抛出。
//
// 它会被上层拼进「structure 步骤第 4/11 段调用模型失败:…」,用户看到的就是这句
// 话;重试逻辑要是在外面又套一层「重试 3 次仍是瞬时失败」,等于把真正的病根
// (预算不够)埋进一句不相干的文案里 —— 而确定性失败压根没重试过。
func TestTransientRetryKeepsDeterministicCauseVerbatim(t *testing.T) {
cause := budgetErr()
_, err := withTransientRetry(context.Background(), time.Millisecond,
func(context.Context) (string, error) { return "", cause })
if err == nil {
t.Fatal("期望失败,实际成功")
}
if !errors.Is(err, cause) {
t.Errorf("应原样抛出同一个错误,实际:%v", err)
}
if strings.Contains(err.Error(), "重试") {
t.Errorf("没有重试过的失败不该出现「重试」字样,实际:%v", err)
}
}
// TestTransientRetryGivesUpWhenContextCancelled 上层取消后不得继续等退避。
//
// 编排层用 ctx 超时兜底(如 150s),退避期间必须能被它打断;否则「取消」只是
// 一句空话,请求会一直挂到退避走完。
func TestTransientRetryGivesUpWhenContextCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
calls := 0
go func() {
// 第一次调用失败后立刻取消,让退避等待成为被打断的那一步。
time.Sleep(20 * time.Millisecond)
cancel()
}()
// 退避给足够长,长到「能等完」和「被打断」结果明显不同。
start := time.Now()
_, err := withTransientRetry(ctx, 30*time.Second, func(context.Context) (string, error) {
calls++
return "", transientErr()
})
elapsed := time.Since(start)
if err == nil {
t.Fatal("期望失败,实际成功")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("错误里应能认出 context.Canceled,实际:%v", err)
}
if calls != 1 {
t.Errorf("取消后不该再发起调用,实际调用 %d 次", calls)
}
if elapsed > 5*time.Second {
t.Errorf("取消应立刻打断退避,实际等了 %s", elapsed.Round(time.Millisecond))
}
}
@@ -0,0 +1,399 @@
package audiotranscribe
import (
"context"
"encoding/json"
"fmt"
"strings"
"eai_agentplatform/backend/internal/config"
)
// SpeakerIdentity 一位说话人的身份名片:机构名 + 头衔 + 姓名。
//
// 为什么是三个字段而不是一个「名字」:ASR 吐出来的说话人在全链路只是一个裸序号
// (FormatTranscriptWithSpeakers 把它渲染成「说话人 0」),而平台里**没有任何人员
// 花名册**可以拿来对照 —— 全库没有人员实体,「参会人」也只是会议纪要办公技能里的
// 一个自由文本字段。所以身份只能从稿内线索推断出来再请用户确认。
// 而一次会议里「谁在说话」真正有用的是这三样:纪要点评谁的意见、行动项派给谁,
// 都靠它;只给一个名字,机构与职务照样要用户自己补。
type SpeakerIdentity struct {
// Key ASR 原样给出的说话人标签,作为配对键("0" / "1" / "SPEAKER_00")。
// 它不参与展示 —— 它只是「这是哪一位」的锚点,改名即静默断链。
Key string `json:"key"`
// Org 机构名。稿里没有依据时为空:**留空是正确结果,不是失败**。
Org string `json:"org"`
// Title 头衔。同上。
Title string `json:"title"`
// Name 姓名。同上。
Name string `json:"name"`
// Evidence 支撑这次推断的原文片段,让用户能核对而不是只能信。
Evidence string `json:"evidence,omitempty"`
// DecidedBy 这份值最终是怎么定下来的,取值见 SpeakerDecidedBy* 常量。
// 由服务端比对「AI 推断值 vs 用户提交值」算出,**不收客户端自报的标志位** ——
// 客户端自报的「我没改过」是无法核验的,而那正是这个字段要记录的事。
DecidedBy string `json:"decided_by,omitempty"`
}
// 身份是怎么定下来的。
//
// 对应 AR12 §5.4 要求记录的「用户显式选择 / 用户手工输入新方案」。该条里的另一档
// 「系统自动默认」在本流程中**不允许出现**:未确认前下游步骤会被
// ensureAudioSpeakersConfirmed 拦住,不存在「没人确认也照样往下跑」的路径。
const (
// SpeakerDecidedByUserConfirmed 用户看过 AI 的推断值,一字未改直接确认。
SpeakerDecidedByUserConfirmed = "user_confirmed"
// SpeakerDecidedByUserEdited 用户至少改过一个字段。
SpeakerDecidedByUserEdited = "user_edited"
)
// DisplayName 这个人该在正式稿里显示成什么。
//
// 姓名优先;没有姓名就退回「机构·头衔」——「某某局·处长」在纪要里也比
// 「说话人 0」有用得多;三样都没有则返回空字符串,调用方据此**跳过替换**,
// 让稿子保持原样,而不是硬塞一个占位符进去假装认出来了。
func (s SpeakerIdentity) DisplayName() string {
if name := strings.TrimSpace(s.Name); name != "" {
return name
}
parts := make([]string, 0, 2)
if org := strings.TrimSpace(s.Org); org != "" {
parts = append(parts, org)
}
if title := strings.TrimSpace(s.Title); title != "" {
parts = append(parts, title)
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, "·")
}
// SpeakerInferMaxChars 推断身份时最多喂给模型多少字的逐字稿。
//
// 与第 5、6 步的 llmChunkChars(1200)不是一个量级,因为这两件事的约束相反:
// 那两步是**输出**一整篇文档,所以要切块;这一步只输出一小段 JSON,瓶颈在输入。
// 取 24000 是因为一次真实的 26:41 录音逐字稿是 11936 字,留了一倍余量;
// 再长就截断,且截断这件事会写进产物与日志(见 runAudioSpeakersStep)。
//
// 不切块再归并的原因:身份判断依赖跨段的线索(后面有人喊了一声「张局」,
// 前面那个自我介绍才坐实),切块会把这类线索切断,归并时也无从判断哪条更可信。
const SpeakerInferMaxChars = 24000
// speakerSystemPrompt 推断说话人身份的提示词。
//
// 第 2 条是整段提示词的重点。平台没有人员花名册,模型一旦按会议的常见套路开始
// 编造(「张总」「李经理」),这些名字会一路进到纪要里,而用户很难在一份通顺的
// 纪要里发现自己被安了个假名字 —— 这比缺一个名字有害得多。仓库里已有同样的口径
// (纪要提示词要求「原文没有就写待定」),这里与之一致。
const speakerSystemPrompt = `你是会议记录助理。任务:从一份逐字转写稿里推断每一位说话人的身份名片 —— 机构名、头衔、姓名。
必须遵守:
1. 只依据稿内线索推断:自我介绍、互相称呼、职务称谓(如「张局」「李总」)、会前寒暄、会议背景。不做任何外部联想。
2. 稿里没有依据的字段一律留空字符串。**绝对不许编造**:宁可三个字段全空,也不要填一个听起来合理的名字。留空是正确结果,不是失败。
3. 同一个人被以不同方式称呼时,取稿里最明确、出现次数最多的那一种。
4. 每位说话人给一句 evidence,摘录支撑这次推断的原文片段(60 字以内);没有依据就给空字符串。
只输出 JSON,不要 Markdown 代码块、不要任何解释。格式:
{"speakers":[{"key":"","org":"","title":"","name":"","evidence":""}]}
speakers 数组必须**逐一覆盖**下面给出的每一个说话人标签,不能多、不能少、顺序不限。`
// InferSpeakerIdentities 让模型从逐字稿里推断每位说话人的身份。
//
// keys 是 ASR 实际给出的说话人标签,**由调用方从第 2 步的分段里取**,
// 不让模型自己决定有几个人:它少认一个,那个人就会在下游的名字替换里被静默漏掉,
// 而界面上完全看不出来 —— 稿子里还剩着「说话人 2」,没人会注意到。
//
// 返回值里的 clipped 说明这次是不是只喂了前 SpeakerInferMaxChars 字。
// 截断在这里做、也在这里报,是因为它是会影响结论的事实,必须让调用方写进产物 ——
// 而调用方自己再截一次的话,两处上限一改一漏,报出来的「没截断」就是假的。
func InferSpeakerIdentities(
ctx context.Context,
transcript string,
keys []string,
route *config.RouteConfig,
) (roster []SpeakerIdentity, clipped bool, err error) {
if len(keys) == 0 {
return nil, false, fmt.Errorf("逐字稿里没有说话人标签,无法推断身份")
}
if strings.TrimSpace(transcript) == "" {
return nil, false, fmt.Errorf("逐字稿是空的,无法推断说话人身份")
}
if route == nil {
return nil, false, fmt.Errorf("没有可用的对话路由,无法推断说话人身份")
}
source, clipped := ClipTranscript(transcript, SpeakerInferMaxChars)
user := fmt.Sprintf("本次逐字稿里的说话人标签共 %d 个:%s\n\n逐字稿:\n%s",
len(keys), strings.Join(keys, "、"), source)
raw, err := callModel(ctx, route, speakerSystemPrompt, user)
if err != nil {
return nil, clipped, fmt.Errorf("推断说话人身份失败:%w", err)
}
roster, err = parseSpeakerRoster(raw)
if err != nil {
return nil, clipped, err
}
if err := ensureRosterCoversKeys(roster, keys); err != nil {
return nil, clipped, err
}
return roster, clipped, nil
}
// ClipTranscript 按字数上限截断逐字稿,并告知调用方有没有真的截断过。
//
// 导出是因为「有没有截断」是会影响结论的事实,得由调用方写进产物与日志 ——
// 一份只看了前 40% 的稿子推出来的身份,和看完全稿推出来的,可信度不是一回事。
func ClipTranscript(transcript string, maxChars int) (string, bool) {
if maxChars <= 0 {
return transcript, false
}
runes := []rune(transcript)
if len(runes) <= maxChars {
return transcript, false
}
return string(runes[:maxChars]), true
}
// parseSpeakerRoster 解析模型返回的 JSON。
//
// 容忍两种情况,因为这两种在实测里都会出现,而它们都不该让整步失败:
// 把 JSON 包在 ```json 代码块里、以及在 JSON 前后带一句客套话。
func parseSpeakerRoster(raw string) ([]SpeakerIdentity, error) {
text := strings.TrimSpace(raw)
if text == "" {
return nil, fmt.Errorf("模型没有返回内容,无法得到说话人身份")
}
// 掐掉代码块围栏与前后的解释文字,只留最外层的那个 {...}。
if start := strings.Index(text, "{"); start >= 0 {
if end := strings.LastIndex(text, "}"); end > start {
text = text[start : end+1]
}
}
var payload struct {
Speakers []SpeakerIdentity `json:"speakers"`
}
if err := json.Unmarshal([]byte(text), &payload); err != nil {
// 原文不截断地贴进错误里:这段 JSON 通常很短,而少了它就没法判断
// 是模型没按格式输出,还是我们自己的解析写错了。
return nil, fmt.Errorf("模型的输出不是预期格式的 JSON(%w):%s", err, text)
}
if len(payload.Speakers) == 0 {
return nil, fmt.Errorf("模型返回的说话人名单是空的:%s", text)
}
roster := make([]SpeakerIdentity, 0, len(payload.Speakers))
for _, item := range payload.Speakers {
roster = append(roster, SpeakerIdentity{
Key: strings.TrimSpace(item.Key),
Org: strings.TrimSpace(item.Org),
Title: strings.TrimSpace(item.Title),
Name: strings.TrimSpace(item.Name),
Evidence: strings.TrimSpace(item.Evidence),
})
}
return roster, nil
}
// ensureRosterCoversKeys 校验模型给的名册与稿里实际的说话人**一一对应**。
//
// 少了:那个人在下游替换里被静默漏掉。多了:凭空多出一位不存在的与会者,
// 且会出现在待确认的名单里让用户以为真有这个人。两种都不能放过,
// 所以这里宁可整步失败 —— 失败会在界面上吵,缺一个说话人不会。
func ensureRosterCoversKeys(roster []SpeakerIdentity, keys []string) error {
seen := make(map[string]bool, len(roster))
for _, item := range roster {
if item.Key == "" {
return fmt.Errorf("模型返回的名单里有条目缺少说话人标签(key 为空)")
}
if seen[item.Key] {
return fmt.Errorf("模型返回的名单里说话人 %s 出现了多次", item.Key)
}
seen[item.Key] = true
}
missing := make([]string, 0)
for _, key := range keys {
if !seen[key] {
missing = append(missing, key)
}
}
extra := make([]string, 0)
for key := range seen {
if !containsString(keys, key) {
extra = append(extra, key)
}
}
if len(missing) > 0 || len(extra) > 0 {
return fmt.Errorf("模型返回的说话人名单与逐字稿对不上:缺少 %v,多出 %v(稿里有 %v)",
missing, extra, keys)
}
return nil
}
// MarkDecidedBy 比对「AI 推断值」与「用户提交值」,标出每一位是怎么定下来的。
//
// 放在这一层而不是 handler 里,是因为它是这条业务规则本身,而不是 HTTP 细节;
// 而且它必须与 SpeakerIdentity 的三个字段保持同步 —— 将来加第四个字段
// (比如「部门」)时,漏改这里会让所有人都被标成「未修改」。
func MarkDecidedBy(submitted, inferred []SpeakerIdentity) []SpeakerIdentity {
originals := make(map[string]SpeakerIdentity, len(inferred))
for _, item := range inferred {
originals[item.Key] = item
}
marked := make([]SpeakerIdentity, 0, len(submitted))
for _, item := range submitted {
before, ok := originals[item.Key]
switch {
case !ok:
// 到不了这里:调用方已经用 ensureRosterCoversKeys 校验过键集合。
// 真到了说明校验被绕过,与其静默标一个值,不如标成「改过」——
// 一个不认识的说话人只可能是人为加进来的。
item.DecidedBy = SpeakerDecidedByUserEdited
case before.Org == item.Org && before.Title == item.Title && before.Name == item.Name:
item.DecidedBy = SpeakerDecidedByUserConfirmed
default:
item.DecidedBy = SpeakerDecidedByUserEdited
}
// Evidence 是 AI 给的依据,不是用户提交的内容:以库里的原值收口,
// 免得客户端顺手把它改掉,让「依据」变成一条可以伪造的字段。
if ok {
item.Evidence = before.Evidence
} else {
item.Evidence = ""
}
marked = append(marked, item)
}
return marked
}
// SpeakerKeysOf 从第 2 步产物的 ContentJSON 里取出说话人标签(去重、保持首次出现顺序)。
//
// 顺序有含义:稿子里先开口的那位排在前面,界面上按这个顺序列出来,
// 用户对着稿子核对时不用来回找。
func SpeakerKeysOf(contentJSON string) []string {
var payload struct {
Segments []Segment `json:"segments"`
}
if err := json.Unmarshal([]byte(contentJSON), &payload); err != nil {
return nil
}
keys := make([]string, 0, 4)
for _, seg := range payload.Segments {
key := strings.TrimSpace(seg.Speaker)
if key == "" || containsString(keys, key) {
continue
}
keys = append(keys, key)
}
return keys
}
// SpeakerRosterOf 从「说话人名单」产物的 ContentJSON 里取回名单。
//
// 与 SpeakerKeysOf 的区别:那个读的是第 2 步的**逐字稿**(segments[].speaker,只有
// 裸标签),这个读的是第 3 步的**名单**(speakers[],带机构名头衔姓名)。
// 两者都是 JSON,形状不同,所以是两个函数而不是一个带开关的。
//
// 解析失败返回 nil 而不是报错:调用方拿到的空名单会让下游退化成「保持原样」,
// 而这正是解析不出来时唯一正确的行为 —— 总比把半份名单套上去、给一半人改名、
// 另一半留着「说话人 3」要好。
func SpeakerRosterOf(contentJSON string) []SpeakerIdentity {
var payload struct {
Speakers []SpeakerIdentity `json:"speakers"`
}
if err := json.Unmarshal([]byte(contentJSON), &payload); err != nil {
return nil
}
return payload.Speakers
}
// ValidateSpeakerRosterKeys 校验用户提交的名单与原推断名单**一一对应**。
//
// 多一个:凭空多出一位不存在的与会者,且下游替换时会把一个没人说过的名字
// 写进稿子。少一个:那个人被静默漏掉,稿子里留着「说话人 2」而没人会注意到。
// 两种都在这里拦掉,复用推断那一步的同一份校验,免得两处口径漂移。
func ValidateSpeakerRosterKeys(submitted, inferred []SpeakerIdentity) error {
keys := make([]string, 0, len(inferred))
for _, item := range inferred {
keys = append(keys, item.Key)
}
return ensureRosterCoversKeys(submitted, keys)
}
// ApplySpeakerNames 把稿子里的「说话人 0」换成确认过的称呼。
//
// 只做内存里的替换,**不回写 transcript 产物**:那份稿子的价值恰恰在于它是
// ASR 没被动过的原始记录(latestArtifactText 取的也是它),改写之后
// 「逐字转写稿」就不再等于机器听到的东西了。
//
// keys 先长后短地替换:「说话人 1」是「说话人 10」的前缀,
// 按原顺序替换会把「说话人 10」改成「张三0」。
func ApplySpeakerNames(transcript string, roster []SpeakerIdentity) string {
if transcript == "" || len(roster) == 0 {
return transcript
}
sorted := make([]SpeakerIdentity, len(roster))
copy(sorted, roster)
// 简单的插入排序:说话人个数是个位数,不值得为它引入 sort 包。
for i := 1; i < len(sorted); i++ {
for j := i; j > 0 && len(sorted[j].Key) > len(sorted[j-1].Key); j-- {
sorted[j], sorted[j-1] = sorted[j-1], sorted[j]
}
}
for _, item := range sorted {
display := item.DisplayName()
key := strings.TrimSpace(item.Key)
if display == "" || key == "" {
continue
}
// 两种写法都换:FormatTranscriptWithSpeakers 产出的是「说话人 0:」,
// 但用户自己编辑过的稿子、或别的 ASR 输出里可能是「说话人0:」。
transcript = strings.ReplaceAll(transcript, "说话人 "+key, display)
transcript = strings.ReplaceAll(transcript, "说话人"+key, display)
}
return transcript
}
// RenderSpeakerRoster 把名单渲染成可读文本,作为产物的正文。
//
// 正文给人看(右栏预览、导出、以及确认后作为留痕),结构化的那份进 ContentJSON。
func RenderSpeakerRoster(roster []SpeakerIdentity, confirmed bool) string {
var b strings.Builder
if confirmed {
b.WriteString("## 说话人名单(已确认)\n\n")
} else {
b.WriteString("## 说话人名单(AI 推断,待确认)\n\n")
b.WriteString("> 以下身份是模型从稿内线索推断的,**尚未确认**。确认之前不会继续整理与生成纪要,\n")
b.WriteString("> 以免把推断出来的身份当成事实写进正式稿。稿里没有依据的字段留空,不做编造。\n\n")
}
for _, item := range roster {
display := item.DisplayName()
if display == "" {
display = "(稿中无依据,未能推断出身份)"
}
fmt.Fprintf(&b, "- 说话人 %s:%s\n", item.Key, display)
if item.Evidence != "" {
fmt.Fprintf(&b, " - 依据:%s\n", item.Evidence)
}
if confirmed && item.DecidedBy == SpeakerDecidedByUserEdited {
b.WriteString(" - 用户已修改\n")
}
}
return b.String()
}
func containsString(list []string, target string) bool {
for _, item := range list {
if item == target {
return true
}
}
return false
}
@@ -0,0 +1,478 @@
// Package audiotranscribe 语音转写(ASR)技能内核。
//
// 内核放在这里而不是 internal/api,是因为它有两个调用方:
// - internal/api.TranscribeAudio —— 裸的 multipart 上传接口;
// - internal/skills/api 的技能工作流第 2 步 —— 从 media_id 读盘再转写。
//
// 后者的包拿不到 api 包的不导出函数,所以内核必须下沉。
// 与 report_generation/report_content.go 的归置方式一致。
//
// 本包只做「拿音频字节 → 拿到转写结果」,不碰数据库、不碰 HTTP 请求上下文,
// 因此可以被单测直接调用(含真实网络那条)。
package audiotranscribe
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os/exec"
"strconv"
"strings"
"time"
"eai_agentplatform/backend/internal/ai"
"eai_agentplatform/backend/internal/config"
)
// Segment 一段带说话人与时间戳的转写片段。
// SiliconFlow 的 Diarize 模型会返回 segments,每段带 speaker / start / end。
type Segment struct {
Speaker string `json:"speaker"`
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
}
// Result 一次转写的完整结果。
type Result struct {
Text string `json:"text"` // 转写文字(含说话人前缀,若有)
Language string `json:"language"` // 语言
Duration float64 `json:"duration"` // 时长(秒)
Segments []Segment `json:"segments"` // 分段(含说话人),可能为空
HasSpeakers bool `json:"has_speakers"` // 是否区分了说话人
Model string `json:"model"` // 实际使用的 ASR 模型
RouteID string `json:"route_id"` // 实际使用的音频路由
// IsLocal 本次转写是否全程在本机完成。判据是**实际服务的那条路由**的
// base_url 是不是回环地址,见 config.IsLocalRoute —— 量的是「字节实际去了哪」。
// 它存在的理由只有一个:本地 ASR 挂掉时会自动回退云端,用户有权知道
// 这一次的录音被送出去了。所以这个字段必须一路带到界面,不能只在日志里。
//
// 措辞按「本机」而不是「内网」:回环只证明音频没离开这台机器,
// 不证明它没离开这栋楼(ASR 若部署在内网另一台机器上,这里也是 false)。
IsLocal bool `json:"is_local"`
// FellBack 本次是否用到了回退链(主路由没跑成,换了另一条)。
// 与 IsLocal 是两件事:主路由是本地、回退到云端,则两个都最有信息量。
FellBack bool `json:"fell_back"`
PrimaryRouteID string `json:"primary_route_id,omitempty"` // 原本该用的那条
FallbackReason string `json:"fallback_reason,omitempty"` // 主路由失败的原因
CreatedAt string `json:"created_at"` // 时间
}
// AllowedExt 允许转写的音频扩展名(小写,不含点)。
var AllowedExt = map[string]bool{
"mp3": true, "wav": true, "m4a": true,
"ogg": true, "flac": true, "aac": true, "wma": true,
}
// IsAudioExt 判断扩展名是否为可转写音频(大小写不敏感,容忍前导点)。
//
// 前端的发送门控与后端的入参校验共用这一张表,避免两边各写一份后走偏。
func IsAudioExt(ext string) bool {
return AllowedExt[strings.ToLower(strings.TrimPrefix(strings.TrimSpace(ext), "."))]
}
// ExtractExt 从文件名提取扩展名(小写,不含点)。
func ExtractExt(filename string) string {
idx := strings.LastIndexByte(filename, '.')
if idx < 0 {
return ""
}
return strings.ToLower(filename[idx+1:])
}
// TranscribeBytes 调一次 ASR 并把结果解析成 Result。
//
// 主路由失败且失败属于「这条路此刻不行」时,依次尝试回退链(见 transcribeChain)。
// 失败一律返回 error,**不把错误文案塞进 Text 字段返回**:
// 那种「永远 200、正文里写错误」的做法会让调用方把错误当成转写稿落库。
func TranscribeBytes(userID uint, fileData []byte, filename, ext, language, routeOverride string) (*Result, error) {
chain, err := transcribeChainFn(routeOverride)
if err != nil {
return nil, err
}
var lastErr error
for i, route := range chain {
result, retriable, err := transcribeOnRoute(userID, route, fileData, filename, ext, language)
if err == nil {
// 成功要如实报健康:本地刚挂、这次退到云端跑通了,这一笔就把
// 「本地不健康」这个事实就地更新了,不必等下一轮 30 分钟的巡检。
config.ReportRouteHealth(config.RouteHealth{
AIRouteID: route.RouteID,
Category: route.Category,
Healthy: true,
Checked: true,
LastCheckedAt: time.Now(),
})
result.PrimaryRouteID = chain[0].RouteID
result.FellBack = i > 0
if lastErr != nil {
result.FallbackReason = lastErr.Error()
}
return result, nil
}
lastErr = err
if !retriable {
// 「这个文件/这次请求本身不行」:换一条路由重发同一份文件不会变好,
// 而代价是把这份录音又送出门一次。隐私代价换不到成功率,不回退。
return nil, err
}
if i < len(chain)-1 {
// 失败的那条就地标不健康,否则下一轮巡检之前所有请求都会先撞它一次。
config.ReportRouteHealth(config.RouteHealth{
AIRouteID: route.RouteID,
Category: route.Category,
Healthy: false,
Checked: true,
LastError: err.Error(),
LastCheckedAt: time.Now(),
})
}
}
// 列出试过的每一条:只说「服务不可达」而不说试过哪几条,
// 排障时分不清「回退链没跑」和「跑了但全都不行」。
ids := make([]string, 0, len(chain))
for _, r := range chain {
ids = append(ids, r.RouteID)
}
return nil, fmt.Errorf("语音转写失败,已依次尝试 %s:%w",
strings.Join(ids, " → "), lastErr)
}
// transcribeChainFn 取路由链。单独拎成变量只为让测试能把链指向本机的 httptest:
// 真实配置里的回退目标是公网 ASR,要测回退就得真把音频发出去 —— 那正是这个功能
// 要防的事,绝不能拿测试来干。注入之后整条链都留在本机,而回退逻辑仍是真的
// (真发 HTTP、真读状态码、真走重试判定)。同一模式见 audio_handlers.go 的
// audioRouteSupportsSpeakers。
var transcribeChainFn = transcribeChain
// transcribeChain 组装「主路由 + 回退链」。
//
// 为什么请求级回退不能省:健康探测是 30 分钟一轮的(route_health.go 的
// defaultAIRouteProbeInterval),本地服务在这两轮之间挂掉,探测结果还是「健康」,
// 于是请求会先撞一次已经死掉的本地路由 —— 对用户就是一次硬失败。巡检负责
// 「选谁当主」,回退链负责「主不行时换谁」,两者是叠加关系,不是二选一。
//
// 只对 audio 分类做这件事:chat/embed 的回退在 internal/ai 那边已有实现。
func transcribeChain(routeOverride string) ([]*config.RouteConfig, error) {
route, err := getAudioRoute(routeOverride)
if err != nil {
return nil, fmt.Errorf("语音转写路由不可用:%w", err)
}
if route.Category != "audio" {
return nil, fmt.Errorf("路由 %s 的分类是 %q,不是 audio", route.RouteID, route.Category)
}
return append([]*config.RouteConfig{route},
config.GetFallbackAudioRoutes(route.RouteID)...), nil
}
// transcribeOnRoute 在**一条**指定路由上跑一次转写。
//
// 第二个返回值 retriable 表示「换个路由重发有没有意义」:
// - 连不上 / 5xx / 429 —— 这条路此刻不行,换一条有意义;
// - 400/413/415 等 4xx —— 是这份文件或这次请求不行,换谁都不行;
// - 超时 —— 预算已经吃掉了,再换一条大概率还是被掐断,且会把音频再发一次。
//
// 超时**不**算 retriable 是刻意的。回退只有在「还来得及成功」时才有价值;
// 一次超时意味着主路由的预算是整段耗尽的,接着把同一份长音频发给云端,
// 结果多半是用户那边先超时(前端 axios 15 分钟),而音频已经出门了。
func transcribeOnRoute(userID uint, route *config.RouteConfig, fileData []byte,
filename, ext, language string) (*Result, bool, error) {
// 本机服务免密钥(判据与健康探测共用,见 config.IsLocalRoute)。
// 非本地仍要求有密钥:缺密钥的云端路由打过去只会拿到 401,
// 提前报错比让用户等一轮网络往返再看到 401 好。
isLocal := config.IsLocalRoute(route)
if !isLocal && route.APIKey == "" {
// 请求还没发出去,换一条路由是零成本的(没有音频出网),算可回退。
return nil, true, fmt.Errorf("音频路由 %s(provider %s)缺少 API Key", route.RouteID, route.Provider)
}
// 构建 multipart body
buf := bytes.NewBuffer(nil)
w := multipart.NewWriter(buf)
modelField, err := w.CreateFormField("model")
if err != nil {
return nil, false, fmt.Errorf("构建请求失败:%w", err)
}
if _, err := modelField.Write([]byte(route.Model)); err != nil {
return nil, false, fmt.Errorf("写入 model 失败:%w", err)
}
// language:中转服务可能忽略该字段,但传上不影响;空值与 "auto" 都不传,
// 交给模型自行判断,避免某些服务把 "auto" 当非法语言码拒掉。
lang := strings.TrimSpace(language)
if lang != "" && !strings.EqualFold(lang, "auto") {
langField, err := w.CreateFormField("language")
if err != nil {
return nil, false, fmt.Errorf("构建请求失败:%w", err)
}
if _, err := langField.Write([]byte(lang)); err != nil {
return nil, false, fmt.Errorf("写入 language 失败:%w", err)
}
}
uploadName := strings.TrimSpace(filename)
if uploadName == "" {
uploadName = "audio." + ext
}
filePart, err := w.CreateFormFile("file", uploadName)
if err != nil {
return nil, false, fmt.Errorf("构建请求失败:%w", err)
}
if _, err := filePart.Write(fileData); err != nil {
return nil, false, fmt.Errorf("写入音频失败:%w", err)
}
if err := w.Close(); err != nil {
return nil, false, fmt.Errorf("构建请求失败:%w", err)
}
timeout := time.Duration(route.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 600 * time.Second // 长音频转写天然慢,默认给足 10 分钟
}
req, err := http.NewRequest("POST", route.FullURL, buf)
if err != nil {
return nil, false, fmt.Errorf("请求构建失败:%w", err)
}
req.Header.Set("Content-Type", w.FormDataContentType())
authKey := route.APIKey
if authKey == "" {
// 本地服务不校验密钥的值,但要求这个头存在(serve.py 收不到就回 401)。
// 不写 "Bearer " 留个尾空格让对端去 strip —— 显式给个占位值更清楚。
authKey = "local"
}
req.Header.Set("Authorization", "Bearer "+authKey)
client := &http.Client{Timeout: timeout}
startedAt := time.Now()
resp, err := client.Do(req)
latencyMs := int(time.Since(startedAt).Milliseconds())
if err != nil {
LogCall(userID, route, false, err.Error(), latencyMs)
// 超时不回退,理由见函数头注释;连不上则回退 —— 本地服务没起来
// 正是最常见的那种失败,也正是「自动回退云端」要接住的那一种。
return nil, !isTimeoutErr(err), fmt.Errorf("ASR 服务不可达(%s):%w", route.RouteID, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
LogCall(userID, route, false, "读取响应失败", latencyMs)
return nil, true, fmt.Errorf("读取 ASR 响应失败:%w", err)
}
if resp.StatusCode != http.StatusOK {
msg := fmt.Sprintf("ASR 返回 HTTP %d:%s", resp.StatusCode, truncateForError(body, 300))
LogCall(userID, route, false, msg, latencyMs)
return nil, isRetriableHTTPStatus(resp.StatusCode), fmt.Errorf("%s", msg)
}
// 响应:{duration, text, segments:[{speaker,start,end,text}], usage}
var out struct {
Duration float64 `json:"duration"`
Text string `json:"text"`
Segments []struct {
Speaker string `json:"speaker"`
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
} `json:"segments"`
}
if err := json.Unmarshal(body, &out); err != nil {
LogCall(userID, route, false, "响应解析失败", latencyMs)
// 200 但不是 ASR 的 JSON:这条路由大概率指向了别的服务(比如把 ASR
// 打到了 chat 端点上)。换一条是对的。
return nil, true, fmt.Errorf("ASR 响应解析失败:%w(原始报文:%s)", err, truncateForError(body, 200))
}
segments := make([]Segment, 0, len(out.Segments))
hasSpeakers := false
for _, s := range out.Segments {
seg := Segment{
Speaker: strings.TrimSpace(s.Speaker),
Start: s.Start,
End: s.End,
Text: strings.TrimSpace(s.Text),
}
if seg.Speaker != "" {
hasSpeakers = true
}
if seg.Text == "" {
continue
}
segments = append(segments, seg)
}
text := strings.TrimSpace(out.Text)
if text == "" {
// 有的服务只给 segments 不给 text,这里补一个。
text = JoinSegments(segments)
}
// 空白音频会得到 200 + 空文本,这不是「成功转写」。
// 也不回退:这条路由说「这份音频没有人声」,换一条大概率还是同一句话,
// 而代价是把音频再送一次出去。
if text == "" {
msg := "ASR 返回空文本(音频可能无有效人声,或格式不被识别)"
LogCall(userID, route, false, msg, latencyMs)
return nil, false, fmt.Errorf("%s", msg)
}
LogCall(userID, route, true, "", latencyMs)
return &Result{
Text: text,
Language: lang,
Duration: out.Duration,
Segments: segments,
HasSpeakers: hasSpeakers,
Model: route.Model,
RouteID: route.RouteID,
IsLocal: isLocal,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}, false, nil
}
// isTimeoutErr 判断传输层错误是不是「等超时了」。
//
// http.Client 超时抛的是 *url.Error,其 Timeout() 为真;用 errors.As 取它,
// 而不是拿错误文案去 grep "timeout" —— 文案随 Go 版本变,而且网络错误里
// 出现 timeout 字样的不止超时一种(比如 DNS 超时,那种其实值得回退)。
func isTimeoutErr(err error) bool {
var ue *url.Error
if errors.As(err, &ue) {
return ue.Timeout()
}
return false
}
// isRetriableHTTPStatus 换个路由重发有没有意义(HTTP 状态维度)。
//
// 5xx 是「这条路由此刻坏了」,429 是「这条路由此刻忙」,都值得换一条。
// 4xx 一律不换:那是「这份请求本身不行」—— 400/415 是音频格式或参数不对,
// 413 是文件太大,401/403 是密钥不对(回退链上的路由在本项目里共用同一份
// secrets,换一条也是同样的密钥)。把同一份文件再送一次出门,换不到成功率。
func isRetriableHTTPStatus(code int) bool {
return code >= 500 || code == http.StatusTooManyRequests
}
// getAudioRoute 取转写用的音频路由。
//
// override 非空时优先(供后台按路由做通路测试);为空则用 agent_routes 里的
// audio_transcribe。取不到即报错,不回退 chat 路由 —— 见 config.GetAudioRoute。
func getAudioRoute(override string) (*config.RouteConfig, error) {
agent := strings.TrimSpace(override)
if agent == "" {
agent = "audio_transcribe"
}
return config.GetAudioRoute(agent)
}
// JoinSegments 把分段拼成带说话人前缀的文本;无说话人时按时间顺序直接拼接。
func JoinSegments(segments []Segment) string {
lines := make([]string, 0, len(segments))
for _, s := range segments {
if s.Speaker != "" {
lines = append(lines, s.Speaker+": "+s.Text)
} else {
lines = append(lines, s.Text)
}
}
return strings.Join(lines, "\n")
}
// LogCall 记录一次转写调用。成功才计点(ComputeCredits 内部已判),
// 失败必须如实记 failed —— 旧实现无论成败都写 Success: true,用量与计费都是假的。
func LogCall(userID uint, route *config.RouteConfig, success bool, errMsg string, latencyMs int) {
ai.LogCall(ai.LogEntry{
UserID: userID,
UsageKind: ai.UsageKindAudioTranscribe,
Provider: route.Provider,
AIRouteID: route.RouteID,
Model: route.Model,
Success: success,
ErrorMessage: errMsg,
LatencyMs: latencyMs,
})
}
// truncateForError 截断第三方返回的原始报文,避免把整页 HTML 错误塞进用户可见消息。
func truncateForError(b []byte, max int) string {
s := strings.TrimSpace(string(b))
if len(s) <= max {
return s
}
return s[:max] + "…"
}
// FormatTranscriptWithSpeakers 把转写结果渲染成逐字稿正文(技能工作流第 2 步的产物)。
func FormatTranscriptWithSpeakers(result *Result) string {
if len(result.Segments) == 0 {
return result.Text
}
var b strings.Builder
for _, s := range result.Segments {
who := s.Speaker
if who != "" {
who = "说话人 " + who
} else {
who = "说话人 —"
}
b.WriteString(fmt.Sprintf("[%s - %s] %s:%s\n",
FormatClock(s.Start), FormatClock(s.End), who, s.Text))
}
return strings.TrimRight(b.String(), "\n")
}
// FormatClock 把秒数渲染成 mm:ss(分段起止时间用;超过一小时也只显示到分钟)。
func FormatClock(sec float64) string {
if sec < 0 {
sec = 0
}
total := int(sec + 0.5)
return strconv.Itoa(total/60) + ":" + fmt.Sprintf("%02d", total%60)
}
// FormatDuration 把秒数渲染成人读的时长(第 1 步「确认音频范围」展示用)。
// 一小时以上出小时位,避免出现「62:33」这种要心算的写法;不足一秒按 0 秒处理。
func FormatDuration(sec float64) string {
if sec < 0 {
sec = 0
}
total := int(sec + 0.5)
if total < 3600 {
return FormatClock(float64(total))
}
return fmt.Sprintf("%d:%02d:%02d", total/3600, (total%3600)/60, total%60)
}
// ProbeDuration 用 ffprobe 读音频时长(秒)。
//
// 读不到就返回 0 且不报错:时长只是第 1 步的展示信息,缺了不该让整个技能失败。
// ffprobe 不在 PATH 上同样返回 0(裸进程依赖,缺失降级而非中断)。
func ProbeDuration(path string) float64 {
if _, err := exec.LookPath("ffprobe"); err != nil {
return 0
}
out, err := exec.Command("ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
path,
).Output()
if err != nil {
return 0
}
seconds, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
if err != nil || seconds < 0 {
return 0
}
return seconds
}
@@ -0,0 +1,413 @@
package audiotranscribe
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"eai_agentplatform/backend/internal/model"
)
// TestMain 定位 backend-go 并切换工作目录,让 config/ai_config.json 在测试进程内
// 可被找到(configDir() 的回退逻辑依赖 CWD/config)。
func TestMain(m *testing.M) {
if base := locateBackendGoForTest(); base != "" {
_ = os.Chdir(base)
}
os.Exit(m.Run())
}
// locateBackendGoForTest 从当前工作目录向上找含 config/ai_config.json 的目录。
func locateBackendGoForTest() string {
dir, err := os.Getwd()
if err != nil {
return ""
}
for {
if _, err := os.Stat(filepath.Join(dir, "config", "ai_config.json")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}
func TestExtractExt(t *testing.T) {
cases := map[string]string{
"meeting.mp3": "mp3",
"MEETING.MP3": "mp3",
"a.b.m4a": "m4a",
"noext": "",
"trailing.": "",
"/path/to/rec.wav": "wav",
}
for in, want := range cases {
if got := ExtractExt(in); got != want {
t.Errorf("ExtractExt(%q) = %q, want %q", in, got, want)
}
}
}
// TestIsAudioExt 断言前后端共用同一张音频格式表:带点的、大写的、空白的都要认。
func TestIsAudioExt(t *testing.T) {
for _, in := range []string{"mp3", "MP3", ".m4a", " wav ", "FLAC"} {
if !IsAudioExt(in) {
t.Errorf("IsAudioExt(%q) = false,期望 true", in)
}
}
for _, in := range []string{"", "mp4", "txt", "mp3x", "docx"} {
if IsAudioExt(in) {
t.Errorf("IsAudioExt(%q) = true,期望 false", in)
}
}
}
func TestFormatClock(t *testing.T) {
cases := map[float64]string{
0: "0:00",
7.499: "0:07",
59.6: "1:00", // 四舍五入进位
60: "1:00",
143.2: "2:23",
-5: "0:00", // 负值钳到 0,不出现 -0:05
}
for in, want := range cases {
if got := FormatClock(in); got != want {
t.Errorf("FormatClock(%v) = %q, want %q", in, got, want)
}
}
}
// TestFormatDuration 断言一小时以上会出小时位,不再显示成要心算的「62:33」。
func TestFormatDuration(t *testing.T) {
cases := map[float64]string{
0: "0:00",
143.2: "2:23",
3599: "59:59",
3600: "1:00:00",
3753: "1:02:33",
-1: "0:00",
}
for in, want := range cases {
if got := FormatDuration(in); got != want {
t.Errorf("FormatDuration(%v) = %q, want %q", in, got, want)
}
}
}
func TestJoinSegments(t *testing.T) {
segs := []Segment{
{Speaker: "1", Text: "第一句"},
{Speaker: "", Text: "没说话人"},
{Speaker: "2", Text: "第二句"},
}
got := JoinSegments(segs)
want := "1: 第一句\n没说话人\n2: 第二句"
if got != want {
t.Errorf("JoinSegments = %q, want %q", got, want)
}
}
func TestFormatTranscriptWithSpeakersCarriesLabelsAndTimestamps(t *testing.T) {
result := &Result{
Segments: []Segment{
{Speaker: "1", Start: 0.14, End: 7.499, Text: "大家好"},
{Speaker: "2", Start: 8.47, End: 14.73, Text: "我来说第一个议题"},
},
}
got := FormatTranscriptWithSpeakers(result)
for _, want := range []string{"说话人 1", "说话人 2", "0:00 - 0:07", "0:08 - 0:15", "大家好"} {
if !strings.Contains(got, want) {
t.Errorf("逐字稿缺少 %q\n实际内容:\n%s", want, got)
}
}
}
// TestChunkTranscriptLosesNothing 是本包最容易出错的一处:分块是长音频唯一
// 走得到 LLM 的路径,任何一块被吃掉都会变成纪要里「莫名少了一段」,
// 而且不会有任何报错。所以断言的是「内容无损」,不是「块数对不对」。
func TestChunkTranscriptLosesNothing(t *testing.T) {
lines := make([]string, 0, 60)
for i := 0; i < 60; i++ {
lines = append(lines, strings.Repeat("这是一句会议发言。", 10)) // 每行 90 字
}
transcript := strings.Join(lines, "\n")
chunks := ChunkTranscript(transcript, 500)
if len(chunks) < 2 {
t.Fatalf("分块数 = %d,期望被切成多块(每块上限 500 字)", len(chunks))
}
for index, chunk := range chunks {
if n := len([]rune(chunk)); n > 500 {
t.Errorf("第 %d 块 %d 字,超过上限 500", index+1, n)
}
}
// 拼回去必须与原文逐字一致(块之间用换行连接,原文也是换行连接)。
if got := strings.Join(chunks, "\n"); got != transcript {
t.Errorf("分块后拼回的文本与原文不一致:原文 %d 字,拼回 %d 字",
len([]rune(transcript)), len([]rune(got)))
}
}
// TestChunkTranscriptSplitsOverlongLine 断言单行超长时硬切而不是整行塞进一块,
// 否则纯文本(无分段信息)的逐字稿会绕过上限。
func TestChunkTranscriptSplitsOverlongLine(t *testing.T) {
transcript := strings.Repeat("啊", 1200)
chunks := ChunkTranscript(transcript, 500)
if len(chunks) != 3 {
t.Fatalf("分块数 = %d,期望 3", len(chunks))
}
if got := strings.Join(chunks, ""); got != transcript {
t.Error("硬切后内容与原文不一致")
}
}
func TestChunkTranscriptShortInputIsSingleChunk(t *testing.T) {
if got := ChunkTranscript("只有一句话。", 500); len(got) != 1 {
t.Errorf("短稿被切成 %d 块,期望 1 块", len(got))
}
if got := ChunkTranscript(" ", 500); got != nil {
t.Errorf("空白稿返回了 %d 块,期望 nil", len(got))
}
}
// TestMediaFilePath 断言路径按审批状态分目录,且拒绝越出目录的文件名。
func TestMediaFilePath(t *testing.T) {
approved, err := MediaFilePath(model.MediaFile{ID: 1, StoredName: "a.mp3", Status: "approved"})
if err != nil {
t.Fatalf("approved 素材解析失败: %v", err)
}
if filepath.Base(filepath.Dir(approved)) != "approved" {
t.Errorf("approved 素材路径 = %q,期望落在 approved/ 下", approved)
}
pending, err := MediaFilePath(model.MediaFile{ID: 2, StoredName: "b.mp3", Status: "pending"})
if err != nil {
t.Fatalf("pending 素材解析失败: %v", err)
}
if filepath.Base(filepath.Dir(pending)) != "pending" {
t.Errorf("pending 素材路径 = %q,期望落在 pending/ 下", pending)
}
// 脏记录(含分隔符 / 只有 StoredPath / 两者都空)必须报错,不得拼出目录外的路径。
if _, err := MediaFilePath(model.MediaFile{ID: 3, StoredName: "../../etc/passwd"}); err == nil {
t.Error("含路径分隔符的 StoredName 没有被拒绝")
}
if _, err := MediaFilePath(model.MediaFile{ID: 4}); err == nil {
t.Error("既无 StoredName 也无 StoredPath 的素材没有被拒绝")
}
fallback, err := MediaFilePath(model.MediaFile{ID: 5, StoredPath: "c.mp3", Status: "approved"})
if err != nil || filepath.Base(fallback) != "c.mp3" {
t.Errorf("只有 StoredPath 的旧记录没有被兜住: path=%q err=%v", fallback, err)
}
}
// TestTranscribeBytesRejectsNonAudioRoute 断言:路由指错时直接报错,
// 不会带着 chat 路由去请求 ASR 服务。这是「配错要吵,不要静默」那条规则的单测。
func TestTranscribeBytesRejectsNonAudioRoute(t *testing.T) {
_, err := TranscribeBytes(1, []byte("not-audio"), "x.mp3", "mp3", "zh", "chat_route_lmuai_deepseek_v4_flash")
if err == nil {
t.Fatal("把 chat 路由当音频路由使用时没有报错")
}
if !strings.Contains(err.Error(), "audio_routes") {
t.Errorf("错误信息没有点明 audio_routes 配错:%v", err)
}
t.Logf("按预期拒绝: %v", err)
}
// TestRunLLMStepRejectsUnknownKind 断言未知步骤直接报错,不会静默走某套默认提示词。
func TestRunLLMStepRejectsUnknownKind(t *testing.T) {
if _, err := RunLLMStep(nil, Kind("nope"), "逐字稿", nil); err == nil {
t.Error("未知 kind 没有报错")
}
}
// TestTranscribeRealNetwork 用真实 ASR 端点做一次端到端转写(含说话人分离)。
//
// 默认跳过:它打的是第三方付费接口,且会把音频发到公网。需要显式给音频路径才跑:
//
// ASR_TEST_AUDIO=/tmp/asr_two_speakers.mp3 go test ./internal/skills/packages/audio_transcribe/ -run TestTranscribeRealNetwork -v
//
// 音频应当是「两段不同音色」的合成语音,这样才能同时验证转写文本与说话人分离。
func TestTranscribeRealNetwork(t *testing.T) {
path := os.Getenv("ASR_TEST_AUDIO")
if path == "" {
t.Skip("未设置 ASR_TEST_AUDIO,跳过真实网络转写测试")
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读取测试音频失败: %v", err)
}
result, err := TranscribeBytes(1, data, filepath.Base(path), ExtractExt(path), "zh", "")
if err != nil {
t.Fatalf("真实转写失败: %v", err)
}
if strings.TrimSpace(result.Text) == "" {
t.Fatal("转写文本为空")
}
if result.RouteID == "" || result.Model == "" {
t.Errorf("结果没有带路由/模型信息: route=%q model=%q", result.RouteID, result.Model)
}
if !result.HasSpeakers {
t.Errorf("没有识别出说话人(期望两段不同音色被分开):%+v", result.Segments)
}
if len(result.Segments) < 2 {
t.Errorf("分段数 = %d,期望至少 2 段", len(result.Segments))
}
speakers := map[string]bool{}
for _, s := range result.Segments {
speakers[s.Speaker] = true
if s.Text == "" {
t.Error("存在空文本的分段")
}
}
if len(speakers) < 2 {
t.Errorf("只识别出 %d 个说话人,期望 2 个", len(speakers))
}
t.Logf("route=%s model=%s duration=%.2fs 说话人=%v 分段=%d",
result.RouteID, result.Model, result.Duration, speakers, len(result.Segments))
t.Logf("逐字稿:\n%s", FormatTranscriptWithSpeakers(result))
}
// TestCheckCompletionRejectsBudgetExhaustedAndEmpty 是「绝不把半截稿当成品收下」的守卫测试。
//
// 为什么用纯函数测而不是打一次真实模型:这道判断只依赖 finish_reason、正文和
// 一个整数,没有任何理由非要连网才能验。
//
// 这一条钉的是行为本身:
// - finish_reason=length + 半截正文 → **必须报错**(改之前是原样返回、落库成产物)
// - finish_reason=length + 空正文 → 报「预算不够」,不要报那句会把排查引偏的「空正文」
// - 报错必须带上**路由名与该路由的 max_tokens** —— 这条错误的全部价值就在于
// 指出「把 max_tokens 提上去」这个动作,不带这两个数字等于没说
// - finish_reason=stop + 正常正文 → 放行
// - finish_reason=stop + 空正文 → 报空正文(这不是预算问题,别指错方向)
//
// 「正文写了多少字」那部分由 TestBudgetErrorReportsHowMuchWasWritten 单独验,
// 因为那个数字应当由输入算出来,写死在表里只会变成一处迟早对不上的手抄本。
func TestCheckCompletionRejectsBudgetExhaustedAndEmpty(t *testing.T) {
cases := []struct {
name string
finishReason string
content string
maxTokens int
wantErr bool
wantInErr []string
wantText string
}{
{
"预算耗尽且有半截正文", "length", "# 纪要\n## 议题一\n大家讨论了素材审", 4096,
true, []string{"预算不够", "chat_route_test", "4096", "16384"}, "",
},
{
"预算耗尽且正文为空", "length", "", 4096,
true, []string{"预算不够", "4096"}, "",
},
{
"length 的大小写与空白不影响判定", " LENGTH ", "半截", 8192,
true, []string{"预算不够", "8192"}, "",
},
{
"正常结束", "stop", "## 核心结论\n- 周四提测", 4096,
false, nil, "## 核心结论\n- 周四提测",
},
{
"正常结束但正文只有空白", "stop", " \n\t ", 4096,
true, []string{"空正文"}, "",
},
{
"没有 finish_reason 时按正文判断", "", "有正文", 4096,
false, nil, "有正文",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
text, err := checkCompletion(tc.finishReason, tc.content, "chat_route_test", tc.maxTokens)
if tc.wantErr {
if err == nil {
t.Fatalf("没有报错,返回了 %q —— 半截稿会被当成成品落库", text)
}
for _, want := range tc.wantInErr {
if !strings.Contains(err.Error(), want) {
t.Errorf("错误信息里没有 %q,这句话就白报了:%v", want, err)
}
}
return
}
if err != nil {
t.Fatalf("不该报错却报了:%v", err)
}
if text != tc.wantText {
t.Errorf("正文 = %q,期望 %q", text, tc.wantText)
}
})
}
}
// TestBudgetErrorReportsHowMuchWasWritten 验「本次正文只写出 N 字」里的 N 是真的。
//
// 为什么值得单拎一条:这个数字是判断病情的唯一线索 —— 0 字是「思考还没写完、
// 正文没开始」,1161 字是「写了一半被砍断」,两者的输入长度和处置都不一样。
// 数字算错比不报还糟,因为它看起来精确。
func TestBudgetErrorReportsHowMuchWasWritten(t *testing.T) {
// 注释放上一行而不是行尾:中文注释的行尾对齐会被 gofmt 推来推去,
// 每次格式化都产生一串与内容无关的 diff。
contents := []string{
// 思考没写完,正文一个字都没有
"",
// 刚开了个头
"半截稿",
// 写了大半被砍断
strings.Repeat("字", 1161),
// 计的是 TrimSpace 之后的长度 —— 落库的就是这一份
" 两头的空白不算 ",
}
for _, content := range contents {
_, err := checkCompletion("length", content, "chat_route_test", 4096)
if err == nil {
t.Fatalf("正文 %q 触发了截断却没有报错", content)
}
want := fmt.Sprintf("写出 %d 字", len([]rune(strings.TrimSpace(content))))
if !strings.Contains(err.Error(), want) {
t.Errorf("正文 %d 字,错误里应当出现 %q:%v",
len([]rune(strings.TrimSpace(content))), want, err)
}
}
}
// TestMultiChunkSettlementIsPerKind 钉住「多块怎么收口」这个按步骤分的决定。
//
// 为什么值得单独一条:这不是风格问题,是预算算出来的。
// 结构化稿的输出与输入等长 —— 一份 9000 字的逐字稿按 2500 字切 4 块后,
// 若再让模型把 4 段结果归并成一篇,等于要它一次写 9000 字(≈5000+ token),
// 而对话路由的 max_tokens 是 4096,必然截断。分段结果本身已经结构化,
// 按序拼起来信息一点不少。
// 纪要相反:输出远短于输入,归并才是它的价值(跨段去重、按时序排),拼不了。
//
// 有人要把 joinOnly 改回 false 时,这条会红 —— 请连带把 max_tokens 提上去,
// 否则用户拿到的是断稿(而 checkCompletion 会把它变成一次失败,不是一份残篇)。
func TestMultiChunkSettlementIsPerKind(t *testing.T) {
structure, err := specFor(KindStructure)
if err != nil {
t.Fatalf("取 structure 文案失败:%v", err)
}
if !structure.joinOnly {
t.Error("structure 应当是 joinOnly:让模型归并等长输出会撞 max_tokens,得到的是断稿")
}
minutes, err := specFor(KindMinutes)
if err != nil {
t.Fatalf("取 minutes 文案失败:%v", err)
}
if minutes.joinOnly {
t.Error("minutes 不该是 joinOnly:跨段去重和按时序排正是归并的价值,拼起来做不到")
}
if strings.TrimSpace(minutes.mergeRequest) == "" {
t.Error("minutes 走归并,却没有归并提示词")
}
}
@@ -0,0 +1,164 @@
package audiotranscribe
import (
"bytes"
"encoding/json"
"io"
"net/http"
"os"
"strconv"
"strings"
"testing"
"time"
"eai_agentplatform/backend/internal/config"
)
// TestZZChunkProbe 复现「11 段里有一段返回没有 choices 的响应」。
//
// 背景:四步端到端跑到第 3 步(整理)的第 4/11 段时,上游回了一个 HTTP 200、
// 但报文里没有 choices 的响应,整步就此失败。这个探针不需要 ASR —— 第 3 步的输入
// 只是文本,用等长的密集中文替身就能复现同样的调用形状(1200 字分块、同样的
// 系统/指令提示词、同一条路由),连打若干段,看异常出现的频率以及原始报文长什么样。
//
// 只读响应、不改数据;会真实计费,所以默认跳过:
//
// LLM_CHUNK_PROBE=1 LLM_CHUNK_PROBE_ROUNDS=2 go test ./internal/skills/packages/audio_transcribe/ -run TestZZChunkProbe -v
func TestZZChunkProbe(t *testing.T) {
if os.Getenv("LLM_CHUNK_PROBE") == "" {
t.Skip("未设置 LLM_CHUNK_PROBE,跳过分块探针")
}
routeID := os.Getenv("LLM_BUDGET_PROBE_ROUTE")
if routeID == "" {
routeID = "audio_transcribe_llm"
}
route, err := config.GetRoute(routeID)
if err != nil || route == nil {
t.Fatalf("取路由 %s 失败: %v", routeID, err)
}
t.Logf("路由 id=%s model=%s max_tokens=%d", route.RouteID, route.Model, route.MaxTokens)
raw, err := os.ReadFile("../../TOP_CODING_RULES.md")
if err != nil {
t.Fatalf("读取密集中文样本失败: %v", err)
}
dense := []rune(strings.Join(strings.Fields(string(raw)), " "))
// 拼到足够长:一次 11 段,跟端到端里真实的段数一致。
const chunkChars = 1200
need := chunkChars * 11
for len(dense) < need {
dense = append(dense, dense...)
}
spec, err := specFor(KindStructure)
if err != nil {
t.Fatalf("取 structure 文案失败: %v", err)
}
rounds := 1
if v := os.Getenv("LLM_CHUNK_PROBE_ROUNDS"); v != "" {
if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 {
rounds = n
}
}
// 允许从环境变量压一个 max_tokens,用来回答「把上限抬高之后,逐块调用会不会
// 反而话变多、更慢」—— 上限抬高的唯一代价就在这里,别靠猜。
maxTokensOverride := 0
if v := os.Getenv("LLM_CHUNK_PROBE_MAX_TOKENS"); v != "" {
if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 {
maxTokensOverride = n
}
}
if maxTokensOverride > 0 {
route.MaxTokens = maxTokensOverride
t.Logf("已把本次探针的 max_tokens 压成 %d", maxTokensOverride)
}
anomalies, attempts := 0, 0
for round := 1; round <= rounds; round++ {
for index := 0; index < 11; index++ {
chunk := string(dense[index*chunkChars : (index+1)*chunkChars])
attempts++
if !probeChunk(t, route, spec, index+1, chunk) {
anomalies++
}
}
}
t.Logf("共 %d 次调用,异常 %d 次", attempts, anomalies)
}
// probeChunk 打一次「整理某一段」的调用,正常返回 true。
func probeChunk(t *testing.T, route *config.RouteConfig, spec kindSpec, seq int, chunk string) bool {
t.Helper()
body := map[string]any{
"model": route.Model,
"messages": []map[string]string{
{"role": "system", "content": spec.system},
{"role": "user", "content": spec.instruction + "\n\n逐字稿:\n" + chunk},
},
"stream": false,
"temperature": route.Temperature,
"max_tokens": route.MaxTokens,
}
payload, _ := json.Marshal(body)
client := &http.Client{Timeout: 180 * time.Second}
req, err := http.NewRequest(http.MethodPost, route.FullURL, bytes.NewReader(payload))
if err != nil {
t.Fatalf("构造请求失败: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if route.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+route.APIKey)
}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
t.Logf("第 %d 段 请求失败: %v", seq, err)
return false
}
defer resp.Body.Close()
rawRes, _ := io.ReadAll(resp.Body)
elapsed := time.Since(start).Round(time.Millisecond)
var out struct {
Choices []struct {
FinishReason string `json:"finish_reason"`
Message struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
} `json:"message"`
} `json:"choices"`
Usage map[string]any `json:"usage"`
}
if err := json.Unmarshal(rawRes, &out); err != nil {
t.Logf("第 %d 段 http=%d 解析失败(原文 %d 字节):%s",
seq, resp.StatusCode, len(rawRes), truncateRunes(string(rawRes), 300))
return false
}
if resp.StatusCode != http.StatusOK || len(out.Choices) == 0 {
// 这就是端到端里把整步打挂的那种响应:原始报文必须原样留下来。
t.Logf("第 %d 段 异常 http=%d 用时=%s choices=%d 原始报文:%s",
seq, resp.StatusCode, elapsed, len(out.Choices), truncateRunes(string(rawRes), 600))
return false
}
c := out.Choices[0]
t.Logf("第 %d 段 ok 用时=%s finish=%q 思考=%d字 正文=%d字 usage=%v",
seq, elapsed, c.FinishReason, len([]rune(c.Message.ReasoningContent)), len([]rune(c.Message.Content)), out.Usage)
return strings.TrimSpace(c.Message.Content) != ""
}
// truncateRunes 按字数截断,用于把原始报文压进一行日志。
func truncateRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
@@ -0,0 +1,159 @@
package audiotranscribe
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"testing"
"time"
"eai_agentplatform/backend/internal/config"
)
// TestZZMergeBudgetProbe 找出「纪要归并调用」到底需要多大的预算。
//
// 背景:14 段的长稿跑到纪要的归并时挂掉,finish_reason=length、正文 0 字 ——
// 归并的输入是各段纪要拼接,是全流程最长的一次输入,8192 的预算被思考吃光。
//
// 要决定的是「往上要预算」还是「改成多级归并」,而这两条路的分岔点只有一个事实:
// 上游到底认不认比 8192 更大的 max_tokens。不认,就只能在调用次数上想办法;
// 认,加预算是最省事也最不容易再坏的做法。
//
// 会真实计费,默认跳过:
//
// LLM_MERGE_PROBE=1 go test ./internal/skills/packages/audio_transcribe/ -run TestZZMergeBudgetProbe -v -timeout 20m
func TestZZMergeBudgetProbe(t *testing.T) {
if os.Getenv("LLM_MERGE_PROBE") == "" {
t.Skip("未设置 LLM_MERGE_PROBE,跳过归并预算探针")
}
route, err := config.GetRoute("audio_transcribe_llm")
if err != nil || route == nil {
t.Fatalf("取路由失败: %v", err)
}
t.Logf("路由 id=%s model=%s 配置内的 max_tokens=%d", route.RouteID, route.Model, route.MaxTokens)
spec, err := specFor(KindMinutes)
if err != nil {
t.Fatalf("取纪要文案失败: %v", err)
}
raw, err := os.ReadFile("../../TOP_CODING_RULES.md")
if err != nil {
t.Fatalf("读取密集中文样本失败: %v", err)
}
dense := []rune(strings.Join(strings.Fields(string(raw)), " "))
// 按「14 段纪要拼接」的量级造输入:每段约 600 字,共 8400 字。
// 这个大小是失败的现场量级,也是要先解决的那一档。
partials := make([]string, 0, 14)
for i := 0; i < 14; i++ {
start := (i * 600) % (len(dense) - 600)
partials = append(partials, string(dense[start:start+600]))
}
var merged strings.Builder
merged.WriteString(spec.mergeRequest)
merged.WriteString("\n\n")
for i, partial := range partials {
fmt.Fprintf(&merged, "【第 %d 段】\n", i+1)
merged.WriteString(partial)
merged.WriteString("\n\n")
}
user := merged.String()
t.Logf("归并输入 %d 字", len([]rune(user)))
// 8192 是失败现场(对照组),其余是「上游认不认更大预算」的问题。
for _, maxTokens := range []int{8192, 16384, 32768} {
t.Run("max_tokens="+strconv.Itoa(maxTokens), func(t *testing.T) {
call, err := probeRawCall(route, spec.mergeSystem, user, maxTokens)
if err != nil {
t.Logf("请求层面就失败了:%v", err)
return
}
t.Logf("%s", call)
})
}
}
// probeRawCall 直接打一次,把原始结果压成一行日志返回。
//
// 不走 callModel:它会在预算不够时直接报错,而这里恰恰要看「不够时长什么样」
// 以及「给更多预算上游认不认」,两个问题都要求把响应原样拿回来。
func probeRawCall(route *config.RouteConfig, system, user string, maxTokens int) (string, error) {
body := map[string]any{
"model": route.Model,
"messages": []map[string]string{
{"role": "system", "content": system},
{"role": "user", "content": user},
},
"stream": false,
"temperature": route.Temperature,
"max_tokens": maxTokens,
}
payload, _ := json.Marshal(body)
client := &http.Client{Timeout: 300 * time.Second}
req, err := http.NewRequest(http.MethodPost, route.FullURL, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
if route.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+route.APIKey)
}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
rawRes, _ := io.ReadAll(resp.Body)
elapsed := time.Since(start).Round(time.Second)
var out struct {
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error"`
Choices []struct {
FinishReason string `json:"finish_reason"`
Message struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
} `json:"message"`
} `json:"choices"`
Usage map[string]any `json:"usage"`
}
if err := json.Unmarshal(rawRes, &out); err != nil {
return "http=" + strconv.Itoa(resp.StatusCode) + " 解析失败:" + truncateRunes(string(rawRes), 300), nil
}
if out.Error != nil {
return "http=" + strconv.Itoa(resp.StatusCode) + " 上游报错:" + out.Error.Type + " / " + out.Error.Message, nil
}
if resp.StatusCode != http.StatusOK || len(out.Choices) == 0 {
return "http=" + strconv.Itoa(resp.StatusCode) + " 异常报文:" + truncateRunes(string(rawRes), 300), nil
}
c := out.Choices[0]
return "用时=" + elapsed.String() +
" finish=" + c.FinishReason +
" 思考=" + strconv.Itoa(len([]rune(c.Message.ReasoningContent))) + "字" +
" 正文=" + strconv.Itoa(len([]rune(c.Message.Content))) + "字" +
" usage=" + briefUsage(out.Usage), nil
}
// briefUsage 把 usage 压成一行;只关心两个 token 数,不关心嵌套细节。
func briefUsage(m map[string]any) string {
if m == nil {
return "{}"
}
return "prompt=" + fmt.Sprint(m["prompt_tokens"]) +
" completion=" + fmt.Sprint(m["completion_tokens"]) +
" total=" + fmt.Sprint(m["total_tokens"])
}
@@ -0,0 +1,106 @@
package audiotranscribe
import (
"context"
"os"
"strings"
"testing"
"time"
"eai_agentplatform/backend/internal/config"
)
// TestZZRunLLMStepAtElevenChunks 用真实代码路径跑一遍「长稿」的第 3、4 步。
//
// 为什么需要它:四步端到端要花钱调 ASR,而它每次都挂在第 3 步 —— 也就是说第 4 步
// (纪要)**从来没被真正跑到过**,第 3 步也没跑到过 11 段全通。这两步里最可疑的是
// 纪要的收口:它是 joinOnly=false,11 段各自出稿后还要再打一次**归并**调用,
// 而归并的输入是 11 段结果全文拼接 —— 是整个流程里最长的一次输入,最容易撞预算。
//
// 这个探针不发 ASR,只拿一篇等长的密集中文当逐字稿,直接调 RunLLMStep,
// 走的就是线上那条路径(分块、callModel、重试、收口全都一样)。
//
// 会真实计费,所以默认跳过:
//
// LLM_STEP_PROBE=1 go test ./internal/skills/packages/audio_transcribe/ -run TestZZRunLLMStepAtElevenChunks -v -timeout 30m
func TestZZRunLLMStepAtElevenChunks(t *testing.T) {
if os.Getenv("LLM_STEP_PROBE") == "" {
t.Skip("未设置 LLM_STEP_PROBE,跳过长稿步骤探针")
}
routeID := os.Getenv("LLM_BUDGET_PROBE_ROUTE")
if routeID == "" {
// 与 audio_handlers.go 里的 audioLLMAgentRoute 同一个名字:
// 那个常量在 skillapi 包里,这里够不着,所以直接写字面量。
routeID = "audio_transcribe_llm"
}
route, err := config.GetRoute(routeID)
if err != nil || route == nil {
t.Fatalf("取路由 %s 失败: %v", routeID, err)
}
t.Logf("路由 id=%s model=%s max_tokens=%d", route.RouteID, route.Model, route.MaxTokens)
transcript := syntheticTranscript(t)
chunks := ChunkTranscript(transcript, llmChunkChars)
t.Logf("替身逐字稿 %d 字,切成 %d 段", len([]rune(transcript)), len(chunks))
if len(chunks) != 11 {
t.Logf("注意:段数是 %d 而不是 11,与端到端里的量级不同", len(chunks))
}
// 顺序跑两个步骤,且**不因前一个失败就跳过**后一个 —— 它们要分别定性,
// 一个挂了正好说明另一个也得单独看。
for _, kind := range []Kind{KindStructure, KindMinutes} {
t.Run(string(kind), func(t *testing.T) {
start := time.Now()
out, err := RunLLMStep(context.Background(), kind, transcript, route)
elapsed := time.Since(start).Round(time.Second)
if err != nil {
t.Fatalf("%s 步骤失败(用时 %s):%v", kind, elapsed, err)
}
if strings.TrimSpace(out) == "" {
t.Fatalf("%s 步骤返回空正文(用时 %s)—— 落库会变成一个点开什么都没有的产物", kind, elapsed)
}
t.Logf("%s 步骤完成:用时 %s,输出 %d 字", kind, elapsed, len([]rune(out)))
// 头 120 字留个样子,确认它不是一段自我说明或占位文本。
t.Logf("%s 输出开头:%s", kind, truncateRunes(strings.TrimSpace(out), 120))
})
}
}
// syntheticTranscript 造一份与真实逐字稿同形的长文本:每段一行、带说话人标签,
// 总长约 13000 字(26 分钟会议的量级)。
//
// 刻意带上说话人与换行,是因为 ChunkTranscript 优先在行边界切 —— 用一整块无换行的
// 文本测,切法就跟线上不一样了。
func syntheticTranscript(t *testing.T) string {
t.Helper()
raw, err := os.ReadFile("../../TOP_CODING_RULES.md")
if err != nil {
t.Fatalf("读取密集中文样本失败: %v", err)
}
dense := []rune(strings.Join(strings.Fields(string(raw)), " "))
const lineChars = 40
var b strings.Builder
written, line := 0, 0
for written < 14000 {
speaker := line%2 + 1
end := written + lineChars
if end > len(dense) {
end = len(dense)
}
b.WriteString("[说话人")
b.WriteString(string(rune('0' + speaker)))
b.WriteString("] ")
b.WriteString(string(dense[written:end]))
b.WriteString("\n")
written = end
line++
if written >= len(dense) {
// 样本用完了就从头接,保证长度够。
written = 0
}
}
return b.String()
}
@@ -1,6 +1,7 @@
package reportgeneration
import (
"context"
"encoding/json"
"fmt"
"strings"
@@ -94,7 +95,7 @@ func PromptReportWithSections(req ReportGenRequest, knowledge string) []ReportCh
)
aiRoute, _ := config.GetRoute("title_gen")
aiContent, err := ai.GenerateWithFallback(aiRoute, []ai.Message{
aiContent, err := ai.GenerateWithFallback(context.Background(), aiRoute, []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
})
@@ -130,7 +131,7 @@ func PromptReportAutoChapters(req ReportGenRequest, knowledge string) []ReportCh
)
aiRoute, _ := config.GetRoute("title_gen")
aiContent, err := ai.GenerateWithFallback(aiRoute, []ai.Message{
aiContent, err := ai.GenerateWithFallback(context.Background(), aiRoute, []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
})