You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
120 lines
4.4 KiB
120 lines
4.4 KiB
package memory
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"yunyan/comm"
|
|
)
|
|
|
|
// 模型时常无视「不要 markdown 代码块」,也常在数组前后带一句解释。
|
|
// 这些都不是异常输入,是实测里的常态,解析必须扛得住。
|
|
func TestParseExtractedTodos_Tolerance(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
want int
|
|
}{
|
|
{"裸数组", `[{"title":"跟进A客户","owner":"王总","due_date":"2026-09-10","due_raw":"下周四前"}]`, 1},
|
|
{"带 json 代码块", "```json\n[{\"title\":\"跟进A客户\"}]\n```", 1},
|
|
{"带无语言代码块", "```\n[{\"title\":\"跟进A客户\"}]\n```", 1},
|
|
{"前后有解释文字", "好的,以下是待办:\n[{\"title\":\"跟进A客户\"}]\n希望对你有帮助。", 1},
|
|
{"空数组", `[]`, 0},
|
|
{"空数组带代码块", "```json\n[]\n```", 0},
|
|
{"多条", `[{"title":"A"},{"title":"B"},{"title":"C"}]`, 3},
|
|
}
|
|
for _, c := range cases {
|
|
got, err := parseExtractedTodos(c.raw)
|
|
if err != nil {
|
|
t.Errorf("%s: 解析失败 %v", c.name, err)
|
|
continue
|
|
}
|
|
if len(got) != c.want {
|
|
t.Errorf("%s: 期望 %d 条,得到 %d 条", c.name, c.want, len(got))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 真正解析不出来时必须报错,不能悄悄返回空数组——
|
|
// 那样「模型输出坏了」和「本次会议确实没有待办」就分不开了。
|
|
func TestParseExtractedTodos_RealFailureReturnsError(t *testing.T) {
|
|
for _, raw := range []string{"", "本次会议无明确待办事项。", "{不是JSON"} {
|
|
if _, err := parseExtractedTodos(raw); err == nil {
|
|
t.Errorf("输入 %q 应该报错而不是返回空数组", raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseExtractedTodos_FieldMapping(t *testing.T) {
|
|
raw := `[{"title":"跟进A客户","owner":"王总","due_date":"2026-09-10","due_raw":"下周四前"}]`
|
|
got, err := parseExtractedTodos(raw)
|
|
if err != nil || len(got) != 1 {
|
|
t.Fatalf("解析失败 err=%v n=%d", err, len(got))
|
|
}
|
|
if got[0].Title != "跟进A客户" || got[0].Owner != "王总" ||
|
|
got[0].DueDate != "2026-09-10" || got[0].DueRaw != "下周四前" {
|
|
t.Errorf("字段映射错: %+v", got[0])
|
|
}
|
|
}
|
|
|
|
// 内容门槛:拦住 6 秒测试通话、20 秒单人自述这类东西,
|
|
// 同时不能误伤「上游没给数据」和「运营把门槛关掉」两种情况。
|
|
func TestMeetingContentEnough(t *testing.T) {
|
|
const (
|
|
minSec = int32(60)
|
|
minChars = 120
|
|
)
|
|
cases := []struct {
|
|
name string
|
|
seconds int32
|
|
runes int
|
|
minSec int32
|
|
minChars int
|
|
want bool
|
|
}{
|
|
{"6秒测试通话", 6, 300, minSec, minChars, false},
|
|
{"20秒单人自述:时长先拦住", 20, 90, minSec, minChars, false},
|
|
{"够长但正文太薄(长段噪音)", 600, 40, minSec, minChars, false},
|
|
{"正常会议", 900, 3000, minSec, minChars, true},
|
|
{"刚好卡在门槛上算通过", 60, 120, minSec, minChars, true},
|
|
// 下面两条是刻意的失败方向:宁可多抽,也不要因为一个字段没传就静默关掉抽取
|
|
{"上游没给时长 → 不按时长拦", 0, 3000, minSec, minChars, true},
|
|
{"上游没给字数 → 不按字数拦", 900, 0, minSec, minChars, true},
|
|
{"门槛配 0 = 不限", 6, 10, 0, 0, true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got, why := meetingContentEnough(c.seconds, c.runes, c.minSec, c.minChars)
|
|
if got != c.want {
|
|
t.Fatalf("期望 %v,得到 %v(%s)", c.want, got, why)
|
|
}
|
|
if !got && why == "" {
|
|
t.Error("拦下来了却没给出原因,日志里会只剩一句「跳过抽取」")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 门槛的默认值必须真的生效:LoadConfig 不写这两项时是 60/120,
|
|
// 显式写 0 则表示不限——两者不能混为一谈(零值与「没配」在 mapstructure 里长得一样)。
|
|
func TestOptionsExtractThresholdDefaults(t *testing.T) {
|
|
var noCfg Options
|
|
if err := noCfg.LoadConfig(map[string]interface{}{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if noCfg.MeetingExtractMinSeconds != comm.MeetingMinSeconds || noCfg.MeetingExtractMinChars != 120 {
|
|
t.Errorf("没配时应取默认 %d/120,得到 %d/%d",
|
|
comm.MeetingMinSeconds, noCfg.MeetingExtractMinSeconds, noCfg.MeetingExtractMinChars)
|
|
}
|
|
|
|
var zeroCfg Options
|
|
if err := zeroCfg.LoadConfig(map[string]interface{}{
|
|
"meeting_extract_min_seconds": 0,
|
|
"meeting_extract_min_chars": 0,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if zeroCfg.MeetingExtractMinSeconds != 0 || zeroCfg.MeetingExtractMinChars != 0 {
|
|
t.Errorf("显式配 0 应表示不限,得到 %d/%d",
|
|
zeroCfg.MeetingExtractMinSeconds, zeroCfg.MeetingExtractMinChars)
|
|
}
|
|
}
|
|
|