Connect LLM segment proposals into HandleTurn, replace U10–U12 dry-run stubs with production owners (blackboard supplement, docgov claim/submit, workflow run/resume) when flags are on, and update policy/sysadmin maturity metadata.
221 lines
5.8 KiB
Go
221 lines
5.8 KiB
Go
package intentflow
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"chat-agent-system/internal/intent"
|
|
)
|
|
|
|
const SegmenterVersionLLM = "segmenter.v2.llm"
|
|
|
|
// buildPlanFromLLMSpans runs deterministic recognition per LLM span (U9).
|
|
func buildPlanFromLLMSpans(
|
|
ctx context.Context,
|
|
fullText string,
|
|
primary *intent.IntentRecognitionResult,
|
|
spans []SegmentSpan,
|
|
) *intent.PlanProposal {
|
|
if len(spans) < 2 || len(spans) > 4 {
|
|
return nil
|
|
}
|
|
rec := intent.NewRecognizer()
|
|
proposed := make([]intent.ProposedSegment, 0, len(spans))
|
|
for i, span := range spans {
|
|
segText := fullText[span.Start:span.End]
|
|
inSeg := intent.InputSegment{
|
|
Text: segText,
|
|
Normalized: normalizeSegmentText(segText),
|
|
Span: intent.TextSpan{Start: span.Start, End: span.End},
|
|
Evidence: "llm",
|
|
StructureConfidence: "medium",
|
|
}
|
|
text := strings.TrimSpace(inSeg.Normalized)
|
|
if text == "" {
|
|
text = strings.TrimSpace(inSeg.Text)
|
|
}
|
|
var frame *intent.IntentFrameV2
|
|
var missing []string
|
|
leaf, err := rec.Recognize(ctx, text, nil)
|
|
if err == nil && leaf != nil {
|
|
frame = intent.BuildIntentFrameV2(leaf, intent.DefaultRegistry())
|
|
if leaf.TopIntent != nil {
|
|
missing = append([]string{}, leaf.TopIntent.MissingSlots...)
|
|
}
|
|
if frame != nil && len(frame.MissingSlots) > 0 {
|
|
missing = append(missing, frame.MissingSlots...)
|
|
}
|
|
if leaf.TopIntent != nil && leaf.TopIntent.Intent == intent.IntentSecurityForbidden {
|
|
p := &intent.PlanProposal{
|
|
SegmenterVersion: SegmenterVersionLLM,
|
|
ComboHint: intent.ComboRejected,
|
|
Evidence: []intent.IntentEvidence{{Kind: "segment_security", Detail: string(intent.ComboRejected), Weight: 1}},
|
|
Segments: []intent.ProposedSegment{
|
|
proposedSegmentFromInput(inSeg, i, frame, missing),
|
|
},
|
|
}
|
|
p.Digest = llmProposalDigest(p)
|
|
return p
|
|
}
|
|
}
|
|
seg := proposedSegmentFromInput(inSeg, i, frame, uniqueStrings(missing))
|
|
if i > 0 {
|
|
seg.UnresolvedAnaphora = collectAnaphora(text)
|
|
seg.DependsOn = []string{fmt.Sprintf("%d", i)}
|
|
}
|
|
proposed = append(proposed, seg)
|
|
}
|
|
p := &intent.PlanProposal{
|
|
SegmenterVersion: SegmenterVersionLLM,
|
|
Segments: proposed,
|
|
ComboHint: comboHintForSegments(proposed, primary),
|
|
Evidence: []intent.IntentEvidence{
|
|
{Kind: "segmenter", Detail: SegmenterVersionLLM, Weight: 1},
|
|
{Kind: "split", Detail: "llm", Weight: 0.5},
|
|
},
|
|
}
|
|
p.Digest = llmProposalDigest(p)
|
|
return p
|
|
}
|
|
|
|
func proposedSegmentFromInput(s intent.InputSegment, i int, frame *intent.IntentFrameV2, missing []string) intent.ProposedSegment {
|
|
return intent.ProposedSegment{
|
|
StepID: fmt.Sprintf("%d", i+1),
|
|
Text: s.Text,
|
|
Normalized: s.Normalized,
|
|
Span: s.Span,
|
|
Connector: s.Connector,
|
|
Evidence: s.Evidence,
|
|
StructureConfidence: s.StructureConfidence,
|
|
Frame: frame,
|
|
MissingSlots: missing,
|
|
}
|
|
}
|
|
|
|
func comboHintForSegments(segs []intent.ProposedSegment, primary *intent.IntentRecognitionResult) intent.ComboHint {
|
|
if len(segs) <= 1 {
|
|
return intent.ComboSingle
|
|
}
|
|
needsClarify := false
|
|
needsConfirm := false
|
|
allQuery := true
|
|
seenKeys := map[string]int{}
|
|
for _, s := range segs {
|
|
if s.StructureConfidence == "low" || len(s.UnresolvedAnaphora) > 0 || len(s.MissingSlots) > 0 || s.Frame == nil {
|
|
needsClarify = true
|
|
}
|
|
if s.Frame == nil {
|
|
allQuery = false
|
|
continue
|
|
}
|
|
if s.Frame.Score > 0 && s.Frame.Score < 0.6 {
|
|
needsClarify = true
|
|
}
|
|
if isWriteFrame(s.Frame) {
|
|
needsConfirm = true
|
|
allQuery = false
|
|
}
|
|
if s.Frame.Goal != intent.GoalQuery && s.Frame.Goal != "" {
|
|
allQuery = false
|
|
}
|
|
if s.Frame.ActionKey != "" {
|
|
seenKeys[s.Frame.ActionKey]++
|
|
if seenKeys[s.Frame.ActionKey] > 1 {
|
|
needsClarify = true
|
|
}
|
|
}
|
|
}
|
|
if needsClarify {
|
|
return intent.ComboNeedsClarify
|
|
}
|
|
if needsConfirm {
|
|
return intent.ComboNeedsConfirm
|
|
}
|
|
if allQuery {
|
|
return intent.ComboReadOnly
|
|
}
|
|
if primary != nil && primary.RequiresClarifying {
|
|
return intent.ComboNeedsClarify
|
|
}
|
|
return intent.ComboNeedsConfirm
|
|
}
|
|
|
|
func isWriteFrame(frame *intent.IntentFrameV2) bool {
|
|
if frame == nil {
|
|
return false
|
|
}
|
|
switch frame.Goal {
|
|
case intent.GoalGenerate, intent.GoalMutate, intent.GoalControl:
|
|
return true
|
|
}
|
|
switch frame.LegacyIntent {
|
|
case intent.IntentAdvanceStage, intent.IntentTriggerTask, intent.IntentTriggerAdhocTask,
|
|
intent.IntentSupplementInfo, intent.IntentSwitchWorkflow, intent.IntentTriggerWorkflow,
|
|
intent.IntentRunResume, intent.IntentRunRestart:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func llmProposalDigest(p *intent.PlanProposal) string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
h := sha256.New()
|
|
_, _ = h.Write([]byte(p.SegmenterVersion))
|
|
_, _ = h.Write([]byte{0})
|
|
_, _ = h.Write([]byte(p.ComboHint))
|
|
for _, s := range p.Segments {
|
|
_, _ = h.Write([]byte{0})
|
|
_, _ = h.Write([]byte(s.Normalized))
|
|
_, _ = h.Write([]byte{0})
|
|
if s.Frame != nil {
|
|
_, _ = h.Write([]byte(s.Frame.ActionKey))
|
|
}
|
|
for _, a := range s.UnresolvedAnaphora {
|
|
_, _ = h.Write([]byte{1})
|
|
_, _ = h.Write([]byte(a))
|
|
}
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
func normalizeSegmentText(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
return strings.Join(strings.Fields(s), " ")
|
|
}
|
|
|
|
func uniqueStrings(in []string) []string {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
seen := map[string]struct{}{}
|
|
var out []string
|
|
for _, s := range in {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[s]; ok {
|
|
continue
|
|
}
|
|
seen[s] = struct{}{}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func collectAnaphora(text string) []string {
|
|
markers := []string{"这个", "那个", "它", "其", "上述", "刚才的", "该结果", "这份"}
|
|
var out []string
|
|
for _, m := range markers {
|
|
if strings.Contains(text, m) {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|