Files
weichen b5a782bb38 feat(chat): dialogue triggers for comments/reopen/pending-supplements with mistouch guards
Wire intent→coordinator DocResolver confirm cards→action_executor kinds for comment, reopen, pending-supplements query, and real submit-review execution.
2026-09-25 20:14:10 +00:00

409 lines
11 KiB
Go
Raw Permalink 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 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
}