package squad import ( "archive/zip" "bytes" "encoding/xml" "fmt" "io" "path/filepath" "strings" "unicode/utf8" ) // ExtractTextWithAnchors 从不同格式材料中提取纯文本,并自动注入物理页码锚点 func ExtractTextWithAnchors(filename string, data []byte) (string, error) { if len(data) == 0 { return "", nil } ext := strings.ToLower(filepath.Ext(filename)) switch ext { case ".txt", ".md", ".json", ".yaml", ".yml": return string(data), nil case ".docx": txt, err := extractTextFromDOCX(data) if err == nil && len(strings.TrimSpace(txt)) > 0 { return injectPageAnchorsForText(txt), nil } // 降级:从二进制中提取可见字符 return injectPageAnchorsForText(extractAsciiUtf8Strings(data)), nil default: // 通用降级 return injectPageAnchorsForText(extractAsciiUtf8Strings(data)), nil } } // injectPageAnchorsForText 将连续文本按约 1200 字符切分,并注入 锚点 func injectPageAnchorsForText(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } // 若原始文本中包含换页符 \f if strings.Contains(raw, "\f") { pages := strings.Split(raw, "\f") var sb strings.Builder for i, page := range pages { pTrim := strings.TrimSpace(page) if pTrim == "" { continue } sb.WriteString(fmt.Sprintf("\n\n%s\n", i+1, pTrim)) } return strings.TrimSpace(sb.String()) } // 否则按段落累加约 1200 字符为一页 paragraphs := strings.Split(raw, "\n") var sb strings.Builder currentPage := 1 currentLen := 0 sb.WriteString(fmt.Sprintf("\n", currentPage)) for _, p := range paragraphs { pTrim := strings.TrimRight(p, "\r\t ") pLen := utf8.RuneCountInString(pTrim) if currentLen > 0 && currentLen+pLen > 1200 { currentPage++ sb.WriteString(fmt.Sprintf("\n\n", currentPage)) currentLen = 0 } sb.WriteString(pTrim) sb.WriteString("\n") currentLen += pLen + 1 } return strings.TrimSpace(sb.String()) } // extractTextFromDOCX 解析 docx 的 word/document.xml 提取段落文本 func extractTextFromDOCX(data []byte) (string, error) { r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { return "", err } var docFile *zip.File for _, f := range r.File { if f.Name == "word/document.xml" { docFile = f break } } if docFile == nil { return "", fmt.Errorf("word/document.xml not found in docx") } rc, err := docFile.Open() if err != nil { return "", err } defer rc.Close() xmlBytes, err := io.ReadAll(rc) if err != nil { return "", err } decoder := xml.NewDecoder(bytes.NewReader(xmlBytes)) var sb strings.Builder inParagraph := false for { tok, err := decoder.Token() if err != nil { break } switch elem := tok.(type) { case xml.StartElement: if elem.Name.Local == "p" { inParagraph = true } else if elem.Name.Local == "br" { for _, attr := range elem.Attr { if attr.Name.Local == "type" && attr.Value == "page" { sb.WriteString("\f") } } } else if elem.Name.Local == "lastRenderedPageBreak" { sb.WriteString("\f") } case xml.EndElement: if elem.Name.Local == "p" { if inParagraph { sb.WriteString("\n") inParagraph = false } } case xml.CharData: sb.Write(elem) } } return sb.String(), nil } // extractAsciiUtf8Strings 降级算法:从二进制流中提取连续的有效可读 UTF-8 文本 func extractAsciiUtf8Strings(data []byte) string { var sb strings.Builder var cur bytes.Buffer flush := func() { if cur.Len() >= 4 { s := cur.String() s = strings.TrimSpace(s) if s != "" { sb.WriteString(s) sb.WriteString("\n") } } cur.Reset() } for i := 0; i < len(data); { r, size := utf8.DecodeRune(data[i:]) if size > 0 && r != utf8.RuneError && (r >= 32 || r == '\n' || r == '\t' || r == '\r') { cur.WriteRune(r) i += size } else { flush() i++ } } flush() return sb.String() } // ExtractAdvisorBriefing 从 output.md 与黑板事实中提取 B 轨业务参谋决策锦囊 func ExtractAdvisorBriefing(taskKey string, markdownBody string, fields map[string]string) *AdvisorBriefing { if fields == nil { fields = make(map[string]string) } projectName := fields["project_name"] customerName := fields["customer_name"] deviceType := fields["device_type"] if projectName == "" { projectName = "本项目" } if customerName == "" { customerName = "客户" } if deviceType == "" { deviceType = "医疗器械" } brief := &AdvisorBriefing{ MaturityScore: 60, MaturityLevel: "V0.1", MaturityHeadline: "已提取基础事实框架,关键技术与商务参数已按行业基准预设", BaselineAssumptions: []string{}, PreemptiveRisks: []string{}, NextBestActions: []string{}, CommunicationKit: "", } // 1. 扫描文档提取待确认项与假定 lines := strings.Split(markdownBody, "\n") inPendingSection := false var pendingItems []string for _, line := range lines { trim := strings.TrimSpace(line) if strings.HasPrefix(trim, "## 待确认清单") { inPendingSection = true continue } if inPendingSection && strings.HasPrefix(trim, "## ") { inPendingSection = false } if inPendingSection { if strings.HasPrefix(trim, "- ") || strings.HasPrefix(trim, "* ") { item := strings.TrimLeft(trim, "-* ") if len(item) > 3 && !strings.HasPrefix(item, "(逐条") { pendingItems = append(pendingItems, item) } } } } // 2. 根据任务类型与提取事实定制成熟度、风险与话术 switch taskKey { case "intake", "user_requirements": brief.MaturityScore = 65 brief.MaturityLevel = "V0.1" brief.MaturityHeadline = fmt.Sprintf("已完成「%s」的初始事实沉淀,当前具备进入方案与法规初筛的基准条件", projectName) brief.BaselineAssumptions = []string{ "硬件与模具研发暂按国内 ISO 13485 标准 CDMO 体系工程规范核算", "临床评价路径暂按免临床评价/同品种对比做基准成本估算", "软件若涉及 AI 或算法质控,暂假定数据不出域、私有化部署", } brief.PreemptiveRisks = []string{ "若客户宣称中包含实质性疗效或治疗作用,可能触发器械分类升档(如二类转三类),研发与注册周期将增加 6-12 个月", "时间与预算尚缺客户书面口径,直接对外报价存在后续争议风险", } brief.NextBestActions = []string{ "使用下方微信话术与客户负责人核实 3 个关键事实口径", "同意当前基准假设,直接触发 V0.5 阶段(方案成型与法规对齐)", "上传新的客户沟通纪要或补充材料进行局部刷新", } topClarifications := "1. 目标注册区域是否锁定中国 NMPA;\n2. 期望的样机测试与立项交付节点;\n3. 是否有指定采购的元器件品牌或特定工艺要求。" if len(pendingItems) > 0 { var picked []string for _, pi := range pendingItems { if len(picked) >= 3 { break } if len(pi) > 100 { pi = pi[:100] + "..." } picked = append(picked, pi) } topClarifications = "" for idx, p := range picked { topClarifications += fmt.Sprintf("%d. %s\n", idx+1, p) } } brief.CommunicationKit = fmt.Sprintf( "【建议发送给 %s 负责人的微信沟通草稿】\n"+ "----------------------------------------\n"+ "您好!关于「%s」的立项需求,我们小队已基于您此前提供的资料做了梳理,搭建了一版立项需求与方案底表。\n\n"+ "为了方便我们后续为您更精准地测算开发周期和成本,想向您请教并确认几个核心细节:\n"+ "%s\n"+ "您可以随时语音或文字简单告知,我们随时跟进更新!\n"+ "----------------------------------------", customerName, projectName, topClarifications, ) case "research", "feasibility_reg", "feasibility_dev", "prelim_design": brief.MaturityScore = 80 brief.MaturityLevel = "V0.5" brief.MaturityHeadline = fmt.Sprintf("「%s」的设计方案框架与法规路径已成型,已具备进行详细 WBS 拆解与报价的条件", projectName) brief.BaselineAssumptions = []string{ "系统架构采用模块化分层设计,软硬件接口保持独立兼容", "电气安全与电磁兼容暂按 GB 9706.1-2020 / YY 0505 标准预留检测整改预算", "关键物料与传感器优先考虑国内成熟供应链以确保交付周期可控", } brief.PreemptiveRisks = []string{ "关键元器件交付周期可能受供应链波动影响,建议首期备料周期预留 4 周冗余", "新国标实施对型式检验提出更严格要求,需警惕实验室排队时间窗口", } brief.NextBestActions = []string{ "审阅初步系统架构图与法规分类论证说明", "进入 V1.0 商业锁定阶段,生成明细 CDO 报价单与 Excel 核算模型", } brief.CommunicationKit = fmt.Sprintf( "【建议发送给 %s 的方案汇报提纲】\n"+ "----------------------------------------\n"+ "您好!关于「%s」,我们已完成了技术路线比选与法规路径分析:\n"+ "1. 系统初步架构及子模块边界已清晰,推荐选用高成熟度国产供应链方案;\n"+ "2. 注册路径与检验标准已完成梳理,预计整体开发与型检周期可控;\n"+ "我们正在为您生成精细化的 CDO 工时与成本核算表,如有最新调整敬请告知。\n"+ "----------------------------------------", customerName, projectName, ) case "cdo_quote": brief.MaturityScore = 95 brief.MaturityLevel = "V1.0" brief.MaturityHeadline = fmt.Sprintf("「%s」已完成全模块 WBS 工时拆解与成本测算,具备合同签约与立项启动条件", projectName) brief.BaselineAssumptions = []string{ "按水木东方标准 CDMO 研发工时单价模型测算,包含设计、工艺、质量与项目管理全环节", "第三方注册检测规费按当前国家认可实验室公允价格预留", } brief.PreemptiveRisks = []string{ "客户中途若增加子功能或改动工业设计外壳,会直接影响模具及结构费用,合同需设变更保护条款", } brief.NextBestActions = []string{ "下载并审阅 CDO报价核算-V01.xlsx 明细表", "根据谈判空间决策是否给予商务折扣优惠", "确认并锁定项目立项任务书与商务合同草稿", } brief.CommunicationKit = fmt.Sprintf( "【商务报价呈送微信/邮件草稿】\n"+ "----------------------------------------\n"+ "尊敬的 %s 领导:\n"+ "您好!关于「%s」的 CDMO 研发与制造服务方案,我们技术与商务团队已完成最终核算:\n"+ "附件为详细报价核算明细表,涵盖从设计输入、详细工程开发、样品试制到注册检验的全流程工时分解。\n"+ "期待与您进一步探讨商务细节,预祝合作顺利!\n"+ "----------------------------------------", customerName, projectName, ) default: brief.MaturityScore = 70 brief.MaturityLevel = "V0.5" brief.MaturityHeadline = fmt.Sprintf("已完成任务「%s」的阶段交付与事实沉淀", taskKey) brief.NextBestActions = []string{"审阅产出正文", "推进到下一阶段"} } return brief }