Wire intent→coordinator DocResolver confirm cards→action_executor kinds for comment, reopen, pending-supplements query, and real submit-review execution.
409 lines
11 KiB
Go
409 lines
11 KiB
Go
package intentflow
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"sync"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"chat-agent-system/internal/intent"
|
||
)
|
||
|
||
const (
|
||
SegmentProposalSchemaV1 = "intentflow.segment_proposal.v1"
|
||
frozenProject143 = int64(143)
|
||
)
|
||
|
||
var (
|
||
llmProposerMu sync.RWMutex
|
||
llmProposer SegmentProposer
|
||
)
|
||
|
||
var (
|
||
ErrSegmentProposalDisabled = errors.New("SEGMENT_PROPOSAL_DISABLED")
|
||
ErrSegmentProposalMalformed = errors.New("SEGMENT_PROPOSAL_MALFORMED")
|
||
ErrSegmentProposalTimeout = errors.New("SEGMENT_PROPOSAL_TIMEOUT")
|
||
ErrSegmentProposalOutOfBounds = errors.New("SEGMENT_PROPOSAL_OUT_OF_BOUNDS")
|
||
ErrSegmentRulesDecided = errors.New("SEGMENT_RULES_DECIDED")
|
||
)
|
||
|
||
// SegmentSpan is a half-open byte range into the original utterance.
|
||
type SegmentSpan struct {
|
||
Start int `json:"start"`
|
||
End int `json:"end"`
|
||
Text string `json:"text,omitempty"`
|
||
}
|
||
|
||
// SegmentProposalRequest is the minimal U9 input.
|
||
type SegmentProposalRequest struct {
|
||
Text string
|
||
RequestDigest string
|
||
MaxSegments int
|
||
SchemaVersion string
|
||
}
|
||
|
||
// SegmentProposal is a versioned, digest-bound suggestion.
|
||
type SegmentProposal struct {
|
||
SchemaVersion string `json:"schema_version"`
|
||
Digest string `json:"digest"`
|
||
Spans []SegmentSpan `json:"spans"`
|
||
}
|
||
|
||
// SegmentProposer proposes spans only when rules cannot decide (U9).
|
||
type SegmentProposer interface {
|
||
Propose(ctx context.Context, req SegmentProposalRequest) (*SegmentProposal, error)
|
||
}
|
||
|
||
type disabledSegmentProposer struct{}
|
||
|
||
func (disabledSegmentProposer) Propose(context.Context, SegmentProposalRequest) (*SegmentProposal, error) {
|
||
return nil, ErrSegmentProposalDisabled
|
||
}
|
||
|
||
// SetLLMSegmentProposer injects the product-PI proposer from the composition root.
|
||
func SetLLMSegmentProposer(p SegmentProposer) {
|
||
llmProposerMu.Lock()
|
||
llmProposer = p
|
||
llmProposerMu.Unlock()
|
||
}
|
||
|
||
// RulesCanDecide reports that U2 deterministic splits already cover the utterance.
|
||
func RulesCanDecide(text string) bool {
|
||
segs := intent.SplitPlanSegments(text)
|
||
if len(segs) >= 2 {
|
||
return true
|
||
}
|
||
if len(segs) == 1 && segs[0].Evidence == "internal_chain" {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
type ruleGatedProposer struct {
|
||
inner SegmentProposer
|
||
}
|
||
|
||
func (r ruleGatedProposer) Propose(ctx context.Context, req SegmentProposalRequest) (*SegmentProposal, error) {
|
||
if RulesCanDecide(req.Text) {
|
||
return nil, ErrSegmentRulesDecided
|
||
}
|
||
if r.inner == nil {
|
||
return nil, ErrSegmentProposalMalformed
|
||
}
|
||
return r.inner.Propose(ctx, req)
|
||
}
|
||
|
||
// ActiveSegmentProposer returns disabled when MULTI_INTENT_U9_SEGMENT is off.
|
||
// Flag on: rule splits skip LLM; otherwise the injected PI proposer or stub.
|
||
func ActiveSegmentProposer() SegmentProposer {
|
||
if !CurrentFeatureFlags().U9Segment {
|
||
return disabledSegmentProposer{}
|
||
}
|
||
llmProposerMu.RLock()
|
||
inner := llmProposer
|
||
llmProposerMu.RUnlock()
|
||
if inner == nil {
|
||
inner = NewStubSegmentProposer(0)
|
||
}
|
||
return ruleGatedProposer{inner: inner}
|
||
}
|
||
|
||
// StubSegmentProposer is scaffolding (no LLM): schema/span validation + timeout fail-closed.
|
||
type StubSegmentProposer struct {
|
||
ArtificialDelay time.Duration
|
||
}
|
||
|
||
func NewStubSegmentProposer(delay time.Duration) *StubSegmentProposer {
|
||
return &StubSegmentProposer{ArtificialDelay: delay}
|
||
}
|
||
|
||
func (s *StubSegmentProposer) Propose(ctx context.Context, req SegmentProposalRequest) (*SegmentProposal, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return nil, ErrSegmentProposalTimeout
|
||
}
|
||
if s != nil && s.ArtificialDelay > 0 {
|
||
timer := time.NewTimer(s.ArtificialDelay)
|
||
defer timer.Stop()
|
||
select {
|
||
case <-ctx.Done():
|
||
return nil, ErrSegmentProposalTimeout
|
||
case <-timer.C:
|
||
}
|
||
}
|
||
if err := ctx.Err(); err != nil {
|
||
return nil, ErrSegmentProposalTimeout
|
||
}
|
||
|
||
schema := req.SchemaVersion
|
||
if schema == "" {
|
||
schema = SegmentProposalSchemaV1
|
||
}
|
||
if schema != SegmentProposalSchemaV1 {
|
||
return nil, ErrSegmentProposalMalformed
|
||
}
|
||
text := req.Text
|
||
if text == "" || !utf8.ValidString(text) {
|
||
return nil, ErrSegmentProposalMalformed
|
||
}
|
||
maxSeg := req.MaxSegments
|
||
if maxSeg <= 0 {
|
||
maxSeg = 4
|
||
}
|
||
if maxSeg > 4 || req.RequestDigest == "" {
|
||
return nil, ErrSegmentProposalMalformed
|
||
}
|
||
|
||
span := SegmentSpan{Start: 0, End: len(text), Text: text}
|
||
if err := ValidateProposedSpans(text, []SegmentSpan{span}); err != nil {
|
||
return nil, err
|
||
}
|
||
return &SegmentProposal{
|
||
SchemaVersion: schema,
|
||
Digest: fmt.Sprintf("stub:%s", req.RequestDigest),
|
||
Spans: []SegmentSpan{span},
|
||
}, nil
|
||
}
|
||
|
||
// ValidateProposedSpans rejects out-of-bounds or overlapping spans.
|
||
func ValidateProposedSpans(text string, spans []SegmentSpan) error {
|
||
if len(spans) == 0 {
|
||
return ErrSegmentProposalMalformed
|
||
}
|
||
n := len(text)
|
||
prevEnd := 0
|
||
for _, sp := range spans {
|
||
if sp.Start < 0 || sp.End < sp.Start || sp.End > n {
|
||
return ErrSegmentProposalOutOfBounds
|
||
}
|
||
if sp.Start < prevEnd {
|
||
return ErrSegmentProposalOutOfBounds
|
||
}
|
||
if sp.Text != "" && sp.Text != text[sp.Start:sp.End] {
|
||
return ErrSegmentProposalMalformed
|
||
}
|
||
prevEnd = sp.End
|
||
}
|
||
return nil
|
||
}
|
||
|
||
var mutateActionKeys = map[string]struct{}{
|
||
"project.fact.supplement": {},
|
||
}
|
||
|
||
var docGovActionKeys = map[string]struct{}{
|
||
"document.claim": {},
|
||
"document.review.submit": {},
|
||
"document.review.approve": {},
|
||
"document.review.reject": {},
|
||
"document.final.approve": {},
|
||
"document.final.reject": {},
|
||
"document.proposal.decide": {},
|
||
"document.comment.add": {},
|
||
"document.comment.resolve": {},
|
||
"document.reopen.request": {},
|
||
"document.reopen.approve": {},
|
||
"document.pending_supplements.query": {},
|
||
}
|
||
|
||
var runControlActionKeys = map[string]struct{}{
|
||
"workflow.switch": {},
|
||
"workflow.run": {},
|
||
"run.resume": {},
|
||
"run.restart": {},
|
||
}
|
||
|
||
// DocGovLiveActionKeys are the U11 keys wired to the real docgov engine.
|
||
// 基线键(claim / review.submit)随 U11 总 flag 放量;P8 逐键键(review approve/reject、
|
||
// final.*、proposal.decide)须额外打开对应 LIVE_* flag 才进入 live 集合。
|
||
// 2026-09-25 三权流转对话触发键(comment/reopen/pending)随基线集合接入,
|
||
// 同样受 U11 总 flag 控制。
|
||
func DocGovLiveActionKeys() map[string]struct{} {
|
||
keys := map[string]struct{}{
|
||
"document.claim": {},
|
||
"document.review.submit": {},
|
||
"document.comment.add": {},
|
||
"document.comment.resolve": {},
|
||
"document.reopen.request": {},
|
||
"document.reopen.approve": {},
|
||
"document.pending_supplements.query": {},
|
||
}
|
||
f := CurrentFeatureFlags()
|
||
if f.U11LiveReview {
|
||
keys["document.review.approve"] = struct{}{}
|
||
keys["document.review.reject"] = struct{}{}
|
||
}
|
||
if f.U11LiveFinal {
|
||
keys["document.final.approve"] = struct{}{}
|
||
keys["document.final.reject"] = struct{}{}
|
||
}
|
||
if f.U11LiveProposal {
|
||
keys["document.proposal.decide"] = struct{}{}
|
||
}
|
||
return keys
|
||
}
|
||
|
||
// RunControlLiveActionKeys are the U12 keys wired to the real run-control adapters.
|
||
// 基线键(workflow.run / run.resume)随 U12 总 flag 放量;P8 逐键键(workflow.switch、
|
||
// run.restart)须额外打开对应 LIVE_* flag 才进入 live 集合。
|
||
func RunControlLiveActionKeys() map[string]struct{} {
|
||
keys := map[string]struct{}{
|
||
"workflow.run": {},
|
||
"run.resume": {},
|
||
}
|
||
f := CurrentFeatureFlags()
|
||
if f.U12LiveSwitch {
|
||
keys["workflow.switch"] = struct{}{}
|
||
}
|
||
if f.U12LiveRestart {
|
||
keys["run.restart"] = struct{}{}
|
||
}
|
||
return keys
|
||
}
|
||
|
||
// IsDocGovLiveKey reports whether the docgov key routes to the live engine
|
||
// under current flags (used by adapters and tests).
|
||
func IsDocGovLiveKey(key string) bool {
|
||
_, ok := DocGovLiveActionKeys()[key]
|
||
return ok
|
||
}
|
||
|
||
// IsRunControlLiveKey reports whether the run-control key routes to the live
|
||
// adapter under current flags.
|
||
func IsRunControlLiveKey(key string) bool {
|
||
_, ok := RunControlLiveActionKeys()[key]
|
||
return ok
|
||
}
|
||
|
||
// LiveOwners carries production adapters from the composition root (U10–U12).
|
||
// Nil fields fall back to dry-run scaffolding when the corresponding flag is on.
|
||
type LiveOwners struct {
|
||
Mutate ActionOwner
|
||
DocGov ActionOwner
|
||
RunControl ActionOwner
|
||
}
|
||
|
||
// DryRunAdapter is a fail-closed / dry-run ActionOwner for U10–U12 scaffolding.
|
||
type DryRunAdapter struct {
|
||
mu sync.Mutex
|
||
allowed map[string]struct{}
|
||
receipts map[string]Receipt
|
||
Denied bool
|
||
Label string
|
||
RequireRev bool
|
||
}
|
||
|
||
func NewMutateDryRunAdapter() *DryRunAdapter {
|
||
return newDryRunAdapter("mutate", mutateActionKeys, true)
|
||
}
|
||
|
||
func NewDocGovDryRunAdapter() *DryRunAdapter {
|
||
return newDryRunAdapter("docgov", docGovActionKeys, true)
|
||
}
|
||
|
||
func NewRunControlDryRunAdapter() *DryRunAdapter {
|
||
return newDryRunAdapter("run_control", runControlActionKeys, false)
|
||
}
|
||
|
||
func newDryRunAdapter(label string, keys map[string]struct{}, requireRev bool) *DryRunAdapter {
|
||
cp := make(map[string]struct{}, len(keys))
|
||
for k := range keys {
|
||
cp[k] = struct{}{}
|
||
}
|
||
return &DryRunAdapter{
|
||
allowed: cp,
|
||
receipts: map[string]Receipt{},
|
||
Label: label,
|
||
RequireRev: requireRev,
|
||
}
|
||
}
|
||
|
||
func (a *DryRunAdapter) allows(key string) bool {
|
||
if a == nil {
|
||
return false
|
||
}
|
||
_, ok := a.allowed[key]
|
||
return ok
|
||
}
|
||
|
||
func (a *DryRunAdapter) Authorize(_ context.Context, exec StepExec) error {
|
||
if a == nil || a.Denied || !a.allows(exec.ActionKey) {
|
||
return ErrPlanActionForbidden
|
||
}
|
||
if a.Label == "docgov" && exec.Principal.ProjectID == frozenProject143 {
|
||
return ErrPlanActionForbidden
|
||
}
|
||
if a.RequireRev && exec.ExpectedResourceVersion == "" {
|
||
return ErrPlanActionVersionConflict
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *DryRunAdapter) Execute(ctx context.Context, exec StepExec) (Receipt, error) {
|
||
if err := a.Authorize(ctx, exec); err != nil {
|
||
return Receipt{}, err
|
||
}
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
if existing, ok := a.receipts[exec.IdempotencyKey]; ok {
|
||
if existing.ActionDigest != exec.ActionDigest {
|
||
return Receipt{}, ErrPlanDecisionConflict
|
||
}
|
||
return existing, nil
|
||
}
|
||
rec := Receipt{
|
||
IdempotencyKey: exec.IdempotencyKey,
|
||
ActionDigest: exec.ActionDigest,
|
||
ResultRef: "dryrun:" + a.Label + ":" + exec.StepID,
|
||
ResultDigest: "validated_only:" + exec.ActionDigest,
|
||
SafeSummary: a.Label + " dry-run accepted",
|
||
}
|
||
a.receipts[exec.IdempotencyKey] = rec
|
||
return rec, nil
|
||
}
|
||
|
||
func (a *DryRunAdapter) LookupReceipt(_ context.Context, idempotencyKey string) (Receipt, bool, error) {
|
||
if a == nil {
|
||
return Receipt{}, false, nil
|
||
}
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
rec, ok := a.receipts[idempotencyKey]
|
||
return rec, ok, nil
|
||
}
|
||
|
||
// BuildOwnerMux registers U10–U12 owners when their flags are on.
|
||
// Pass LiveOwners via BuildOwnerMuxWithLive for production adapters.
|
||
func BuildOwnerMux(query, task ActionOwner) OwnerMux {
|
||
return BuildOwnerMuxWithLive(query, task, LiveOwners{})
|
||
}
|
||
|
||
// BuildOwnerMuxWithLive wires optional live adapters from the composition root.
|
||
func BuildOwnerMuxWithLive(query, task ActionOwner, live LiveOwners) OwnerMux {
|
||
m := OwnerMux{Query: query, Task: task}
|
||
f := CurrentFeatureFlags()
|
||
if f.U10Mutate {
|
||
if live.Mutate != nil {
|
||
m.Mutate = live.Mutate
|
||
} else {
|
||
m.Mutate = NewMutateDryRunAdapter()
|
||
}
|
||
}
|
||
if f.U11DocGov {
|
||
if live.DocGov != nil {
|
||
m.DocGov = live.DocGov
|
||
} else {
|
||
m.DocGov = NewDocGovDryRunAdapter()
|
||
}
|
||
}
|
||
if f.U12RunControl {
|
||
if live.RunControl != nil {
|
||
m.RunControl = live.RunControl
|
||
} else {
|
||
m.RunControl = NewRunControlDryRunAdapter()
|
||
}
|
||
}
|
||
return m
|
||
}
|