Files
weichen 016135d946 feat(intentflow): wire U9–U12 live adapters for multi-intent closeout
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.
2026-09-23 15:26:46 +00:00

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
}