The chat fallback only treated 生成/做/跑/出 as task verbs, and the shorter RDev alias 用户需求 stole 初始用户需求表. Co-authored-by: Cursor <cursoragent@cursor.com>
1292 lines
47 KiB
Go
1292 lines
47 KiB
Go
package intent
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"chat-agent-system/internal/maturity"
|
||
"chat-agent-system/internal/squad"
|
||
)
|
||
|
||
// Recognizer 意图识别器接口
|
||
type Recognizer interface {
|
||
Recognize(ctx context.Context, input string, userCtx *ContextSnapshot) (*IntentRecognitionResult, error)
|
||
}
|
||
|
||
// RuleBasedRecognizer 规则与上下文约束结合的四级漏斗意图消解引擎
|
||
type RuleBasedRecognizer struct{}
|
||
|
||
func NewRecognizer() Recognizer {
|
||
return &RuleBasedRecognizer{}
|
||
}
|
||
|
||
func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userCtx *ContextSnapshot) (*IntentRecognitionResult, error) {
|
||
result, err := r.recognizeCore(ctx, input, userCtx)
|
||
if err != nil || result == nil {
|
||
return result, err
|
||
}
|
||
attachPlanProposal(ctx, r, input, userCtx, result)
|
||
return result, nil
|
||
}
|
||
|
||
func (r *RuleBasedRecognizer) recognizeCore(ctx context.Context, input string, userCtx *ContextSnapshot) (*IntentRecognitionResult, error) {
|
||
trimmed := strings.TrimSpace(input)
|
||
if trimmed == "" {
|
||
result := &IntentRecognitionResult{
|
||
RawInput: input,
|
||
Branch: BranchAmbiguousFallback,
|
||
TopIntent: &CandidateIntent{
|
||
Intent: IntentCasualChat,
|
||
Branch: BranchAmbiguousFallback,
|
||
Confidence: 1.0,
|
||
Reasoning: "输入为空",
|
||
},
|
||
Candidates: []CandidateIntent{
|
||
{Intent: IntentCasualChat, Branch: BranchAmbiguousFallback, Confidence: 1.0, Reasoning: "空输入"},
|
||
},
|
||
}
|
||
finalizeRecognitionResult(result)
|
||
return result, nil
|
||
}
|
||
|
||
snapID := ""
|
||
currentStage := maturity.StageV01_Triage
|
||
var pending *PendingExpectation
|
||
|
||
if userCtx != nil {
|
||
snapID = userCtx.SnapshotID
|
||
if userCtx.CurrentStage != "" {
|
||
currentStage = maturity.MaturityStage(userCtx.CurrentStage)
|
||
}
|
||
pending = userCtx.PendingExpectation
|
||
}
|
||
|
||
// -------------------------------------------------------------
|
||
// 阶段零 (Stage 0):态势前置过滤与安全防护检查 (Security & UI Guard)
|
||
// -------------------------------------------------------------
|
||
// 1. 安全红线校验:严禁通过自然语言对话修改系统受控配置、用户法定身份/职位/工号
|
||
// 「不要修改配置,只查询…」类否定写 + 只读意图不得误触安全拦截
|
||
if isSecurityViolation(trimmed) && !isNegatedWriteWithReadonlyIntent(trimmed) {
|
||
top := &CandidateIntent{
|
||
Intent: IntentSecurityForbidden,
|
||
Branch: BranchNewIntentPreempt,
|
||
Confidence: 1.0,
|
||
FilledSlots: map[string]string{SlotForbiddenTarget: "identity_or_settings"},
|
||
Reasoning: "检测到试图修改系统底层受控配置或用户法定身份/职位/工号,根据医疗器械质量与合规管理规范(ISO 13485 / 21 CFR Part 11),触发安全红线拦截",
|
||
}
|
||
result := &IntentRecognitionResult{
|
||
RawInput: trimmed,
|
||
Branch: BranchNewIntentPreempt,
|
||
TopIntent: top,
|
||
Candidates: []CandidateIntent{*top},
|
||
ContextSnapshotID: snapID,
|
||
RequiresClarifying: false,
|
||
}
|
||
finalizeRecognitionResult(result)
|
||
return result, nil
|
||
}
|
||
|
||
// 1.5 否定写 + 只读查询:不得误触安全拦截,也不得当作写操作
|
||
if isNegatedWriteWithReadonlyIntent(trimmed) {
|
||
top := &CandidateIntent{
|
||
Intent: IntentQueryUserRights,
|
||
Branch: BranchNewIntentPreempt,
|
||
Confidence: 0.88,
|
||
FilledSlots: map[string]string{SlotQueryTopic: "system_configuration"},
|
||
Reasoning: "用户明确否定修改配置并请求只读查询,不得路由为 SECURITY_FORBIDDEN 或写操作",
|
||
ClarificationQuestion: "我理解您希望只读查看当前系统配置,不会对配置做任何修改。请问您具体想查看哪一项配置?",
|
||
}
|
||
result := &IntentRecognitionResult{
|
||
RawInput: trimmed,
|
||
Branch: BranchNewIntentPreempt,
|
||
TopIntent: top,
|
||
Candidates: []CandidateIntent{*top},
|
||
ContextSnapshotID: snapID,
|
||
RequiresClarifying: true,
|
||
}
|
||
finalizeRecognitionResult(result)
|
||
return result, nil
|
||
}
|
||
|
||
// 2. 纯 UI 视窗与主题控制指令拦截:归入 CASUAL_CHAT 并给出不可执行提示
|
||
if isPureUICommand(trimmed) {
|
||
top := &CandidateIntent{
|
||
Intent: IntentCasualChat,
|
||
Branch: BranchNewIntentPreempt,
|
||
Confidence: 0.95,
|
||
FilledSlots: make(map[string]string),
|
||
Reasoning: "纯UI视窗或主题控制指令,系统暂不支持通过对话直接操作界面视窗布局或主题,请通过界面按钮或快捷键操作",
|
||
ClarificationQuestion: "检测到您下达了界面视窗或主题控制指令(如折叠/打开侧边栏、切换主题/深色浅色)。该操作属于前端UI视窗布局与主题控制,请直接点击界面右上角或侧边的视窗/主题切换按钮。",
|
||
}
|
||
result := &IntentRecognitionResult{
|
||
RawInput: trimmed,
|
||
Branch: BranchNewIntentPreempt,
|
||
TopIntent: top,
|
||
Candidates: []CandidateIntent{*top},
|
||
ContextSnapshotID: snapID,
|
||
RequiresClarifying: false,
|
||
}
|
||
finalizeRecognitionResult(result)
|
||
return result, nil
|
||
}
|
||
|
||
// -------------------------------------------------------------
|
||
// 阶段 0.5:若输入明确是咨询参谋/犹豫(如“这个先放放,你觉得呢”),优先归入分支 3 (参谋主动分忧)
|
||
// -------------------------------------------------------------
|
||
if isConsultativeOrHesitant(trimmed) {
|
||
res := buildAmbiguousConsultResult(trimmed, snapID, pending)
|
||
return res, nil
|
||
}
|
||
|
||
// -------------------------------------------------------------
|
||
// 阶段 0.8:分支 1 判定——命中历史轮次挂起预期 (肯定 / 选项序号 / 复合带补丁 / 明确撤销)
|
||
// -------------------------------------------------------------
|
||
if pending != nil {
|
||
if hitCand := matchExpectationHit(trimmed, pending); hitCand != nil {
|
||
result := &IntentRecognitionResult{
|
||
RawInput: trimmed,
|
||
Branch: BranchExpectationHit,
|
||
TopIntent: hitCand,
|
||
Candidates: []CandidateIntent{*hitCand},
|
||
ContextSnapshotID: snapID,
|
||
}
|
||
finalizeRecognitionResult(result)
|
||
return result, nil
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------
|
||
// 阶段一与阶段二 (Stage 1 & Stage 2):领域大类路由、微观细分动作与槽位提取
|
||
// -------------------------------------------------------------
|
||
var candidates []CandidateIntent
|
||
|
||
// 领域 1: 法定受控文档治理流转 (GOV_LIFECYCLE)
|
||
// (涵盖 驳回、通过、送审、认领、终审批准、提案裁决)
|
||
if cand := matchDocGovAction(trimmed, userCtx); cand != nil {
|
||
cand.Branch = BranchNewIntentPreempt
|
||
candidates = append(candidates, *cand)
|
||
}
|
||
|
||
// 领域 2: 决策辅助、合规与知识检索 (DECISION_QUERY - Knowledge & Rights)
|
||
// (涵盖 法规强标、标杆类似案例、用户法定职责与权限)
|
||
if cand := matchKnowledgeQuery(trimmed); cand != nil {
|
||
cand.Branch = BranchNewIntentPreempt
|
||
candidates = append(candidates, *cand)
|
||
}
|
||
|
||
// 领域 3: 交付物与阶段演进 (CONTENT_SYNTHESIS)
|
||
// 3.1 阶段推进 (ADVANCE_STAGE)
|
||
if cand := matchAdvanceStage(trimmed, currentStage); cand != nil {
|
||
cand.Branch = BranchNewIntentPreempt
|
||
candidates = append(candidates, *cand)
|
||
}
|
||
if cand := matchImpactIntent(trimmed); cand != nil {
|
||
cand.Branch = BranchNewIntentPreempt
|
||
candidates = append(candidates, *cand)
|
||
}
|
||
|
||
// 3.2 补充信息/参数事实 (SUPPLEMENT_INFO)
|
||
if cand := matchSupplementInfo(trimmed); cand != nil {
|
||
cand.Branch = BranchNewIntentPreempt
|
||
candidates = append(candidates, *cand)
|
||
}
|
||
|
||
// 3.3 交付任务触发 (TRIGGER_TASK)
|
||
if cand := matchTriggerTask(trimmed); cand != nil {
|
||
cand.Branch = BranchNewIntentPreempt
|
||
candidates = append(candidates, *cand)
|
||
}
|
||
|
||
// 领域 4: 长任务运行控制 (Phase 4)
|
||
if cand := matchRunControl(trimmed, userCtx); cand != nil {
|
||
cand.Branch = BranchNewIntentPreempt
|
||
candidates = append(candidates, *cand)
|
||
}
|
||
|
||
// 领域 5: 工作流控制与状态查询 (Phase 3 — 优先于进度查询,避免「测试经理」等词误判)
|
||
if CurrentFeatureFlags().WorkflowIntentsEnabled {
|
||
if cand := matchQueryWorkflowStatus(trimmed, userCtx); cand != nil {
|
||
cand.Branch = BranchNewIntentPreempt
|
||
candidates = append(candidates, *cand)
|
||
}
|
||
if cand := matchSwitchWorkflow(trimmed, userCtx); cand != nil {
|
||
cand.Branch = BranchNewIntentPreempt
|
||
candidates = append(candidates, *cand)
|
||
}
|
||
if cand := matchTriggerWorkflow(trimmed, userCtx); cand != nil {
|
||
cand.Branch = BranchNewIntentPreempt
|
||
candidates = append(candidates, *cand)
|
||
}
|
||
}
|
||
|
||
// 领域 5: 文档/交付物状态只读查询
|
||
if cand := matchQueryDocStatus(trimmed, userCtx); cand != nil {
|
||
cand.Branch = BranchNewIntentPreempt
|
||
candidates = append(candidates, *cand)
|
||
}
|
||
|
||
// 领域 6: 帮助与暂不支持
|
||
if cand := matchHelpCapabilities(trimmed); cand != nil {
|
||
cand.Branch = BranchNewIntentPreempt
|
||
candidates = append(candidates, *cand)
|
||
}
|
||
if cand := matchUnsupportedActionCandidate(trimmed); cand != nil {
|
||
cand.Branch = BranchNewIntentPreempt
|
||
candidates = append(candidates, *cand)
|
||
}
|
||
|
||
// 领域 7: 进度与技术研判查询 (DECISION_QUERY - Progress & Technical)
|
||
if cand := matchQueryProgress(trimmed); cand != nil {
|
||
cand.Branch = BranchNewIntentPreempt
|
||
candidates = append(candidates, *cand)
|
||
}
|
||
|
||
// 通用对话/闲聊保底 (CASUAL_CHAT)
|
||
chatCand := CandidateIntent{
|
||
Intent: IntentCasualChat,
|
||
Branch: BranchNewIntentPreempt,
|
||
Confidence: 0.2,
|
||
FilledSlots: make(map[string]string),
|
||
Reasoning: "未匹配到明确业务指令,归入通用对话",
|
||
}
|
||
if isGreetingOrHelp(trimmed) {
|
||
chatCand.Confidence = 0.85
|
||
chatCand.Reasoning = "匹配到问候语或通用帮助请求"
|
||
}
|
||
candidates = append(candidates, chatCand)
|
||
|
||
sortCandidates(candidates)
|
||
top := &candidates[0]
|
||
applyCapabilityHint(top, userCtx)
|
||
|
||
// -------------------------------------------------------------
|
||
// 阶段三 (Stage 3):槽位完备性与置信度裁决 (门禁裁决与追问下发)
|
||
// -------------------------------------------------------------
|
||
branch := BranchNewIntentPreempt
|
||
requiresClarifying := false
|
||
slotOrPolicyClarify := false
|
||
|
||
// 1. 若为 DOCGOV_REVIEW_REJECT 或 DOCGOV_FINAL_REJECT,检查必填槽位 reject_reason
|
||
if top.Intent == IntentDocGovReviewReject || top.Intent == IntentDocGovFinalReject {
|
||
if top.FilledSlots[SlotRejectReason] == "" {
|
||
requiresClarifying = true
|
||
slotOrPolicyClarify = true
|
||
top.ClarificationQuestion = "请说明具体的法定驳回理由,我将为您注入文档批注并退回编制阶段。"
|
||
top.MissingSlots = []string{SlotRejectReason}
|
||
}
|
||
}
|
||
|
||
// 2. 权限/生命周期前置剪枝:保留真实写意图,仅标记需澄清(L2 最终裁决)
|
||
if strings.Contains(top.Reasoning, "权限不符") || strings.Contains(top.Reasoning, "前置剪枝") {
|
||
requiresClarifying = true
|
||
slotOrPolicyClarify = true
|
||
}
|
||
|
||
// 3. 已有定向澄清问题(如查询语气的只读确认)
|
||
if top.ClarificationQuestion != "" && !slotOrPolicyClarify {
|
||
requiresClarifying = true
|
||
slotOrPolicyClarify = true
|
||
}
|
||
|
||
// 4. 写/读候选冲突:分差不足时禁止直接执行 Top1
|
||
if applyCandidateConflictGate(candidates, top) {
|
||
requiresClarifying = true
|
||
slotOrPolicyClarify = true
|
||
}
|
||
|
||
// 5. 低置信度且无定向澄清时才归入模糊咨询分支
|
||
if top.Confidence < 0.6 && !slotOrPolicyClarify {
|
||
branch = BranchAmbiguousFallback
|
||
top.Branch = BranchAmbiguousFallback
|
||
requiresClarifying = true
|
||
}
|
||
|
||
result := &IntentRecognitionResult{
|
||
RawInput: trimmed,
|
||
Branch: branch,
|
||
TopIntent: top,
|
||
Candidates: candidates,
|
||
ContextSnapshotID: snapID,
|
||
RequiresClarifying: requiresClarifying,
|
||
}
|
||
finalizeRecognitionResult(result)
|
||
if pending != nil {
|
||
if plan := buildExpectationPlusNewPlan(ctx, r, trimmed, userCtx, pending, result); plan != nil {
|
||
result.IntentPlan = plan
|
||
}
|
||
}
|
||
if result.IntentPlan == nil {
|
||
if plan := buildLimitedIntentPlan(ctx, r, trimmed, userCtx, result); plan != nil {
|
||
result.IntentPlan = plan
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// isSecurityViolation 检查是否命中敏感安全与权限红线
|
||
func isSecurityViolation(input string) bool {
|
||
lower := strings.ToLower(input)
|
||
patterns := []string{
|
||
"修改身份", "身份修改", "修改职位", "职位修改", "修改岗位", "岗位修改",
|
||
"修改姓名", "姓名修改", "修改名字", "名字修改",
|
||
"修改部门", "部门修改",
|
||
"修改资质", "资质修改",
|
||
"改成批准人", "改成审核人", "改成编制人",
|
||
"工号改成", "修改工号", "工号修改",
|
||
"修改设置", "修改配置", "系统设置", "系统配置",
|
||
"修改网关", "网关配置", "网关密钥", "大模型网关", "模型网关",
|
||
"修改密码", "重置密码", "提升权限", "越权",
|
||
"修改向量库", "向量库连接", "向量库配置", "修改向量库连接",
|
||
"修改知识库连接", "知识库连接", "知识库配置",
|
||
}
|
||
for _, p := range patterns {
|
||
if strings.Contains(lower, p) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
secRegexes := []string{
|
||
`把.*(?:身份|职位|角色|工号|权限|姓名|名字|部门|资质).*(?:修改|改|换|重置|设为|变为)`,
|
||
`(?:修改|改|换|重置|设为).*(?:我的)?(?:身份|职位|角色|工号|权限|姓名|名字|部门|资质|网关|向量库|知识库连接|密钥|apikey|api_key)`,
|
||
`.*(?:向量库连接|修改向量库|知识库连接|大模型网关密钥|网关密钥).*`,
|
||
}
|
||
for _, r := range secRegexes {
|
||
if matched, _ := regexp.MatchString(r, input); matched {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isPureUICommand 检查是否为纯前端UI视窗布局与主题控制指令
|
||
func isPureUICommand(input string) bool {
|
||
patterns := []string{
|
||
"打开工具栏", "关闭工具栏",
|
||
"折叠侧边栏", "展开侧边栏", "隐藏侧边栏", "显示侧边栏",
|
||
"折叠左侧栏", "展开左侧栏", "折叠右侧栏", "展开右侧栏",
|
||
"折叠工作台", "展开工作台",
|
||
"切换全屏", "全屏显示", "退出全屏",
|
||
"打开控制台", "关闭控制台",
|
||
"切换主题", "换成深色", "换成浅色",
|
||
"打开抽屉", "展开抽屉", "关闭抽屉",
|
||
"深色模式", "浅色模式", "深色主题", "浅色主题",
|
||
}
|
||
for _, p := range patterns {
|
||
if strings.Contains(input, p) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// resolveTargetDoc 优先从输入中提取显式受控文档名称,提取不到时回退至上下文的 ActiveDocID
|
||
func resolveTargetDoc(input string, userCtx *ContextSnapshot) string {
|
||
if doc := extractDocName(input); doc != "" {
|
||
return doc
|
||
}
|
||
if userCtx != nil && userCtx.ActiveDocID != "" {
|
||
return userCtx.ActiveDocID
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// isRejectReasonQuery 判定是否为询问驳回/退回原因(而非发起驳回执行)
|
||
func isRejectReasonQuery(input string) bool {
|
||
markers := []string{
|
||
"原因", "为何", "为什么", "怎么回事", "什么情况", "什么意见",
|
||
"哪点", "哪些问题", "整改", "意见是什么", "理由", "怎么改", "如何修改",
|
||
"查一下驳回", "看看驳回",
|
||
}
|
||
for _, m := range markers {
|
||
if strings.Contains(input, m) {
|
||
return true
|
||
}
|
||
}
|
||
return strings.Contains(input, "被驳回")
|
||
}
|
||
|
||
// matchDocGovAction 匹配法定受控文档治理流转动作与槽位提取
|
||
func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent {
|
||
// 0. 询问驳回原因:归属 DECISION_QUERY,不得路由为 DOCGOV_*_REJECT
|
||
if (strings.Contains(input, "驳回") || strings.Contains(input, "退回")) && isRejectReasonQuery(input) {
|
||
slots := make(map[string]string)
|
||
if doc := resolveTargetDoc(input, userCtx); doc != "" {
|
||
slots[SlotTargetDoc] = doc
|
||
}
|
||
return &CandidateIntent{
|
||
Intent: IntentQueryRejectReason,
|
||
Confidence: 0.96,
|
||
FilledSlots: slots,
|
||
Reasoning: "用户询问受控文档驳回/退回原因,路由为 QUERY_REJECT_REASON",
|
||
}
|
||
}
|
||
|
||
// 1. 驳回 (Review Reject or Final Reject)
|
||
if strings.Contains(input, "驳回") {
|
||
isFinal := false
|
||
if strings.Contains(input, "终审") || strings.Contains(input, "批准") {
|
||
isFinal = true
|
||
} else if userCtx != nil && (userCtx.DocLifecycleState == "approving" || userCtx.UserDocRole == "approver") {
|
||
isFinal = true
|
||
}
|
||
|
||
targetIntent := IntentDocGovReviewReject
|
||
if isFinal {
|
||
targetIntent = IntentDocGovFinalReject
|
||
}
|
||
|
||
slots := make(map[string]string)
|
||
|
||
// target_doc
|
||
targetDoc := resolveTargetDoc(input, userCtx)
|
||
if targetDoc != "" {
|
||
slots[SlotTargetDoc] = targetDoc
|
||
}
|
||
|
||
// target_section
|
||
section := extractSection(input)
|
||
if section != "" {
|
||
slots[SlotTargetSection] = section
|
||
}
|
||
|
||
// reject_reason
|
||
reason := extractRejectReason(input)
|
||
if reason != "" {
|
||
slots[SlotRejectReason] = reason
|
||
}
|
||
|
||
confidence := 0.95
|
||
reasoning := fmt.Sprintf("用户下达法定治理驳回指令,目标意图为 %s", targetIntent)
|
||
clarifyQ := ""
|
||
|
||
// Stage 0 权限与状态前置剪枝校验
|
||
if userCtx != nil {
|
||
if !isFinal && userCtx.UserDocRole != "" && userCtx.UserDocRole != "reviewer" && userCtx.UserDocRole != "system" {
|
||
confidence = 0.90
|
||
reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是审核人(UserDocRole: %s),权限不符,无法执行审核驳回", userCtx.UserDocRole)
|
||
clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备审核驳回权限(需审核人角色)。", userCtx.UserDocRole)
|
||
} else if isFinal && userCtx.UserDocRole != "" && userCtx.UserDocRole != "approver" && userCtx.UserDocRole != "system" {
|
||
confidence = 0.90
|
||
reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是批准人(UserDocRole: %s),权限不符,无法执行终审驳回", userCtx.UserDocRole)
|
||
clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备终审批准驳回权限(需批准人角色)。", userCtx.UserDocRole)
|
||
}
|
||
|
||
if userCtx.DocLifecycleState == "drafting" {
|
||
confidence = 0.90
|
||
reasoning = "前置剪枝:当前文档处于编制草稿阶段(drafting),尚未提交审核,无法执行驳回操作"
|
||
clarifyQ = "当前文档处于编制中(drafting),尚未送审,无需驳回。"
|
||
}
|
||
}
|
||
|
||
return &CandidateIntent{
|
||
Intent: targetIntent,
|
||
Confidence: confidence,
|
||
Branch: BranchNewIntentPreempt,
|
||
FilledSlots: slots,
|
||
Reasoning: reasoning,
|
||
ClarificationQuestion: clarifyQ,
|
||
}
|
||
}
|
||
|
||
// 2. 终审批准 (Final Approve)
|
||
if strings.Contains(input, "终审批准") || strings.Contains(input, "批准发布") || strings.Contains(input, "批准生效") ||
|
||
strings.Contains(input, "同意发布") || (strings.Contains(input, "批准") && !strings.Contains(input, "不")) {
|
||
targetIntent := IntentDocGovFinalApprove
|
||
slots := make(map[string]string)
|
||
targetDoc := resolveTargetDoc(input, userCtx)
|
||
if targetDoc != "" {
|
||
slots[SlotTargetDoc] = targetDoc
|
||
}
|
||
|
||
confidence := 0.95
|
||
reasoning := "用户下达受控文档终审批准生效指令"
|
||
clarifyQ := ""
|
||
|
||
if userCtx != nil {
|
||
if userCtx.UserDocRole != "" && userCtx.UserDocRole != "approver" && userCtx.UserDocRole != "system" {
|
||
confidence = 0.90
|
||
reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是批准人(UserDocRole: %s),权限不符,无法执行终审批准", userCtx.UserDocRole)
|
||
clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备终审批准权限(需批准人角色)。", userCtx.UserDocRole)
|
||
} else if userCtx.DocLifecycleState != "" && userCtx.DocLifecycleState != "approving" {
|
||
confidence = 0.90
|
||
reasoning = fmt.Sprintf("前置剪枝:当前文档处于【%s】阶段,尚未进入终审批准阶段(approving),无法执行终审批准", userCtx.DocLifecycleState)
|
||
clarifyQ = fmt.Sprintf("当前文档处于【%s】阶段,未进入终审阶段,无法执行终审批准。", userCtx.DocLifecycleState)
|
||
}
|
||
}
|
||
|
||
return &CandidateIntent{
|
||
Intent: targetIntent,
|
||
Confidence: confidence,
|
||
Branch: BranchNewIntentPreempt,
|
||
FilledSlots: slots,
|
||
Reasoning: reasoning,
|
||
ClarificationQuestion: clarifyQ,
|
||
}
|
||
}
|
||
|
||
// 3. 审核通过 (Review Approve)
|
||
if strings.Contains(input, "审核通过") || strings.Contains(input, "审阅通过") || strings.Contains(input, "评审通过") ||
|
||
(strings.Contains(input, "通过") && (strings.Contains(input, "审核") || strings.Contains(input, "审阅") || strings.Contains(input, "报告") || strings.Contains(input, "准予") || strings.Contains(input, "环节"))) {
|
||
if !strings.Contains(input, "下一阶段") && !strings.Contains(input, "推进") {
|
||
targetIntent := IntentDocGovReviewApprove
|
||
slots := make(map[string]string)
|
||
targetDoc := resolveTargetDoc(input, userCtx)
|
||
if targetDoc != "" {
|
||
slots[SlotTargetDoc] = targetDoc
|
||
}
|
||
|
||
confidence := 0.95
|
||
reasoning := "用户下达受控文档审核通过指令"
|
||
clarifyQ := ""
|
||
|
||
if userCtx != nil {
|
||
if userCtx.UserDocRole != "" && userCtx.UserDocRole != "reviewer" && userCtx.UserDocRole != "system" {
|
||
confidence = 0.90
|
||
reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是审核人(UserDocRole: %s),权限不符,无法执行审核通过", userCtx.UserDocRole)
|
||
clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备审核通过权限(需审核人角色)。", userCtx.UserDocRole)
|
||
} else if userCtx.DocLifecycleState != "" && userCtx.DocLifecycleState != "reviewing" {
|
||
confidence = 0.90
|
||
reasoning = fmt.Sprintf("前置剪枝:当前文档处于【%s】阶段,尚未进入审核阶段(reviewing),无法执行审核通过", userCtx.DocLifecycleState)
|
||
clarifyQ = fmt.Sprintf("当前文档处于【%s】阶段,未进入审核阶段,无法执行审核通过。", userCtx.DocLifecycleState)
|
||
}
|
||
}
|
||
|
||
return &CandidateIntent{
|
||
Intent: targetIntent,
|
||
Confidence: confidence,
|
||
Branch: BranchNewIntentPreempt,
|
||
FilledSlots: slots,
|
||
Reasoning: reasoning,
|
||
ClarificationQuestion: clarifyQ,
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4. 认领文档 (Claim) — 查询语气不得路由为写操作
|
||
if strings.Contains(input, "认领") {
|
||
if detectDialogueAct(input) == DialogueActAsk || isDocGovStatusQuery(input, "claim") {
|
||
slots := make(map[string]string)
|
||
if doc := resolveTargetDoc(input, userCtx); doc != "" {
|
||
slots[SlotTargetDoc] = doc
|
||
}
|
||
return &CandidateIntent{
|
||
Intent: IntentQueryUserRights,
|
||
Confidence: 0.91,
|
||
Branch: BranchNewIntentPreempt,
|
||
FilledSlots: slots,
|
||
Reasoning: "查询语气下的文档认领状态询问,不得路由为写操作 DOCGOV_CLAIM",
|
||
ClarificationQuestion: "您是想查询该文档当前由谁认领负责吗?我将为您检索认领状态,不会执行认领操作。",
|
||
}
|
||
}
|
||
slots := make(map[string]string)
|
||
targetDoc := resolveTargetDoc(input, userCtx)
|
||
if targetDoc != "" {
|
||
slots[SlotTargetDoc] = targetDoc
|
||
}
|
||
|
||
return &CandidateIntent{
|
||
Intent: IntentDocGovClaim,
|
||
Confidence: 0.94,
|
||
Branch: BranchNewIntentPreempt,
|
||
FilledSlots: slots,
|
||
Reasoning: "用户下达认领文档编制责任人指令",
|
||
}
|
||
}
|
||
|
||
// 5. 提交送审 (Submit Review) — 查询语气不得路由为写操作
|
||
if strings.Contains(input, "送审") || strings.Contains(input, "提交审核") || strings.Contains(input, "发起审核") || strings.Contains(input, "提审") {
|
||
if detectDialogueAct(input) == DialogueActAsk || isDocGovStatusQuery(input, "submit") {
|
||
slots := make(map[string]string)
|
||
if doc := resolveTargetDoc(input, userCtx); doc != "" {
|
||
slots[SlotTargetDoc] = doc
|
||
}
|
||
return &CandidateIntent{
|
||
Intent: IntentQueryRejectReason,
|
||
Confidence: 0.91,
|
||
Branch: BranchNewIntentPreempt,
|
||
FilledSlots: slots,
|
||
Reasoning: "查询语气下的送审/审核状态询问,不得路由为写操作 DOCGOV_SUBMIT_REVIEW",
|
||
ClarificationQuestion: "您是想查询该文档的送审或审核状态吗?我将为您检索当前流转状态,不会发起新的送审。",
|
||
}
|
||
}
|
||
slots := make(map[string]string)
|
||
targetDoc := resolveTargetDoc(input, userCtx)
|
||
if targetDoc != "" {
|
||
slots[SlotTargetDoc] = targetDoc
|
||
}
|
||
|
||
return &CandidateIntent{
|
||
Intent: IntentDocGovSubmitReview,
|
||
Confidence: 0.94,
|
||
Branch: BranchNewIntentPreempt,
|
||
FilledSlots: slots,
|
||
Reasoning: "用户下达编制完成送交审核指令",
|
||
}
|
||
}
|
||
|
||
// 6. 提案裁决 (Proposal Decide)
|
||
if strings.Contains(input, "提案") || strings.Contains(input, "修改建议") || strings.Contains(input, "变更建议") {
|
||
if strings.Contains(input, "同意") || strings.Contains(input, "采纳") || strings.Contains(input, "拒绝") || strings.Contains(input, "拍板") {
|
||
slots := make(map[string]string)
|
||
if strings.Contains(input, "拒绝") {
|
||
slots[SlotProposalAction] = "reject"
|
||
} else {
|
||
slots[SlotProposalAction] = "accept"
|
||
}
|
||
return &CandidateIntent{
|
||
Intent: IntentDocGovProposalDecide,
|
||
Confidence: 0.93,
|
||
Branch: BranchNewIntentPreempt,
|
||
FilledSlots: slots,
|
||
Reasoning: "用户对受控文档的修改提案或批注做出决策裁决",
|
||
}
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// matchKnowledgeQuery 匹配法规标准、标杆案例与权限查询
|
||
func matchKnowledgeQuery(input string) *CandidateIntent {
|
||
// 排除事实参数补充的输入场景
|
||
excludeKeywords := []string{"客户说", "客户反馈", "客户补充", "补充说", "补充", "修改为", "改为", "增加"}
|
||
for _, ek := range excludeKeywords {
|
||
if strings.Contains(input, ek) {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// 1. 用户权限查询 (IntentQueryUserRights)
|
||
userRightsPatterns := []string{
|
||
"我的权限", "我有什么权限", "我负责什么", "我的角色", "查看我的职责",
|
||
"查询权限", "当前权限", "我在这个项目的角色", "法定角色", "法定职责", "我的职责",
|
||
"文档权限", "文档职责", "权限和职责", "职责和权限", "负责哪些文档", "权限",
|
||
}
|
||
for _, p := range userRightsPatterns {
|
||
if strings.Contains(input, p) {
|
||
return &CandidateIntent{
|
||
Intent: IntentQueryUserRights,
|
||
Confidence: 0.95,
|
||
FilledSlots: map[string]string{
|
||
SlotQueryTopic: "user_rights",
|
||
},
|
||
Reasoning: "用户查询自身在项目及受控文档中的法定职责与操作权限",
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. 标杆/相似案例查询 (IntentQuerySimilarCases)
|
||
similarCasesPatterns := []string{
|
||
"参考案例", "往期经验", "类似项目", "相似案例", "同类产品",
|
||
"同类项目", "以往案例", "标杆案例", "同类获批", "竞品获批案例",
|
||
"注册案例", "申报案例",
|
||
}
|
||
for _, p := range similarCasesPatterns {
|
||
if strings.Contains(input, p) {
|
||
return &CandidateIntent{
|
||
Intent: IntentQuerySimilarCases,
|
||
Confidence: 0.92,
|
||
FilledSlots: map[string]string{
|
||
SlotQueryTopic: "similar_cases",
|
||
},
|
||
Reasoning: "用户查询以往类似产品的注册申报、上市审评或标杆案例与经验",
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. 法规强标查询 (IntentQueryRegulations)
|
||
regKeywords := []string{
|
||
"gb 9706", "gb9706", "yy 0505", "yy0505", "强制性标准", "强标",
|
||
"技术审评指导原则", "指导原则", "合规要求", "注册标准",
|
||
}
|
||
lower := strings.ToLower(input)
|
||
for _, rk := range regKeywords {
|
||
if strings.Contains(lower, rk) {
|
||
return &CandidateIntent{
|
||
Intent: IntentQueryRegulations,
|
||
Confidence: 0.92,
|
||
FilledSlots: map[string]string{
|
||
SlotQueryTopic: "regulations",
|
||
},
|
||
Reasoning: "用户咨询医疗器械适用的国家标准、行业强标或审评指导原则法规",
|
||
}
|
||
}
|
||
}
|
||
|
||
if (strings.Contains(lower, "法规") || strings.Contains(lower, "标准") || strings.Contains(lower, "yy") || strings.Contains(lower, "fda") || strings.Contains(lower, "ce")) &&
|
||
(strings.Contains(lower, "查") || strings.Contains(lower, "哪些") || strings.Contains(lower, "满足") || strings.Contains(lower, "要求") || strings.Contains(lower, "规范")) {
|
||
return &CandidateIntent{
|
||
Intent: IntentQueryRegulations,
|
||
Confidence: 0.90,
|
||
FilledSlots: map[string]string{
|
||
SlotQueryTopic: "regulations",
|
||
},
|
||
Reasoning: "用户咨询医疗器械适用的国家标准、行业强标或审评指导原则法规",
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// extractDocName 从文本或预设映射中提取目标受控文档名称/标识
|
||
func extractDocName(input string) string {
|
||
reDocID := regexp.MustCompile(`(doc-[a-zA-Z0-9_-]+)`)
|
||
if match := reDocID.FindString(input); match != "" {
|
||
return match
|
||
}
|
||
|
||
docNames := []string{
|
||
"用户初始需求收集表", "用户初始需求表", "用户需求收集表", "需求收集表", "需求表",
|
||
"技术与市场调研报告", "调研报告",
|
||
"法规与注册论证报告", "法规评估", "注册评估",
|
||
"初步设计与架构方案", "初步设计", "设计方案",
|
||
"CDO报价单", "初步报价单", "报价单",
|
||
}
|
||
for _, name := range docNames {
|
||
if strings.Contains(input, name) {
|
||
return name
|
||
}
|
||
}
|
||
|
||
reBa := regexp.MustCompile(`把\s*(.+?)\s*(?:审核驳回|终审驳回|驳回|审核通过|批准|认领|送审|提交审核)`)
|
||
if match := reBa.FindStringSubmatch(input); len(match) > 1 {
|
||
return strings.TrimSpace(match[1])
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
// extractRejectReason 从用户输入中提取驳回意见或批注内容
|
||
func extractRejectReason(input string) string {
|
||
reReason := regexp.MustCompile(`(?:意见为|意见是|意见|理由为|理由是|理由|原因|批注为|批注|修改意见)[::\s]\s*(.+)`)
|
||
if match := reReason.FindStringSubmatch(input); len(match) > 1 {
|
||
res := strings.TrimSpace(match[1])
|
||
return strings.Trim(res, ",,。;; ")
|
||
}
|
||
|
||
reAfterReject := regexp.MustCompile(`驳回[,,::\s]+(.+)`)
|
||
if match := reAfterReject.FindStringSubmatch(input); len(match) > 1 {
|
||
res := strings.TrimSpace(match[1])
|
||
res = regexp.MustCompile(`^(?:意见为|意见是|意见|理由为|理由是|理由|原因|批注为|批注|修改意见)[::\s]\s*`).ReplaceAllString(res, "")
|
||
res = strings.Trim(res, ",,。;; ")
|
||
if res != "" {
|
||
return res
|
||
}
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
// extractSection 从文本中抽取目标章节线索 (如 "第3节", "3.1节")
|
||
func extractSection(input string) string {
|
||
reSection := regexp.MustCompile(`(第[0-9一二三四五六七八九十]+[章节条部分]+|[0-9]+\.[0-9]+[节章]*)`)
|
||
if match := reSection.FindString(input); match != "" {
|
||
return match
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// matchExpectationHit 检验是否命中挂起预期 (分支 1)
|
||
func matchExpectationHit(input string, pending *PendingExpectation) *CandidateIntent {
|
||
// 0. 检验超时过期
|
||
if !pending.ExpiresAt.IsZero() && time.Now().After(pending.ExpiresAt) {
|
||
return nil
|
||
}
|
||
|
||
// 1. 否定/暂缓意图
|
||
declineKeywords := []string{"先别", "别推", "算了", "先不", "等等", "暂时不", "先别推", "不要推进", "不要", "不行", "算了吧", "先放一放", "先等等", "取消", "放弃"}
|
||
if pending.DeclineWord != "" {
|
||
declineKeywords = append(declineKeywords, pending.DeclineWord)
|
||
}
|
||
for _, dk := range declineKeywords {
|
||
if input == dk || strings.HasPrefix(input, dk) {
|
||
return &CandidateIntent{
|
||
Intent: IntentDeclineExpectation,
|
||
Confidence: 0.95,
|
||
Branch: BranchExpectationHit,
|
||
IsDecline: true,
|
||
FilledSlots: make(map[string]string),
|
||
Reasoning: fmt.Sprintf("用户明确表示暂缓或取消当前挂起建议 ('%s')", input),
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. 选择序号检验 (如 "1", "2", "选1", "选2", "第1个", "第一个", "选项1")
|
||
var matchedOpt *ExpectationOption
|
||
optIndex := matchOptionIndex(input, pending.Options)
|
||
if optIndex >= 0 && optIndex < len(pending.Options) {
|
||
matchedOpt = &pending.Options[optIndex]
|
||
}
|
||
|
||
// 肯定前缀后含独立新请求谓词时,不得吞掉新意图(如「好,请先查法规」)
|
||
if matchedOpt == nil && hasIndependentNewPredicate(input) {
|
||
return nil
|
||
}
|
||
|
||
// 3. 显式确认短语(不受 AllowAffirmative 限制)
|
||
explicitConfirm := isExplicitConfirmPhrase(input)
|
||
|
||
// 4. 肯定词检验 (如 "好", "行", "可以", "成", "没问题", "同意", "好的", "就按这个", "按你说的办", "确认")
|
||
affirmativePrefixes := []string{"好", "好的", "行", "行啊", "成", "可以", "没问题", "同意", "就按这个", "按这个", "就按这个办", "听你的", "ok", "yes", "就这么定", "确认", "确定", "批准"}
|
||
if pending.AffirmativeWord != "" {
|
||
affirmativePrefixes = append(affirmativePrefixes, pending.AffirmativeWord)
|
||
}
|
||
isAffirmative := explicitConfirm
|
||
|
||
for _, aff := range affirmativePrefixes {
|
||
if input == aff ||
|
||
strings.HasPrefix(input, aff+",") ||
|
||
strings.HasPrefix(input, aff+",") ||
|
||
strings.HasPrefix(input, aff+"但") ||
|
||
strings.HasPrefix(input, aff+" ") ||
|
||
strings.HasPrefix(input, aff+"!") ||
|
||
strings.HasPrefix(input, aff+"!") ||
|
||
(aff == "确认" && (input == "确认" || input == "确认驳回" || input == "确认批准" || input == "确认执行")) ||
|
||
(pending.AffirmativeWord != "" && strings.HasPrefix(input, pending.AffirmativeWord)) {
|
||
isAffirmative = true
|
||
break
|
||
}
|
||
}
|
||
|
||
// 裸肯定词必须 AllowAffirmative=true(显式确认短语除外)
|
||
if isAffirmative && !explicitConfirm && !pending.AllowAffirmative {
|
||
bareAffirmative := false
|
||
for _, aff := range affirmativePrefixes {
|
||
if input == aff {
|
||
bareAffirmative = true
|
||
break
|
||
}
|
||
}
|
||
if bareAffirmative {
|
||
isAffirmative = false
|
||
}
|
||
}
|
||
|
||
if matchedOpt == nil && isAffirmative {
|
||
if pending.DefaultOption != nil {
|
||
matchedOpt = pending.DefaultOption
|
||
} else if len(pending.Options) > 0 {
|
||
matchedOpt = &pending.Options[0]
|
||
} else if pending.ExpectedIntent != "" {
|
||
matchedOpt = &ExpectationOption{
|
||
OptionID: pending.TargetAction,
|
||
Title: pending.Prompt,
|
||
Intent: pending.ExpectedIntent,
|
||
Slots: pending.Payload,
|
||
}
|
||
}
|
||
}
|
||
|
||
if matchedOpt == nil {
|
||
return nil
|
||
}
|
||
|
||
// 4. 提取增量事实补丁 (Patch Merge)
|
||
// 例如: "好,但目标市场改成欧洲CE", "选1,先把预算定为15万"
|
||
patchSlots := extractFactSlots(input)
|
||
|
||
filledSlots := make(map[string]string)
|
||
for k, v := range matchedOpt.Slots {
|
||
filledSlots[k] = v
|
||
}
|
||
for k, v := range patchSlots {
|
||
filledSlots[k] = v
|
||
}
|
||
if pending.TargetAction != "" && filledSlots["target_action"] == "" {
|
||
filledSlots["target_action"] = pending.TargetAction
|
||
}
|
||
|
||
confidence := 1.0
|
||
reasoning := fmt.Sprintf("用户明确肯定上一轮参谋建议动作 [%s: %s]", matchedOpt.OptionID, matchedOpt.Title)
|
||
if len(patchSlots) > 0 {
|
||
confidence = 0.98
|
||
reasoning = fmt.Sprintf("用户肯定上一轮建议动作 [%s: %s],并附带增量事实补丁: %v", matchedOpt.OptionID, matchedOpt.Title, patchSlots)
|
||
} else if optIndex >= 0 {
|
||
confidence = 0.98
|
||
reasoning = fmt.Sprintf("用户按序号选择了备选动作 [%s: %s]", matchedOpt.OptionID, matchedOpt.Title)
|
||
}
|
||
|
||
return &CandidateIntent{
|
||
Intent: matchedOpt.Intent,
|
||
Confidence: confidence,
|
||
Branch: BranchExpectationHit,
|
||
FilledSlots: filledSlots,
|
||
PatchSlots: patchSlots,
|
||
MatchedOption: matchedOpt,
|
||
Reasoning: reasoning,
|
||
}
|
||
}
|
||
|
||
// matchOptionIndex 匹配用户输入的选项编号
|
||
func matchOptionIndex(input string, options []ExpectationOption) int {
|
||
input = strings.TrimSpace(input)
|
||
if len(options) == 0 {
|
||
return -1
|
||
}
|
||
|
||
patterns := []struct {
|
||
regex string
|
||
idx int
|
||
}{
|
||
{"^(1|选1|选第1个|第一个|第1个|选项1|选项一)$", 0},
|
||
{"^(2|选2|选第2个|第二个|第2个|选项2|选项二)$", 1},
|
||
{"^(3|选3|选第3个|第三个|第3个|选项3|选项三)$", 2},
|
||
}
|
||
|
||
for _, p := range patterns {
|
||
if matched, _ := regexp.MatchString("(?i)"+p.regex, input); matched {
|
||
return p.idx
|
||
}
|
||
}
|
||
|
||
for idx, opt := range options {
|
||
if strings.EqualFold(opt.OptionID, input) {
|
||
return idx
|
||
}
|
||
}
|
||
|
||
return -1
|
||
}
|
||
|
||
// isConsultativeOrHesitant 检验是否属于犹豫或征询参谋意见 (分支 3)
|
||
func isConsultativeOrHesitant(input string) bool {
|
||
patterns := []string{
|
||
"你觉得呢", "你怎么看", "有什么建议", "怎么选", "拿不定主意",
|
||
"先放放.*你觉得", "这个先放放", "先放一放.*你觉得", "哪个好", "给点意见",
|
||
}
|
||
for _, p := range patterns {
|
||
if matched, _ := regexp.MatchString("(?i)"+p, input); matched {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// buildAmbiguousConsultResult 构建分支 3 (模糊/分忧引导) 输出
|
||
func buildAmbiguousConsultResult(input, snapID string, pending *PendingExpectation) *IntentRecognitionResult {
|
||
clarifyQ := "我为您准备了当前阶段最稳妥的推进选项。请问需要我为您详细分析技术可行性,还是先为您做方案摸底?"
|
||
if pending != nil && len(pending.Options) > 0 {
|
||
var optDesc strings.Builder
|
||
optDesc.WriteString("针对目前的决策节点,我为您梳理了以下选项供您参考:\n")
|
||
for _, o := range pending.Options {
|
||
optDesc.WriteString(fmt.Sprintf("• [%s] %s: %s\n", o.OptionID, o.Title, o.Description))
|
||
}
|
||
clarifyQ = optDesc.String()
|
||
}
|
||
|
||
top := &CandidateIntent{
|
||
Intent: IntentCasualChat,
|
||
Confidence: 0.65,
|
||
Branch: BranchAmbiguousFallback,
|
||
FilledSlots: make(map[string]string),
|
||
Reasoning: "用户表达犹豫或主动征询参谋意见,触发参谋主动分忧与引导",
|
||
ClarificationQuestion: clarifyQ,
|
||
}
|
||
|
||
result := &IntentRecognitionResult{
|
||
RawInput: input,
|
||
Branch: BranchAmbiguousFallback,
|
||
TopIntent: top,
|
||
Candidates: []CandidateIntent{*top},
|
||
ContextSnapshotID: snapID,
|
||
RequiresClarifying: true,
|
||
}
|
||
finalizeRecognitionResult(result)
|
||
return result
|
||
}
|
||
|
||
// extractFactSlots 从文本中提取结构化事实槽位 (支持补丁合并与补充信息)
|
||
func extractFactSlots(input string) map[string]string {
|
||
slots := make(map[string]string)
|
||
|
||
marketPrefixes := []string{
|
||
"目标市场不仅是", "目标市场不仅有", "目标市场还包括", "目标市场确定为", "目标市场确定是",
|
||
"目标市场改成", "目标市场改为", "目标市场是", "目标市场为",
|
||
"市场改成", "市场改为", "市场是", "市场为", "目标市场除了国内还想考虑",
|
||
}
|
||
for _, p := range marketPrefixes {
|
||
if idx := strings.Index(input, p); idx != -1 {
|
||
val := strings.TrimSpace(input[idx+len(p):])
|
||
val = strings.Trim(val, "::,,。 ")
|
||
if val != "" {
|
||
slots["target_market"] = val
|
||
slots[SlotFactKey] = "target_market"
|
||
slots[SlotFactValue] = val
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
budgetPrefixes := []string{"预算改成", "预算改为", "预算是", "预算为", "预算只有", "预算大概", "预算"}
|
||
for _, p := range budgetPrefixes {
|
||
if idx := strings.Index(input, p); idx != -1 && slots["budget"] == "" {
|
||
val := strings.TrimSpace(input[idx+len(p):])
|
||
val = strings.Trim(val, "::,,。 ")
|
||
if val != "" {
|
||
slots["budget"] = val
|
||
slots[SlotFactKey] = "budget"
|
||
slots[SlotFactValue] = val
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
usePrefixes := []string{"用途是", "用途为", "预期用途是", "场景是", "场景为"}
|
||
for _, p := range usePrefixes {
|
||
if idx := strings.Index(input, p); idx != -1 && slots["intended_use"] == "" {
|
||
val := strings.TrimSpace(input[idx+len(p):])
|
||
val = strings.Trim(val, "::,,。 ")
|
||
if val != "" {
|
||
slots["intended_use"] = val
|
||
slots[SlotFactKey] = "intended_use"
|
||
slots[SlotFactValue] = val
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
return slots
|
||
}
|
||
|
||
func matchAdvanceStage(input string, currentStage maturity.MaturityStage) *CandidateIntent {
|
||
patterns := []string{
|
||
"进入下一阶段", "推进到下一阶段", "推进阶段", "进入方案", "进入论证",
|
||
"方案论证", "可行性论证", "开始方案", "进入报价", "开始报价", "商业锁定",
|
||
"进入v0.1", "推进到v0.1", "进入v0.5", "推进到v0.5", "进入v1.0", "推进到v1.0", "可以进入",
|
||
"没有问题.*进入", "没问题.*推进", "通过.*下一阶段", "开始推进",
|
||
"做.*摸底", "开始.*摸底", "先做.*摸底", "进行.*摸底", "做v0.1", "先做v0.1",
|
||
"做.*论证", "做.*报价", "开始论证",
|
||
}
|
||
|
||
for _, p := range patterns {
|
||
if matched, _ := regexp.MatchString("(?i)"+p, input); matched {
|
||
var target maturity.MaturityStage
|
||
if strings.Contains(input, "报价") || strings.Contains(input, "v1.0") || strings.Contains(input, "V1.0") || strings.Contains(input, "商业锁定") {
|
||
target = maturity.StageV10_Quoting
|
||
} else if strings.Contains(input, "方案") || strings.Contains(input, "论证") || strings.Contains(input, "可行性") || strings.Contains(input, "v0.5") || strings.Contains(input, "V0.5") {
|
||
target = maturity.StageV05_Feasibility
|
||
} else if strings.Contains(input, "摸底") || strings.Contains(input, "初筛") || strings.Contains(input, "v0.1") || strings.Contains(input, "V0.1") {
|
||
target = maturity.StageV01_Triage
|
||
} else {
|
||
if currentStage == maturity.StageV01_Triage {
|
||
target = maturity.StageV05_Feasibility
|
||
} else if currentStage == maturity.StageV05_Feasibility {
|
||
target = maturity.StageV10_Quoting
|
||
} else {
|
||
target = maturity.StageV10_Quoting
|
||
}
|
||
}
|
||
|
||
reason := fmt.Sprintf("匹配推进指令 '%s',目标成熟度阶段为 %s", p, target)
|
||
|
||
return &CandidateIntent{
|
||
Intent: IntentAdvanceStage,
|
||
Confidence: 0.92,
|
||
FilledSlots: map[string]string{
|
||
SlotTargetStage: string(target),
|
||
},
|
||
Reasoning: reason,
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func matchSupplementInfo(input string) *CandidateIntent {
|
||
patterns := []string{
|
||
"补充", "更新", "客户说", "客户反馈", "客户确定", "修改为", "改为", "增加",
|
||
"目标市场", "市场是", "参数是", "用途是", "设备是", "预算是", "时间是", "资料", "附件",
|
||
}
|
||
|
||
matchedAny := false
|
||
for _, p := range patterns {
|
||
if strings.Contains(input, p) {
|
||
matchedAny = true
|
||
break
|
||
}
|
||
}
|
||
if !matchedAny {
|
||
return nil
|
||
}
|
||
|
||
slots := extractFactSlots(input)
|
||
if len(slots) == 0 {
|
||
slots = make(map[string]string)
|
||
}
|
||
slots[SlotSupplementDoc] = input
|
||
slots[SlotSupplementAction] = inferSupplementSubtype(input, slots)
|
||
|
||
return &CandidateIntent{
|
||
Intent: IntentSupplementInfo,
|
||
Confidence: 0.88,
|
||
FilledSlots: slots,
|
||
Reasoning: "用户输入中包含参数变更或新材料事实补充",
|
||
}
|
||
}
|
||
|
||
func matchQueryProgress(input string) *CandidateIntent {
|
||
if matchTriggerTask(input) != nil {
|
||
return nil
|
||
}
|
||
if isWorkflowControlInput(input) || isRunControlInput(input) {
|
||
return nil
|
||
}
|
||
patterns := []string{
|
||
"测试方案", "验证方案", "测试", "验证", "型检", "型式检验", "难点", "特别注意", "注意事项", "比较难",
|
||
"进展", "进度", "走到哪", "到哪一步", "状态", "情况", "评估到哪",
|
||
"风险", "坑", "注意什么", "话术", "怎么说", "怎么跟客户说", "微信", "草稿",
|
||
"探头功耗", "功耗", "供电", "怎么设计", "设计方案", "临床要不要", "怎么做", "查一下", "看下",
|
||
"成熟度", "汇报一下", "项目怎么样",
|
||
}
|
||
|
||
for _, p := range patterns {
|
||
if strings.Contains(input, p) {
|
||
topic := "all"
|
||
if strings.Contains(input, "测试") || strings.Contains(input, "验证") || strings.Contains(input, "型检") || strings.Contains(input, "难点") || strings.Contains(input, "特别注意") || strings.Contains(input, "注意事项") || strings.Contains(input, "比较难") {
|
||
topic = "testing_analysis"
|
||
} else if strings.Contains(input, "风险") || strings.Contains(input, "坑") {
|
||
topic = "risk"
|
||
} else if strings.Contains(input, "话术") || strings.Contains(input, "微信") || strings.Contains(input, "怎么说") {
|
||
topic = "talk_script"
|
||
} else if strings.Contains(input, "进展") || strings.Contains(input, "进度") || strings.Contains(input, "步") {
|
||
topic = "progress"
|
||
} else if strings.Contains(input, "功耗") || strings.Contains(input, "供电") || strings.Contains(input, "设计") {
|
||
topic = "technical_design"
|
||
}
|
||
|
||
confidence := 0.90
|
||
if topic == "testing_analysis" {
|
||
confidence = 0.95
|
||
}
|
||
|
||
return &CandidateIntent{
|
||
Intent: IntentQueryProgress,
|
||
Confidence: confidence,
|
||
FilledSlots: map[string]string{
|
||
SlotQueryTopic: topic,
|
||
},
|
||
Reasoning: fmt.Sprintf("用户在询问项目状态、技术测试方案或对外沟通话术 (主题: %s)", topic),
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func hasTaskWriteVerb(input string) bool {
|
||
for _, verb := range []string{"生成", "做", "跑", "出", "编写", "编制", "起草"} {
|
||
if strings.Contains(input, verb) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func matchTriggerTask(input string) *CandidateIntent {
|
||
type phrase struct {
|
||
text string
|
||
key string
|
||
}
|
||
taskMap := map[string]string{
|
||
"需求收集表": "intake",
|
||
"需求表": "intake",
|
||
"调研报告": "research",
|
||
"调研": "research",
|
||
"法规评估": "feasibility_reg",
|
||
"注册评估": "feasibility_reg",
|
||
"初步设计": "prelim_design",
|
||
"设计方案": "prelim_design",
|
||
"报价单": "cdo_quote",
|
||
"报价": "cdo_quote",
|
||
}
|
||
phrases := make([]phrase, 0, len(taskMap)+60)
|
||
for kw, tKey := range taskMap {
|
||
phrases = append(phrases, phrase{text: kw, key: tKey})
|
||
}
|
||
for _, item := range squad.RDevTriggerPhrases() {
|
||
phrases = append(phrases, phrase{text: item.Text, key: item.TaskKey})
|
||
}
|
||
sort.Slice(phrases, func(i, j int) bool {
|
||
li := utf8.RuneCountInString(phrases[i].text)
|
||
lj := utf8.RuneCountInString(phrases[j].text)
|
||
if li != lj {
|
||
return li > lj
|
||
}
|
||
return phrases[i].text < phrases[j].text
|
||
})
|
||
|
||
if !hasTaskWriteVerb(input) {
|
||
return nil
|
||
}
|
||
// 「初始 / 首轮 / 摸底」是 V0.1 摸底,不能被更短的研发别名「用户需求」截走。
|
||
if hasIntakeSemanticMarker(input) && strings.Contains(input, "需求") {
|
||
return triggerTaskCandidate("需求表", "intake")
|
||
}
|
||
for _, item := range phrases {
|
||
if strings.Contains(input, item.text) {
|
||
return triggerTaskCandidate(item.text, item.key)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func hasIntakeSemanticMarker(input string) bool {
|
||
return strings.Contains(input, "首轮") ||
|
||
strings.Contains(input, "初始") ||
|
||
strings.Contains(input, "需求摸底") ||
|
||
strings.Contains(input, "摸底")
|
||
}
|
||
|
||
func triggerTaskCandidate(phrase, taskKey string) *CandidateIntent {
|
||
return &CandidateIntent{
|
||
Intent: IntentTriggerTask,
|
||
Confidence: 0.86,
|
||
FilledSlots: map[string]string{
|
||
SlotTargetTask: taskKey,
|
||
},
|
||
Reasoning: fmt.Sprintf("用户要求显式生成交付任务: %s (%s)", phrase, taskKey),
|
||
}
|
||
}
|
||
|
||
func isGreetingOrHelp(input string) bool {
|
||
greetings := []string{"你好", "您好", "早上好", "下午好", "晚上好", "早啊", "在吗", "hi", "hello", "你是谁"}
|
||
for _, g := range greetings {
|
||
if strings.Contains(strings.ToLower(input), g) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func matchImpactIntent(input string) *CandidateIntent {
|
||
propose := strings.Contains(input, "修订提案") || strings.Contains(input, "生成修订") || strings.Contains(input, "提议修改文档")
|
||
analyze := strings.Contains(input, "分析影响") || strings.Contains(input, "影响分析") || strings.Contains(input, "波及哪些") ||
|
||
strings.Contains(input, "影响哪些") || strings.Contains(input, "改动影响") || strings.Contains(input, "变更影响")
|
||
if !propose && !analyze {
|
||
return nil
|
||
}
|
||
intent := IntentImpactAnalyze
|
||
if propose {
|
||
intent = IntentRevisePropose
|
||
}
|
||
return &CandidateIntent{
|
||
Intent: intent,
|
||
Confidence: 0.9,
|
||
FilledSlots: map[string]string{},
|
||
}
|
||
}
|
||
|
||
func sortCandidates(c []CandidateIntent) {
|
||
for i := 0; i < len(c)-1; i++ {
|
||
for j := i + 1; j < len(c); j++ {
|
||
if c[j].Confidence > c[i].Confidence {
|
||
c[i], c[j] = c[j], c[i]
|
||
}
|
||
}
|
||
}
|
||
}
|