347 lines
11 KiB
Go
347 lines
11 KiB
Go
package squad
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"strings"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// ExtractTextWithAnchors 从不同格式材料中提取纯文本,并自动注入物理页码锚点 <!-- page N -->
|
|
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 字符切分,并注入 <!-- page N --> 锚点
|
|
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<!-- page %d -->\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("<!-- page %d -->\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<!-- page %d -->\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
|
|
}
|
|
|