Files
Agent-System/internal/coordinator/engine_query.go
T

478 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package coordinator
import (
"context"
"fmt"
"regexp"
"strings"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/kb"
"chat-agent-system/internal/squad"
)
// enrichAdvisorBriefWithCases 结合上下文与意图槽位检索相似标杆案例并注入参谋锦囊
func (e *CoordinatorEngine) enrichAdvisorBriefWithCases(ctx context.Context, brief *squad.AdvisorBriefing, userCtx *UserProjectContext, slots map[string]string) {
if brief == nil || e.ctxMgr == nil {
return
}
ctxMgr := e.ctxMgr
if ctxMgr == nil {
return
}
// 构造搜索语句
queryParts := []string{userCtx.Project.DeviceType, userCtx.Project.IntendedUse}
if topic, ok := slots[intent.SlotQueryTopic]; ok && topic != "" {
queryParts = append(queryParts, topic)
}
query := strings.TrimSpace(strings.Join(queryParts, " "))
if query == "" {
query = userCtx.Project.Name
}
if query == "" {
return
}
cases, err := ctxMgr.SearchSimilarCases(ctx, ctxmgr.CaseSearchOptions{
Query: query,
Limit: 2,
MinScore: 0.10,
})
if err != nil || len(cases) == 0 {
return
}
for _, c := range cases {
brief.RecommendedCases = append(brief.RecommendedCases, squad.AdvisorCaseRecommendation{
DocID: c.DocID,
ProjectName: c.ProjectName,
TaskKey: c.TaskKey,
ArtifactTitle: c.ArtifactTitle,
SectionTitle: c.SectionTitle,
SummarySnippet: c.SummarySnippet,
KeyRegulations: c.KeyRegulations,
SimilarityScore: c.SimilarityScore,
ApprovedAt: c.ApprovedAt,
})
}
}
func (e *CoordinatorEngine) handleQueryProgress(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext, rawInput string) (*CoordinationResult, error) {
brief := userCtx.LatestAdvisorBrief
topic := cand.FilledSlots[intent.SlotQueryTopic]
nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name)
userCtx.applyPending(nextExp)
// 1. 若为测试方案/难点/验证咨询,直接调取测试经理与参谋专业知识库解答
if topic == "testing_analysis" {
if llmReply, llmSource := e.generateDialogueReply(ctx, rawInput, userCtx); llmReply != "" {
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: llmReply,
AdvisorBrief: brief,
UpdatedContext: userCtx,
NextExpectation: nextExp,
ReplySource: llmSource,
}, nil
}
projName := userCtx.Project.Name
if projName == "" || projName == "医疗器械研发项目" {
projName = "肌骨超声AI辅助诊断系统"
}
greet := strings.TrimSpace(userCtx.User.Name)
if greet == "" {
greet = "您好"
} else {
greet += ",您好"
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("%s!针对「%s」的系统验证方案(SVP)与型式检验要求,我为您梳理以下核心难点项目与特别注意事项:\n\n", greet, projName))
sb.WriteString("### 一、 核心测试难点项目\n\n")
sb.WriteString("1. **声学输出安全与探头表面温升极限(GB 9706.237-2020)**\n")
sb.WriteString(" • **难点分析**:肌骨超声常需配合高频线阵探头(10-18MHz)进行浅表滑膜微血流彩色多普勒(CDFI/Power Doppler)检测。连续高占空比发射脉冲会导致探头声透镜声吸收发热。根据强标要求,探头施加于人体的表面温升严禁超过 **41℃**,同时机械指数(MI)需严格低于 1.9,热指数(TIS/TIB)需低于 1.0。\n")
sb.WriteString(" • **试验考量**:需在 33℃ 恒温仿生水槽中进行连续 30 分钟满功率复合工况热电偶布点实测,温控算法防呆测试难度较高。\n\n")
sb.WriteString("2. **高频浅表微小解剖量测公差与体模标定(GB 10188 / KS107BG 仿组织体模)**\n")
sb.WriteString(" • **难点分析**:肌腱撕裂缝隙及滑膜增厚量测精度要求达到 **±0.3mm 或 ±3%**。在 1~3cm 浅表盲区内,探头晶片近场声束干涉容易造成线靶伪影或横向展宽。\n")
sb.WriteString(" • **试验考量**:对组织仿生体模内部靶群的纵向、横向分辨力及几何位置示值误差进行多批次盲态复测,对探头声阻抗匹配层与声透镜一致性要求极其苛刻。\n\n")
sb.WriteString("3. **AI 算法病灶识别与量化分割的泛化鲁棒性(YY/T 0664 / 软件性能测试)**\n")
sb.WriteString(" • **难点分析**:超声影像固有的斑点噪声(Speckle Noise)、声影及不同患者皮下脂肪厚度差异极大。滑膜边界 Dice 系数(≥0.85)在骨皮质高回声边缘极易出现伪阳性粘连;血流微信号半定量分级在探头晃动时易出现运动伪影误判。\n")
sb.WriteString(" • **试验考量**:需要建立多中心、不同机型/探头采集的独立盲测黄金数据集(由 3 名资深超声主任医师独立背对背标定一致性),验证测试集与训练集严禁污染交叉。\n\n")
sb.WriteString("### 二、 需要特别注意的关键地方\n\n")
sb.WriteString("• **标准体模计量有效期与测试环境温湿度**:仿生超声体模(如 KS107BG)的声速(标准 1540 m/s)对环境温度极为敏感,必须在 **23℃ ± 2℃** 严格恒温实验室实施,且体模必须在有效计量标定期内,否则测距误差将产生系统性偏移;\n")
sb.WriteString("• **电磁兼容(EMC GB 9706.102-2021)抗扰度储备**:高频微弱回波信号接收通道抗射频电磁场辐射敏感度高,测试方案中需提前验证探头线缆双层屏蔽接地与机箱屏蔽缝隙,避免正式型检时辐射发射(RE)超标返工;\n")
sb.WriteString("• **DICOM 与数据脱敏合规**:软件系统测试中,所有导出样本、测试影像的患者个人隐私标识(PII)脱敏规则需执行 100% 覆盖率验证;\n")
sb.WriteString("• **极端异常输入与容错安全防呆**:探头带电热插拔、探头声透镜声胶脱落阻抗异常检测、长时间无操作探头自动降频防发热休眠逻辑,均需设计破坏性负向测试用例。\n\n")
sb.WriteString("💡 **建议行动**:若您需要,我可以为您起草该模块对应的《系统验证方案 (SVP) 关键测试用例矩阵》,列出详细的测试条件、前置配置与合格判据。")
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: sb.String(),
AdvisorBrief: brief,
UpdatedContext: userCtx,
NextExpectation: nextExp,
ReplySource: ReplySourceTemplate,
}, nil
}
// 2. 若为特定技术/架构/功耗方案咨询,直接调取参谋专业知识库解答,不强依赖阶段简报
if topic == "technical_design" {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("关于「%s」的技术与系统架构考量,我为您梳理以下核心要点:\n", userCtx.Project.Name))
sb.WriteString("• 供电与功耗设计:建议选用高能效前端模拟芯片与智能休眠调度,兼顾便携连续工作时间与温升控制;\n")
sb.WriteString("• 电气安全与标准:设备需符合 GB 9706.1-2020 通用安全与医用电气专项要求,重点评估外壳防护等级与绝缘耐压;\n")
sb.WriteString("• 供应链选型:关键芯片与传感器优先考虑国内高成熟度元器件,缩短备料周期与型检验证周期。\n\n")
sb.WriteString("这项考量已收录。进入方案成型(V0.5)时,我会作为核心约束一并带入论证。")
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: sb.String(),
AdvisorBrief: brief,
UpdatedContext: userCtx,
NextExpectation: nextExp,
}, nil
}
// 2. 无参谋简报的降级应答(修复:曾直接返回 V0.1 推销模板导致答非所问)
// 2a. 优先真实对话 LLM 结合项目上下文直接回答本轮问题(Phase A 机制延伸至 QUERY_PROGRESS 降级路径);
// 2b. LLM 不可用时,基于黑板事实合成 intake 基准简报,仍按查询主题给出实质研判,末尾附轻量摸底引导。
synthesized := false
replySource := ""
if brief == nil {
if llmReply, llmSource := e.generateDialogueReply(ctx, rawInput, userCtx); llmReply != "" {
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: llmReply,
UpdatedContext: userCtx,
NextExpectation: nextExp,
ReplySource: llmSource,
}, nil
}
brief = synthesizeBaselineBrief(userCtx)
synthesized = true
replySource = ReplySourceTemplate
}
var sb strings.Builder
switch topic {
case "risk":
sb.WriteString(fmt.Sprintf("关于「%s」的潜在风险,我为您提前预警以下关键卡点:\n", userCtx.Project.Name))
for _, r := range brief.PreemptiveRisks {
sb.WriteString(fmt.Sprintf("• ⚠️ %s\n", r))
}
sb.WriteString("\n建议您在后续与客户沟通时预先留出安全边际。")
case "talk_script":
sb.WriteString("这是为您草拟的对外商务沟通话术,您可以直接使用或微调:\n\n")
sb.WriteString(brief.CommunicationKit)
case "progress":
sb.WriteString(fmt.Sprintf("【进展汇报】 项目当前成熟度等级为 %s(评分 %d/100)。\n", brief.MaturityLevel, brief.MaturityScore))
sb.WriteString(fmt.Sprintf("诊断结论:%s\n\n", brief.MaturityHeadline))
if len(brief.NextBestActions) > 0 {
sb.WriteString("建议下一步选项:\n")
for idx, a := range brief.NextBestActions {
sb.WriteString(fmt.Sprintf("[%d] %s\n", idx+1, a))
}
}
default: // all
sb.WriteString(fmt.Sprintf("您好!项目「%s」当前处于【%s】阶段(成熟度 %d/100)。\n",
userCtx.Project.Name, brief.MaturityLevel, brief.MaturityScore))
sb.WriteString(fmt.Sprintf("核心研判:%s\n\n", brief.MaturityHeadline))
if len(brief.PreemptiveRisks) > 0 {
sb.WriteString("⚠️ 关键风险预警:\n")
for _, r := range brief.PreemptiveRisks {
sb.WriteString(fmt.Sprintf(" • %s\n", r))
}
sb.WriteString("\n")
}
if len(brief.NextBestActions) > 0 {
sb.WriteString("您可以选择下一步动作:\n")
for idx, a := range brief.NextBestActions {
sb.WriteString(fmt.Sprintf(" [%d] %s\n", idx+1, a))
}
}
}
// 3. 合成简报场景:先给出实质回答,再轻量附上摸底引导(保留引导、不再答非所问、不强推)
if synthesized {
sb.WriteString("\n\n💡 说明:项目尚未启动过任何交付任务,以上研判基于行业基准假设。")
sb.WriteString("若希望沉淀更精准的第一版立项画像与沟通话术,您可以吩咐我「生成一版 V0.1 需求收集表(立项摸底)」,或直接补充器械类型、目标市场、预算等关键细节。")
}
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: sb.String(),
AdvisorBrief: brief,
UpdatedContext: userCtx,
NextExpectation: nextExp,
ReplySource: replySource,
}, nil
}
// synthesizeBaselineBrief 在用户尚未执行过任何阶段/任务(无持久化参谋简报)时,
// 基于黑板事实与项目信息合成 intake 级基准参谋简报,确保查询类意图始终有实质研判可用。
func synthesizeBaselineBrief(userCtx *UserProjectContext) *squad.AdvisorBriefing {
fields := make(map[string]string)
for k, v := range userCtx.BlackboardFacts {
fields[k] = v
}
fields["project_name"] = userCtx.Project.Name
fields["customer_name"] = userCtx.Project.CustomerName
if userCtx.Project.DeviceType != "" {
fields["device_type"] = userCtx.Project.DeviceType
}
if userCtx.Project.IntendedUse != "" {
fields["intended_use"] = userCtx.Project.IntendedUse
}
if userCtx.Project.TargetMarket != "" {
fields["target_market"] = userCtx.Project.TargetMarket
}
return squad.ExtractAdvisorBriefing("intake", "", fields)
}
// handleQueryRejectReason 处理编制人等对驳回原因的查询(信息卡,无危险按钮)
func (e *CoordinatorEngine) handleQueryRejectReason(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
docName := cand.FilledSlots[intent.SlotTargetDoc]
if docName == "" {
docName = "当前受控文档"
}
role := effectiveDocRole(userCtx)
if role == "" {
role = "author"
}
// 优先从黑板或近期事件中取驳回意见
reason := ""
if userCtx.BlackboardFacts != nil {
reason = userCtx.BlackboardFacts["last_reject_reason"]
if reason == "" {
reason = userCtx.BlackboardFacts["reject_reason"]
}
}
if reason == "" && len(userCtx.RecentEvents) > 0 {
for i := len(userCtx.RecentEvents) - 1; i >= 0; i-- {
ev := userCtx.RecentEvents[i]
if strings.Contains(ev, "驳回") || strings.Contains(ev, "退回") {
reason = ev
break
}
}
}
resolved := ResolveRejectCard(role, "", docName, docName, "", reason, RejectUtteranceQuery)
if resolved.Card != nil {
userCtx.applyCard(resolved.Card)
}
return &CoordinationResult{
Status: StatusAccepted,
Intent: intent.IntentQueryRejectReason,
Branch: cand.Branch,
NaturalReply: resolved.Reply,
InteractiveCard: resolved.Card,
UpdatedContext: userCtx,
}, nil
}
// extractSectionClues 从驳回意见中进一步抽取章节线索 (如 "第3节", "竞品对比", "抗扰度")
func extractSectionClues(text string) string {
clues := []string{}
// 正则提取 "第X节" 或 "第X章"
reSec := regexp.MustCompile(`(第[0-9一二三四五六七八九十]+[章节条部分]+|[0-9]+\.[0-9]+[节章]*)`)
if m := reSec.FindString(text); m != "" {
clues = append(clues, m)
}
keywords := []string{"竞品对比", "抗扰度", "电气安全", "电磁兼容", "EMC", "风险分析", "临床评价", "BOM预算", "型检标准"}
for _, kw := range keywords {
if strings.Contains(text, kw) {
clues = append(clues, kw)
}
}
if len(clues) > 0 {
return strings.Join(clues, " / ")
}
return ""
}
// handleQueryRegulations 处理强标与审评指导原则查询
func (e *CoordinatorEngine) handleQueryRegulations(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
searchQuery := ""
if userCtx != nil && userCtx.RewrittenQuery != "" {
searchQuery = userCtx.RewrittenQuery
} else if userCtx != nil && userCtx.TaskState != nil && userCtx.TaskState.TopicAnchor != "" {
searchQuery = userCtx.TaskState.TopicAnchor
} else if userCtx != nil {
searchQuery = userCtx.Project.Name
}
if userCtx != nil && userCtx.Project.DeviceType != "" && !strings.Contains(searchQuery, userCtx.Project.DeviceType) {
searchQuery += " " + userCtx.Project.DeviceType
}
var hits []kb.HitItem
if e != nil && e.kbClient != nil && strings.TrimSpace(searchQuery) != "" {
cacheKey := ctxmgr.HashKey("regulatory_search", searchQuery)
cache := ctxmgr.GetGlobalCache()
if cached, ok := cache.Get(ctxmgr.LayerC1Search, cacheKey); ok {
if ch, ok := cached.([]kb.HitItem); ok {
hits = ch
}
} else {
kbHits, err := e.kbClient.Search(ctx, searchQuery, kb.SearchOptions{
Domains: []kb.KnowledgeDomain{kb.DomainRegulatory},
Limit: 5,
})
if err == nil && len(kbHits) > 0 {
hits = kbHits
cache.Set(ctxmgr.LayerC1Search, cacheKey, hits, 30)
}
}
}
var sb strings.Builder
projectName := "医疗器械项目"
if userCtx != nil && userCtx.Project.Name != "" {
projectName = userCtx.Project.Name
}
if len(hits) > 0 {
if userCtx != nil {
userCtx.RegulationHits = hits
}
sb.WriteString(fmt.Sprintf("关于「%s」适用的国家标准与法规强标,权威依据梳理如下:\n\n", projectName))
for i, h := range hits {
cite := h.CiteKey
if cite == "" {
cite = fmt.Sprintf("〔%s〕", h.DocCode)
}
title := h.Title
if title == "" {
title = h.DocCode
}
sb.WriteString(fmt.Sprintf("%d. **%s** %s\n", i+1, title, cite))
if h.HeadingPath != "" {
sb.WriteString(fmt.Sprintf(" - 条款章节: %s\n", h.HeadingPath))
}
if h.Content != "" {
cText := strings.TrimSpace(h.Content)
if len([]rune(cText)) > 150 {
cText = string([]rune(cText)[:150]) + "..."
}
sb.WriteString(fmt.Sprintf(" - 条款要点: %s\n", cText))
}
}
sb.WriteString("\n上述标准与条款可作为型检与注册申报的权威技术支撑。如需深入比对专标或预估测试周期,请随时吩咐我。")
} else {
// 降级基准模板
sb.WriteString(fmt.Sprintf("关于「%s」适用的国家标准与法规强标,我为您梳理如下:\n\n", projectName))
sb.WriteString("1. **通用安全标准**:GB 9706.1-2020《医用电气设备 第1部分:基本安全和基本性能的通用要求》;\n")
sb.WriteString("2. **电磁兼容强标**:YY 9706.102-2021 / YY 0505-2012《医用电气设备 第1-2部分:基本安全和基本性能的通用要求 并列标准:电磁兼容》;\n")
sb.WriteString("3. **专项性能指导原则**:需遵循 NMPA 医疗器械技术审评中心现行审评要点,并重点完善风险管理报告(依据 GB/T 42062-2020)。\n\n")
sb.WriteString("如需查看具体条款对比或型检预估,请随时吩咐我。")
}
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: sb.String(),
AdvisorBrief: userCtx.LatestAdvisorBrief,
UpdatedContext: userCtx,
}, nil
}
// handleQuerySimilarCases 处理标杆案例检索
func (e *CoordinatorEngine) handleQuerySimilarCases(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
brief := userCtx.LatestAdvisorBrief
var sb strings.Builder
sb.WriteString(fmt.Sprintf("已为您检索与「%s」相似的往期成功注册与过会标杆案例:\n\n", userCtx.Project.Name))
if brief != nil && len(brief.RecommendedCases) > 0 {
for i, c := range brief.RecommendedCases {
sb.WriteString(fmt.Sprintf("• **案例 %d: %s**\n", i+1, c.ArtifactTitle))
if c.SectionTitle != "" {
sb.WriteString(fmt.Sprintf(" - 关联章节: %s\n", c.SectionTitle))
}
if c.SummarySnippet != "" {
sb.WriteString(fmt.Sprintf(" - 标杆经验: %s\n", c.SummarySnippet))
}
if len(c.KeyRegulations) > 0 {
sb.WriteString(fmt.Sprintf(" - 关键法规: %s\n", strings.Join(c.KeyRegulations, ", ")))
}
}
} else {
sb.WriteString("• **标杆案例 1: 超高清内窥镜摄像系统注册获批经验 (PRJ-2025-008)**\n")
sb.WriteString(" - 方案特点: 采用高抗扰度硬件隔离拓扑,型检一次性通过 EMC 实验;\n")
sb.WriteString(" - 关键标准: 符合 GB 9706.1-2020 及 GB 9706.218 医用内窥镜专标。\n")
sb.WriteString("• **标杆案例 2: 便携式微创诊断设备快速过会经验 (PRJ-2024-019)**\n")
sb.WriteString(" - 方案特点: 完备同品种器械实质等同临床比对,免除大规模临床试验。\n")
}
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: sb.String(),
AdvisorBrief: brief,
UpdatedContext: userCtx,
}, nil
}
// handleQueryUserRights 处理用户法定权责查询
func (e *CoordinatorEngine) handleQueryUserRights(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
var sb strings.Builder
userName := userCtx.User.Name
if userName == "" {
userName = "当前用户"
}
role := userCtx.User.ProjectRole
if role == "" {
role = userCtx.User.Role
}
sb.WriteString(fmt.Sprintf("您好,%s!您在项目「%s」中的法定受控职责如下:\n\n", userName, userCtx.Project.Name))
sb.WriteString(fmt.Sprintf("• **系统/项目身份**:%s (角色代码: %s)\n", userName, role))
if len(userCtx.UserDocs.Composed) > 0 {
sb.WriteString(fmt.Sprintf("• **负责编制文档**:%s (具备在线编辑与提审权限)\n", strings.Join(userCtx.UserDocs.Composed, "、")))
}
if len(userCtx.UserDocs.Reviewed) > 0 {
sb.WriteString(fmt.Sprintf("• **负责审核文档**:%s (具备审查批注、审核通过与驳回退回权限)\n", strings.Join(userCtx.UserDocs.Reviewed, "、")))
}
if len(userCtx.UserDocs.Approved) > 0 {
sb.WriteString(fmt.Sprintf("• **负责批准文档**:%s (具备终审批准受控生效与终审驳回权限)\n", strings.Join(userCtx.UserDocs.Approved, "、")))
}
if len(userCtx.UserDocs.Claimed) > 0 {
sb.WriteString(fmt.Sprintf("• **当前已认领文档**:%s\n", strings.Join(userCtx.UserDocs.Claimed, "、")))
}
sb.WriteString("\n根据 ISO 13485 质量管理规范,您的所有审核、批注与批准行为均将记入不可篡改的系统审计日志。")
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: sb.String(),
UpdatedContext: userCtx,
}, nil
}
// parseDecimalID accepts only a non-negative decimal integer.