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.
 
 
 
 
 
 

250 lines
11 KiB

package memory
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"yunyan/comm"
"yunyan/pb"
)
/*
会议纪要 → 待办抽取(第三路)。
在 echomeet 的 AIProcess 写完 summary/overview、状态置 Completed **之后**由它回调进来,
独立跑、失败只记日志不改 rec.State —— 用户照样看得到纪要,只是没有自动待办。
几个刻意的选择:
- **prompt 放代码不放 echomeet_template**:模板有 1189 条(29 组 × 41 语言),改不动也改不齐;
各模板 outline 职责本就不同,塞进去会互相干扰;输出要被程序 parse 成 JSON,
格式严格性不能交给后台可随手编辑的文本。
- **输入用已生成的 summary 而不是转写全文**:summary 约千字,全文可达上万字,
成本只是前两路的零头。
- **必须注入会议日期**:不给日期,模型把「下周三前」换算成具体日期就是瞎猜。
- **复用 rec.LlmSvcId**:与本条会议的总结同一个模型。同一条会议两路用不同模型,
出问题时无法归因。
*/
const meetingExtractPrompt = `你是一个会议待办抽取器。用户会给你一份会议纪要,以及这次会议的日期。
请从纪要的「待办事项」部分抽取出结构化的任务列表。
严格要求:
1. 只输出一个 JSON 数组,不要 markdown 代码块,不要任何解释文字。数组为空就输出 []。
2. 数组每一项的字段固定为:
{"title":"任务内容","owner":"负责人","due_date":"YYYY-MM-DD","due_raw":"截止时间原文"}
3. title 必填。owner / due_date / due_raw 没有就填空字符串 ""。
4. **title 与 owner 必须与输入纪要使用同一种语言**,不要翻译成中文。
5. due_date 只在能从原文明确推算出具体日期时才填,推算基准是给定的会议日期。
含糊的表述(「尽快」「月底前」无法定位到具体某天)一律留空 due_date,
把原文放进 due_raw。**禁止猜测、禁止编造日期。**
6. 只收录会议中明确要求执行的任务;仅在讨论中提及、未拍板的设想不计入。
7. 同一件事被多次提及只输出一条,取最终确定的版本。
8. 纪要若表明这不是一场真实会议(「单人测试」「录音测试」「无实质内容」「未形成待办」之类),
直接输出 [],不要为了填满「待办事项」这一节而编造任务。
9. 说话人对自己的愿望、需求、期望的描述(如「希望能导出成文字」「想评估一下效果」)不是待办;
只收录会议里明确指派给某人、某方去执行的事。`
// extractedTodo 抽取结果的一条
type extractedTodo struct {
Title string `json:"title"`
Owner string `json:"owner"`
DueDate string `json:"due_date"`
DueRaw string `json:"due_raw"`
}
// 一次会议最多落多少条待办。模型偶尔会把整篇纪要拆成几十条,
// 全落下去会把用户的日历那一天彻底刷爆。
const maxExtractPerMeeting = 30
// meetingContentEnough 内容够不够格进拾忆。ok=false 时 reason 是能直接打进日志的说明。
//
// 两道门槛任一不过就跳过。时长与字数都要看:
// - 只看时长:一段 5 分钟的环境噪音转不出几个字,照样会被抽出废话;
// - 只看字数:模型在内容极薄时会按模板兜底规则写出六百多字的纪要,所以字数指的是
// **转写正文**(调用方已去掉说话人标签),卡在纪要上根本拦不住。
//
// 阈值 <= 0 表示这一道不限;**数据缺失(seconds/contentRunes 为 0)一律按通过处理**:
// 宁可多抽几条用户能自己删的待办,也不要因为上游漏传一个字段就把所有会议的抽取静默关掉。
//
// 写成纯函数而不是 Memory 的方法,是为了能直接单测,不用去造一个带日志的模块实例。
func meetingContentEnough(seconds int32, contentRunes int, minSeconds int32, minChars int) (bool, string) {
if minSeconds > 0 && seconds > 0 && seconds < minSeconds {
return false, fmt.Sprintf("录音仅 %d 秒(门槛 %d 秒),内容太薄", seconds, minSeconds)
}
if minChars > 0 && contentRunes > 0 && contentRunes < minChars {
return false, fmt.Sprintf("转写正文仅 %d 字(门槛 %d 字),内容太薄", contentRunes, minChars)
}
return true, ""
}
// ExtractMeetingTodos 从会议纪要抽待办并落库(实现 comm.IMemory)。
//
// meetingDate 是会议归属日期(YYYY-MM-DD);抽不出截止日期的项落在这一天并置
// date_certain=false,客户端应把它们归到「待定日期」分组,不要和当天确定事项混排
// —— 一次会抽出 8 条没写截止时间的待办全堆在会议当天,那一格就没法看了。
func (this *Memory) ExtractMeetingTodos(ctx context.Context, in comm.MeetingExtractInput) {
uid, recordID, summary := in.Uid, in.RecordID, in.Summary
meetingDate, llmSvcID := in.MeetingDate, in.LLMSvcID
defer func() {
if r := recover(); r != nil {
this.Errorf("会议待办抽取 panic record:%s err:%v", recordID, r)
}
}()
if !this.options.MeetingExtract {
return
}
if strings.TrimSpace(summary) == "" || uid == "" {
return
}
// 内容门槛:太短的录音一律不抽。
//
// ⚠️ 只拦抽取,**不动总结**:「记一下明天买菜」这种十几秒的语音备忘是合法用法,
// 纪要照出。也不把它判成 SummarizFail —— 客户端把那个码显示成
// 「总结失败,请稍后重试」,用户会一直点。
if ok, why := meetingContentEnough(in.Seconds, in.ContentRunes,
this.options.MeetingExtractMinSeconds, this.options.MeetingExtractMinChars); !ok {
this.Infof("会议待办抽取 record:%s %s,跳过抽取", recordID, why)
return
}
if _, ok := comm.ParseMemoryDate(meetingDate); !ok {
this.Warnf("会议待办抽取 record:%s 会议日期非法(%q),跳过", recordID, meetingDate)
return
}
if this.echo == nil {
this.Warnf("会议待办抽取 record:%s 未取到 echomeet,跳过", recordID)
return
}
// 本次是第几轮:取这条会议现有项的最大 gen_round + 1。
// 清理时删的是 gen_round < 本轮 且 user_edited=false 的项,所以轮次必须真的递增——
// 恒为 1 的话第二次重新生成会一条都清不掉,旧待办和新待办并存。
genRound := this.model.nextGenRound(recordID)
prompt := this.options.MeetingExtractPrompt
if prompt == "" {
prompt = meetingExtractPrompt
}
userContent := fmt.Sprintf("<meeting_date>%s</meeting_date>\n\n<summary>\n%s\n</summary>", meetingDate, summary)
c, cancel := context.WithTimeout(ctx, 90*time.Second)
text, usedSvc, err := this.echo.ChatLLM(c, llmSvcID, prompt, userContent)
cancel()
if err != nil {
// 失败隔离:只记日志,绝不改 rec.State。纪要是用户真正要的东西,
// 不能因为附赠的待办抽取失败就把它标成失败。
this.Errorf("会议待办抽取 record:%s 调用失败 svc:%s err:%v", recordID, usedSvc, err)
return
}
todos, err := parseExtractedTodos(text)
if err != nil {
this.Warnf("会议待办抽取 record:%s 解析失败已忽略 err:%v raw:%.200s", recordID, err, text)
return
}
if len(todos) == 0 {
this.Infof("会议待办抽取 record:%s 无待办", recordID)
return
}
if len(todos) > maxExtractPerMeeting {
this.Warnf("会议待办抽取 record:%s 返回 %d 条,截断到 %d", recordID, len(todos), maxExtractPerMeeting)
todos = todos[:maxExtractPerMeeting]
}
// 幂等:清掉上一轮**自动生成**的项,user_edited 的一律保留。
// 不做这个,用户点一次「重新生成」就会丢掉自己的修改。
if n, e := this.model.delMeetingItems(recordID, genRound); e != nil {
this.Warnf("会议待办抽取 record:%s 清理旧轮次失败已忽略 err:%v", recordID, e)
} else if n > 0 {
this.Infof("会议待办抽取 record:%s 清理旧轮次 %d 条", recordID, n)
}
meetDay, _ := comm.ParseMemoryDate(meetingDate)
saved := 0
for i, t := range todos {
t.Title = strings.TrimSpace(t.Title)
if t.Title == "" {
continue
}
item := &pb.DBMemoryItem{
Uid: uid, Category: comm.MemoryCatTodo, Source: comm.MemorySrcMeeting,
SourceId: recordID,
// client_key 用「记录id + 轮次 + 序号」,重跑同一轮不会插重
ClientKey: fmt.Sprintf("meet:%s:%d:%d", recordID, genRound, i),
Title: t.Title,
Owner: strings.TrimSpace(t.Owner),
DueRaw: strings.TrimSpace(t.DueRaw),
HappenDate: meetingDate,
DateCertain: false,
GenRound: genRound,
State: pb.MemoryState_MemoryState_Pending,
RemindAhead: 0, // 会议待办默认不提醒,用户改期时自己开
Currency: "CNY",
}
// 「未明确」是模板要求模型在原文没写时填的占位词,不是真的负责人
if item.Owner == "未明确" || strings.EqualFold(item.Owner, "unspecified") {
item.Owner = ""
}
// 服务端二次校验日期:解析不出、或早于会议当天的一律丢弃,只留 due_raw。
// 参考 SET_clock 踩过的坑——模型把「下午 3:30」的 time 填成 03:30。
if d, ok := comm.ParseMemoryDate(strings.TrimSpace(t.DueDate)); ok {
if !d.Before(meetDay) {
item.HappenDate = comm.FormatMemoryDate(d)
item.DateCertain = true
} else {
this.Warnf("会议待办抽取 record:%s 丢弃早于会议日的截止日 %s", recordID, t.DueDate)
}
}
if err := this.model.addItem(item); err != nil {
this.Warnf("会议待办抽取 record:%s 第%d条落库失败已跳过 err:%v", recordID, i, err)
continue
}
saved++
}
this.Infof("会议待办抽取 record:%s uid:%s svc:%s 落库 %d/%d 条", recordID, uid, usedSvc, saved, len(todos))
}
// DeleteMeetingItems 纪要被删时清掉它自动抽出来的待办(实现 comm.IMemory)。
//
// 调用方(echomeet 的 DelRecords)在归属校验通过之后另起 goroutine 调它,
// 失败只记日志:纪要已经删掉了,不能因为附带的清理失败就把删除本身判成失败。
func (this *Memory) DeleteMeetingItems(uid string, recordIDs []string) (int64, error) {
n, err := this.model.delMeetingItemsBySource(uid, recordIDs)
if err != nil {
return 0, err
}
if n > 0 {
this.Infof("纪要删除连带清理待办 uid:%s records:%v 删掉 %d 条(用户改过的保留)", uid, recordIDs, n)
}
return n, nil
}
// parseExtractedTodos 解析模型输出。
//
// 模型时常无视「不要 markdown 代码块」这条,所以先剥一层 ```json ... ```;
// 也可能在数组前后带一句解释,再按第一个 [ 到最后一个 ] 截一次。
func parseExtractedTodos(text string) ([]extractedTodo, error) {
s := strings.TrimSpace(text)
if strings.HasPrefix(s, "```") {
if i := strings.Index(s, "\n"); i >= 0 {
s = s[i+1:]
}
if i := strings.LastIndex(s, "```"); i >= 0 {
s = s[:i]
}
s = strings.TrimSpace(s)
}
if start, end := strings.Index(s, "["), strings.LastIndex(s, "]"); start >= 0 && end > start {
s = s[start : end+1]
}
out := make([]extractedTodo, 0)
if err := json.Unmarshal([]byte(s), &out); err != nil {
return nil, err
}
return out, nil
}