Files

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
}