merge: candidate intent-plan-u9u12-live into local_dev

This commit is contained in:
weichen
2026-09-23 15:36:48 +00:00
16 changed files with 1263 additions and 29 deletions
@@ -0,0 +1,43 @@
---
status: in_progress
write_scope: internal/app,intentflow,internal/sysadmin,.doc/ops/short-term
mutex: intent-plan
base_dev_sha: 4e601e95
review_state: self-reviewed
integration_state: pending
deployment: not_run
cleanup: pending
remote_sync: not_pushed
owner: Cursor
task_id: intent-plan-u9u12-live-2026-09-24
---
# intent-plan U9–U12 live 接线(Track I3)
## 目标
- **U9**:`HandleTurn` 在 `RulesCanDecide==false` 且 elevated tier 时调用 `ActiveSegmentProposer`;失败 fail-closed;成功用 LLM spans + 确定性识别构建计划(`segmenter.v2.llm`)。
- **U10**:`project.fact.supplement` → `MutateOwner`(`ctxmgr.UpdateProjectBlackboard`,与 SupplementInfo 同源黑板)。
- **U11**:`document.claim`、`document.review.submit` → `DocGovOwner`(docgov 引擎);其余 docgov key 仍 dry-run。
- **U12**:`workflow.run`、`run.resume` → `RunControlOwner`(projectworkflow + taskrun);其余仍 dry-run。
## 验证
```bash
./scripts/arch-check.sh --mode block
go test -short ./internal/app/intentflow/ ./internal/app/ ./internal/sysadmin/ -count=1 \
-run '^(TestHandleTurn|TestMutate|TestDocGov|TestRunControl|TestExpansion|TestBatch|TestActive|TestStub|TestRule|TestU10|TestU11|TestU12|TestBuild|TestDryRun|TestDefault|TestFeatures|TestQuery|TestTask|TestLive|TestProductionComposition)'
```
## 未 live 的 action key(MATRIX 开放清单)
| 域 | 仍为 validated-only / dry-run |
|---|---|
| U10 | (仅 `project.fact.supplement` live;无其它 mutate key) |
| U11 | `document.review.approve/reject`, `document.final.approve/reject`, `document.proposal.decide` |
| U12 | `workflow.switch`, `run.restart` |
## 收口
- 合入:`agentctl validate` → `queue enqueue` 或 integrate
- FEATURE-MATRIX:由父任务更新
+25 -9
View File
@@ -286,18 +286,25 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
livePI := cfg != nil && strings.TrimSpace(cfg.PiAgentRunnerURL) != ""
taskOwner := NewTaskRunAdapter()
taskOwner.Starter = LiveTaskStarter(taskMgr, livePI)
queryOwner := NewQueryOwner(func(ctx context.Context, projectID, userID int64) error {
if rt.Authorizer == nil {
return intentflow.ErrPlanActionForbidden
}
return rt.Authorizer.RequireProjectAccess(ctx, projectID, userID)
}, parts.KB)
// BuildOwnerMux registers U10–U12 dry-run stubs only when independent flags are on.
// Production query path uses QueryOwner (not FakeActionOwner).
queryOwner := NewQueryOwner(projectAccess(rt), parts.KB)
pflags := intentflow.CurrentFeatureFlags()
liveOwners := intentflow.LiveOwners{}
if pflags.U10Mutate {
liveOwners.Mutate = NewMutateOwner(projectAccess(rt), projectBlackboardPort(pool, parts.Ctx))
}
if pflags.U11DocGov {
liveOwners.DocGov = NewDocGovOwner(parts.Gov, projectAccess(rt), nil)
}
if pflags.U12RunControl {
rc := NewRunControlOwner(projectAccess(rt), pwSvc, taskMgr)
rc.ProjectName = projectNameLookup(pool)
liveOwners.RunControl = rc
}
rt.IntentPlans = intentflow.NewPlans(rt.Docflow.UoW, intentflow.NewRepository(), intentflow.Options{
Owner: intentflow.BuildOwnerMux(
Owner: intentflow.BuildOwnerMuxWithLive(
intentflow.QueryGuard{Inner: queryOwner},
taskOwner,
liveOwners,
),
Events: events.PGSink{},
})
@@ -362,6 +369,15 @@ func sysadminFeatureValues(cfg *config.Config) func() map[string]string {
}
}
func projectAccess(rt *Runtime) ProjectAccess {
return func(ctx context.Context, projectID, userID int64) error {
if rt == nil || rt.Authorizer == nil {
return intentflow.ErrPlanActionForbidden
}
return rt.Authorizer.RequireProjectAccess(ctx, projectID, userID)
}
}
func formatProjectAllowlist(ids []int64) string {
if len(ids) == 0 {
return ""
+167
View File
@@ -0,0 +1,167 @@
package app
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"sync"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/docgov"
)
const frozenProject143 = int64(143)
// DocGovRoleResolver maps a user to a docgov role for a document action.
type DocGovRoleResolver func(ctx context.Context, docID string, userID int64) docgov.UserRole
// DocGovOwner executes live U11 keys via the docgov engine; other keys stay dry-run.
type DocGovOwner struct {
Gov *docgov.GovernanceEngine
Access ProjectAccess
Resolve DocGovRoleResolver
Denied bool
dryRun *intentflow.DryRunAdapter
mu sync.Mutex
receipts map[string]intentflow.Receipt
}
func NewDocGovOwner(gov *docgov.GovernanceEngine, access ProjectAccess, resolve DocGovRoleResolver) *DocGovOwner {
return &DocGovOwner{
Gov: gov,
Access: access,
Resolve: resolve,
dryRun: intentflow.NewDocGovDryRunAdapter(),
receipts: map[string]intentflow.Receipt{},
}
}
func (d *DocGovOwner) isLiveKey(key string) bool {
_, ok := intentflow.DocGovLiveActionKeys[key]
return ok
}
func (d *DocGovOwner) Authorize(ctx context.Context, exec intentflow.StepExec) error {
if d == nil || d.Denied {
return intentflow.ErrPlanActionForbidden
}
if !d.isLiveKey(exec.ActionKey) {
return d.dryRun.Authorize(ctx, exec)
}
if exec.Principal.UserID <= 0 || exec.Principal.ProjectID <= 0 || exec.Principal.SessionID == "" {
return intentflow.ErrPlanActionForbidden
}
if exec.Principal.ProjectID == frozenProject143 {
return intentflow.ErrPlanActionForbidden
}
if d.Access != nil {
if err := d.Access(ctx, exec.Principal.ProjectID, exec.Principal.UserID); err != nil {
return intentflow.ErrPlanActionForbidden
}
}
if exec.ExpectedResourceVersion == "" {
return intentflow.ErrPlanActionVersionConflict
}
docID := docIDFromExec(exec)
if docID == "" || d.Gov == nil {
return intentflow.ErrPlanActionForbidden
}
doc, err := d.Gov.GetDocument(ctx, docID)
if err != nil || doc == nil {
return intentflow.ErrPlanActionForbidden
}
if docVersion(doc) != exec.ExpectedResourceVersion {
return intentflow.ErrPlanActionVersionConflict
}
return nil
}
func (d *DocGovOwner) Execute(ctx context.Context, exec intentflow.StepExec) (intentflow.Receipt, error) {
if !d.isLiveKey(exec.ActionKey) {
return d.dryRun.Execute(ctx, exec)
}
if err := d.Authorize(ctx, exec); err != nil {
return intentflow.Receipt{}, err
}
d.mu.Lock()
if rec, ok := d.receipts[exec.IdempotencyKey]; ok {
d.mu.Unlock()
if rec.ActionDigest != exec.ActionDigest {
return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict
}
return rec, nil
}
d.mu.Unlock()
docID := docIDFromExec(exec)
role := docgov.RoleAuthor
if d.Resolve != nil {
role = d.Resolve(ctx, docID, exec.Principal.UserID)
}
name := "user"
var updated *docgov.DocumentRecord
var err error
switch exec.ActionKey {
case "document.claim":
updated, err = d.Gov.RecordDocumentViewed(ctx, docID, exec.Principal.UserID, role, name)
case "document.review.submit":
updated, err = d.Gov.SubmitForReview(ctx, docID, exec.Principal.UserID, role, name)
default:
return intentflow.Receipt{}, intentflow.ErrPlanActionForbidden
}
if err != nil {
return intentflow.Receipt{}, err
}
rec := intentflow.Receipt{
IdempotencyKey: exec.IdempotencyKey,
ActionDigest: exec.ActionDigest,
ResultRef: "docgov:" + docID,
ResultDigest: fmt.Sprintf("docgov:%s:ver=%s", exec.ActionKey, docVersion(updated)),
SafeSummary: exec.ActionKey + " accepted",
}
d.mu.Lock()
defer d.mu.Unlock()
if existing, ok := d.receipts[exec.IdempotencyKey]; ok {
if existing.ActionDigest != exec.ActionDigest {
return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict
}
return existing, nil
}
d.receipts[exec.IdempotencyKey] = rec
return rec, nil
}
func (d *DocGovOwner) LookupReceipt(ctx context.Context, idempotencyKey string) (intentflow.Receipt, bool, error) {
if d == nil {
return intentflow.Receipt{}, false, nil
}
d.mu.Lock()
rec, ok := d.receipts[idempotencyKey]
d.mu.Unlock()
if ok {
return rec, true, nil
}
return d.dryRun.LookupReceipt(ctx, idempotencyKey)
}
func docIDFromExec(exec intentflow.StepExec) string {
if len(exec.Slots) > 0 {
var slots map[string]string
if json.Unmarshal(exec.Slots, &slots) == nil {
if id := strings.TrimSpace(slots["doc_id"]); id != "" {
return id
}
}
}
return strings.TrimSpace(exec.SafeSummary)
}
func docVersion(doc *docgov.DocumentRecord) string {
if doc == nil {
return ""
}
return strconv.Itoa(doc.Version)
}
+81
View File
@@ -0,0 +1,81 @@
package app
import (
"context"
"testing"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/docgov"
)
func TestDocGovOwnerProject143Forbidden(t *testing.T) {
engine := docgov.NewGovernanceEngine(nil)
ctx := context.Background()
doc, err := engine.CreateOrGetDocument(ctx, "doc_test_143", 99, "intake", "t", "V0.5", "", 1, "A", 2, "R", 3, "P")
if err != nil {
t.Fatal(err)
}
owner := NewDocGovOwner(engine, nil, nil)
_, err = owner.Execute(ctx, intentflow.StepExec{
ActionKey: "document.claim",
IdempotencyKey: "143-deny", ActionDigest: "d",
ExpectedResourceVersion: "1",
Principal: intentflow.Principal{UserID: 1, ProjectID: 143, SessionID: "s"},
SafeSummary: doc.DocID,
})
if err != intentflow.ErrPlanActionForbidden {
t.Fatalf("project 143 must stay read-only, got %v", err)
}
}
func TestDocGovOwnerMissingVersionConflict(t *testing.T) {
engine := docgov.NewGovernanceEngine(nil)
ctx := context.Background()
doc, err := engine.CreateOrGetDocument(ctx, "doc_ver_test", 5, "intake", "t", "V0.5", "", 1, "A", 2, "R", 3, "P")
if err != nil {
t.Fatal(err)
}
owner := NewDocGovOwner(engine, func(context.Context, int64, int64) error { return nil }, nil)
_, err = owner.Execute(ctx, intentflow.StepExec{
ActionKey: "document.claim",
IdempotencyKey: "ver-miss", ActionDigest: "d",
ExpectedResourceVersion: "",
Principal: intentflow.Principal{UserID: 1, ProjectID: 5, SessionID: "s"},
SafeSummary: doc.DocID,
})
if err != intentflow.ErrPlanActionVersionConflict {
t.Fatalf("missing version: %v", err)
}
_, err = owner.Execute(ctx, intentflow.StepExec{
ActionKey: "document.claim",
IdempotencyKey: "ver-stale", ActionDigest: "d",
ExpectedResourceVersion: "999",
Principal: intentflow.Principal{UserID: 1, ProjectID: 5, SessionID: "s"},
SafeSummary: doc.DocID,
})
if err != intentflow.ErrPlanActionVersionConflict {
t.Fatalf("stale version: %v", err)
}
}
func TestDocGovOwnerAuthNegative(t *testing.T) {
engine := docgov.NewGovernanceEngine(nil)
ctx := context.Background()
doc, err := engine.CreateOrGetDocument(ctx, "doc_auth", 5, "intake", "t", "V0.5", "", 1, "A", 2, "R", 3, "P")
if err != nil {
t.Fatal(err)
}
owner := NewDocGovOwner(engine, func(context.Context, int64, int64) error {
return intentflow.ErrPlanActionForbidden
}, nil)
_, err = owner.Execute(ctx, intentflow.StepExec{
ActionKey: "document.claim",
IdempotencyKey: "auth-no", ActionDigest: "d",
ExpectedResourceVersion: "1",
Principal: intentflow.Principal{UserID: 9, ProjectID: 5, SessionID: "s"},
SafeSummary: doc.DocID,
})
if err != intentflow.ErrPlanActionForbidden {
t.Fatalf("auth negative: %v", err)
}
}
+36 -4
View File
@@ -209,6 +209,20 @@ var runControlActionKeys = map[string]struct{}{
"run.restart": {},
}
// DocGovLiveActionKeys are the first U11 keys wired to the real docgov engine.
var DocGovLiveActionKeys = map[string]struct{}{
"document.claim": {},
"document.review.submit": {},
}
// 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
@@ -298,18 +312,36 @@ func (a *DryRunAdapter) LookupReceipt(_ context.Context, idempotencyKey string)
return rec, ok, nil
}
// BuildOwnerMux registers U10–U12 dry-run owners only when their flags are on.
// 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 {
m.Mutate = NewMutateDryRunAdapter()
if live.Mutate != nil {
m.Mutate = live.Mutate
} else {
m.Mutate = NewMutateDryRunAdapter()
}
}
if f.U11DocGov {
m.DocGov = NewDocGovDryRunAdapter()
if live.DocGov != nil {
m.DocGov = live.DocGov
} else {
m.DocGov = NewDocGovDryRunAdapter()
}
}
if f.U12RunControl {
m.RunControl = NewRunControlDryRunAdapter()
if live.RunControl != nil {
m.RunControl = live.RunControl
} else {
m.RunControl = NewRunControlDryRunAdapter()
}
}
return m
}
+14
View File
@@ -114,6 +114,20 @@ func defaultBatchRows() []ActionBatchPolicy {
AllowedModes: []ExecutionMode{ModeValidatedOnly, ModeLive},
})
}
for _, key := range []string{
"project.fact.supplement",
"document.claim",
"document.review.submit",
"workflow.run",
"run.resume",
} {
add(ActionBatchPolicy{
ActionKey: key,
RequiresBatchConfirm: true,
OpenFromTier: TierConfirm,
AllowedModes: []ExecutionMode{ModeValidatedOnly, ModeLive},
})
}
add(ActionBatchPolicy{
ActionKey: "security.forbidden",
RejectPlan: true,
+35 -6
View File
@@ -35,13 +35,46 @@ func (p *Plans) handleTurnElevated(ctx context.Context, in TurnInput) (*TurnResu
if text == "" {
return &TurnResult{}, nil
}
digest := in.RequestDigest
if digest == "" {
digest = shortDigest(text)
}
rec := intent.NewRecognizer()
res, err := rec.Recognize(ctx, text, nil)
if err != nil || res == nil || res.PlanProposal == nil {
if err != nil || res == nil {
recordCounterfactual(cfPassthrough)
return &TurnResult{}, nil
}
var proposal *intent.PlanProposal
// U9 live segment proposals require elevated tier ≥ auto_read (plan Track I3).
if flags.U9Segment && !RulesCanDecide(text) && tier.Allows(TierAutoRead) {
sp, propErr := ActiveSegmentProposer().Propose(ctx, SegmentProposalRequest{
Text: text,
RequestDigest: digest,
MaxSegments: 4,
})
if propErr != nil {
return &TurnResult{}, nil
}
if sp == nil || len(sp.Spans) < 2 || len(sp.Spans) > 4 {
return &TurnResult{}, nil
}
if err := ValidateProposedSpans(text, sp.Spans); err != nil {
return &TurnResult{}, nil
}
proposal = buildPlanFromLLMSpans(ctx, text, res, sp.Spans)
if proposal == nil || len(proposal.Segments) < 2 {
return &TurnResult{}, nil
}
} else {
proposal = res.PlanProposal
}
if proposal == nil {
recordCounterfactual(cfPassthrough)
return &TurnResult{}, nil
}
proposal := res.PlanProposal
route := counterfactualRoute(proposal)
recordCounterfactual(route)
@@ -96,10 +129,6 @@ func (p *Plans) handleTurnElevated(ctx context.Context, in TurnInput) (*TurnResu
if turnID == "" {
turnID = "turn-" + shortDigest(text)
}
digest := in.RequestDigest
if digest == "" {
digest = shortDigest(text)
}
status := PlanAwaitingConfirmation
mode := ModeLive
+97
View File
@@ -0,0 +1,97 @@
package intentflow_test
import (
"context"
"testing"
"chat-agent-system/internal/app/intentflow"
)
type stubSpanProposer struct {
spans []intentflow.SegmentSpan
err error
}
func (s stubSpanProposer) Propose(context.Context, intentflow.SegmentProposalRequest) (*intentflow.SegmentProposal, error) {
if s.err != nil {
return nil, s.err
}
return &intentflow.SegmentProposal{
SchemaVersion: intentflow.SegmentProposalSchemaV1,
Digest: "stub-digest",
Spans: s.spans,
}, nil
}
func TestHandleTurnU9FailClosedOnMalformedProposal(t *testing.T) {
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
PlanTier: intentflow.TierAutoRead, U9Segment: true, ProjectAllowlist: []int64{42},
})
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
intentflow.SetLLMSegmentProposer(stubSpanProposer{err: intentflow.ErrSegmentProposalMalformed})
t.Cleanup(func() { intentflow.SetLLMSegmentProposer(nil) })
p, _, _ := testPlans()
res, err := p.HandleTurn(context.Background(), intentflow.TurnInput{
Principal: principal(), Text: "帮我查法规再查案例",
ClientTurnID: "u9-fail", RequestDigest: "u9-fail-d",
})
if err != nil {
t.Fatal(err)
}
if res.Plan != nil {
t.Fatalf("malformed U9 must fail closed: %+v", res.Plan)
}
}
func TestHandleTurnU9SkipsLLMWhenRulesDecide(t *testing.T) {
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
PlanTier: intentflow.TierAutoRead, U9Segment: true, ProjectAllowlist: []int64{42},
})
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
intentflow.SetLLMSegmentProposer(stubSpanProposer{
spans: []intentflow.SegmentSpan{{Start: 0, End: 1, Text: "x"}},
})
t.Cleanup(func() { intentflow.SetLLMSegmentProposer(nil) })
p, _, _ := testPlans()
res, err := p.HandleTurn(context.Background(), intentflow.TurnInput{
Principal: principal(), Text: "查一下项目风险,然后查相关法规",
ClientTurnID: "u9-rules", RequestDigest: "u9-rules-d",
})
if err != nil {
t.Fatal(err)
}
if res == nil || res.Plan == nil {
t.Fatal("rules-decided connector split must still plan")
}
}
func TestHandleTurnU9AllowlistStillGates(t *testing.T) {
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
PlanTier: intentflow.TierAutoRead, U9Segment: true,
})
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
intentflow.ResetMetricsForTest()
text := "帮我查法规再查案例"
intentflow.SetLLMSegmentProposer(stubSpanProposer{spans: []intentflow.SegmentSpan{
{Start: 0, End: 15, Text: "帮我查法规"},
{Start: 15, End: 27, Text: "再查案例"},
}})
t.Cleanup(func() { intentflow.SetLLMSegmentProposer(nil) })
p, _, _ := testPlans()
res, err := p.HandleTurn(context.Background(), intentflow.TurnInput{
Principal: principal(), Text: text,
ClientTurnID: "u9-allow", RequestDigest: "u9-allow-d",
})
if err != nil {
t.Fatal(err)
}
if res.Plan != nil {
t.Fatalf("empty allowlist must deny U9 elevated tier: %+v", res.Plan)
}
if intentflow.Metrics().Snapshot().AllowlistDenied < 1 {
t.Fatal("expected allowlist_denied metric")
}
}
+220
View File
@@ -0,0 +1,220 @@
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
}
+145
View File
@@ -0,0 +1,145 @@
package app
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"chat-agent-system/internal/app/intentflow"
)
const mutateActionKey = "project.fact.supplement"
// ProjectBlackboardPort reads/writes the same project blackboard as SupplementInfo.
type ProjectBlackboardPort struct {
CurrentVersion func(ctx context.Context, projectID int64) (string, error)
MergeFacts func(ctx context.Context, projectID int64, facts map[string]string) (newVersion string, err error)
}
// MutateOwner is the U10 live adapter for project.fact.supplement only.
type MutateOwner struct {
Access ProjectAccess
Blackboard ProjectBlackboardPort
Denied bool
mu sync.Mutex
receipts map[string]intentflow.Receipt
}
func NewMutateOwner(access ProjectAccess, bb ProjectBlackboardPort) *MutateOwner {
return &MutateOwner{
Access: access,
Blackboard: bb,
receipts: map[string]intentflow.Receipt{},
}
}
func (m *MutateOwner) Authorize(ctx context.Context, exec intentflow.StepExec) error {
if m == nil || m.Denied {
return intentflow.ErrPlanActionForbidden
}
if exec.ActionKey != mutateActionKey {
return intentflow.ErrPlanActionForbidden
}
if exec.Principal.UserID <= 0 || exec.Principal.ProjectID <= 0 || exec.Principal.SessionID == "" {
return intentflow.ErrPlanActionForbidden
}
if m.Access != nil {
if err := m.Access(ctx, exec.Principal.ProjectID, exec.Principal.UserID); err != nil {
return intentflow.ErrPlanActionForbidden
}
}
if exec.ExpectedResourceVersion == "" {
return intentflow.ErrPlanActionVersionConflict
}
if m.Blackboard.CurrentVersion != nil {
got, err := m.Blackboard.CurrentVersion(ctx, exec.Principal.ProjectID)
if err != nil {
return err
}
if got != exec.ExpectedResourceVersion {
return intentflow.ErrPlanActionVersionConflict
}
}
return nil
}
func (m *MutateOwner) Execute(ctx context.Context, exec intentflow.StepExec) (intentflow.Receipt, error) {
if err := m.Authorize(ctx, exec); err != nil {
return intentflow.Receipt{}, err
}
m.mu.Lock()
if rec, ok := m.receipts[exec.IdempotencyKey]; ok {
m.mu.Unlock()
if rec.ActionDigest != exec.ActionDigest {
return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict
}
return rec, nil
}
m.mu.Unlock()
facts := supplementFactsFromExec(exec)
if len(facts) == 0 {
return intentflow.Receipt{}, intentflow.ErrPlanActionForbidden
}
newVer := exec.ExpectedResourceVersion
if m.Blackboard.MergeFacts != nil {
got, err := m.Blackboard.MergeFacts(ctx, exec.Principal.ProjectID, facts)
if err != nil {
return intentflow.Receipt{}, err
}
newVer = got
}
rec := intentflow.Receipt{
IdempotencyKey: exec.IdempotencyKey,
ActionDigest: exec.ActionDigest,
ResultRef: fmt.Sprintf("blackboard:%d", exec.Principal.ProjectID),
ResultDigest: fmt.Sprintf("supplement:ver=%s", newVer),
SafeSummary: "project facts supplemented",
}
m.mu.Lock()
defer m.mu.Unlock()
if existing, ok := m.receipts[exec.IdempotencyKey]; ok {
if existing.ActionDigest != exec.ActionDigest {
return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict
}
return existing, nil
}
m.receipts[exec.IdempotencyKey] = rec
return rec, nil
}
func (m *MutateOwner) LookupReceipt(_ context.Context, idempotencyKey string) (intentflow.Receipt, bool, error) {
if m == nil {
return intentflow.Receipt{}, false, nil
}
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.receipts[idempotencyKey]
return rec, ok, nil
}
func supplementFactsFromExec(exec intentflow.StepExec) map[string]string {
if len(exec.Slots) > 0 {
var slots map[string]string
if json.Unmarshal(exec.Slots, &slots) == nil && len(slots) > 0 {
out := map[string]string{}
for k, v := range slots {
if strings.TrimSpace(v) != "" {
out[k] = strings.TrimSpace(v)
}
}
if len(out) > 0 {
return out
}
}
}
text := strings.TrimSpace(exec.SafeSummary)
if text == "" {
return nil
}
return map[string]string{"supplement_doc": text}
}
+70
View File
@@ -0,0 +1,70 @@
package app
import (
"context"
"testing"
"chat-agent-system/internal/app/intentflow"
)
func TestMutateOwnerMissingRevisionConflict(t *testing.T) {
m := NewMutateOwner(nil, ProjectBlackboardPort{})
ctx := context.Background()
_, err := m.Execute(ctx, intentflow.StepExec{
ActionKey: "project.fact.supplement",
IdempotencyKey: "k1", ActionDigest: "d1",
Principal: intentflow.Principal{UserID: 1, ProjectID: 2, SessionID: "s"},
SafeSummary: "补充目标市场为国内",
})
if err != intentflow.ErrPlanActionVersionConflict {
t.Fatalf("want version conflict, got %v", err)
}
}
func TestMutateOwnerDigestReplay(t *testing.T) {
ver := "1"
m := NewMutateOwner(nil, ProjectBlackboardPort{
CurrentVersion: func(context.Context, int64) (string, error) { return ver, nil },
MergeFacts: func(context.Context, int64, map[string]string) (string, error) {
return "2", nil
},
})
ctx := context.Background()
exec := intentflow.StepExec{
ActionKey: "project.fact.supplement",
IdempotencyKey: "k2", ActionDigest: "d2",
ExpectedResourceVersion: ver,
Principal: intentflow.Principal{UserID: 1, ProjectID: 2, SessionID: "s"},
SafeSummary: "补充目标市场为国内",
}
first, err := m.Execute(ctx, exec)
if err != nil {
t.Fatal(err)
}
again, err := m.Execute(ctx, exec)
if err != nil || again.ResultDigest != first.ResultDigest {
t.Fatalf("idempotent replay: %v %+v", err, again)
}
exec.ActionDigest = "other"
if _, err := m.Execute(ctx, exec); err != intentflow.ErrPlanDecisionConflict {
t.Fatalf("digest mismatch: %v", err)
}
}
func TestMutateOwnerForbiddenWithoutAccess(t *testing.T) {
m := NewMutateOwner(func(context.Context, int64, int64) error {
return intentflow.ErrPlanActionForbidden
}, ProjectBlackboardPort{
CurrentVersion: func(context.Context, int64) (string, error) { return "1", nil },
})
_, err := m.Execute(context.Background(), intentflow.StepExec{
ActionKey: "project.fact.supplement",
IdempotencyKey: "k3", ActionDigest: "d3",
ExpectedResourceVersion: "1",
Principal: intentflow.Principal{UserID: 1, ProjectID: 2, SessionID: "s"},
SafeSummary: "补充材料",
})
if err != intentflow.ErrPlanActionForbidden {
t.Fatalf("want forbidden, got %v", err)
}
}
+60
View File
@@ -0,0 +1,60 @@
package app
import (
"context"
"strconv"
"chat-agent-system/internal/ctxmgr"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
func projectBlackboardPort(pool *pgxpool.Pool, ctxMgr *ctxmgr.ContextManager) ProjectBlackboardPort {
return ProjectBlackboardPort{
CurrentVersion: func(ctx context.Context, projectID int64) (string, error) {
if pool != nil {
var version int64
err := pool.QueryRow(ctx, `
SELECT COALESCE(version, 0) FROM project_blackboards WHERE project_id = $1
`, projectID).Scan(&version)
if err == pgx.ErrNoRows {
return "0", nil
}
if err != nil {
return "", err
}
return strconv.FormatInt(version, 10), nil
}
return "0", nil
},
MergeFacts: func(ctx context.Context, projectID int64, facts map[string]string) (string, error) {
if ctxMgr == nil {
return "0", nil
}
before, _ := projectBlackboardPort(pool, ctxMgr).CurrentVersion(ctx, projectID)
if err := ctxMgr.UpdateProjectBlackboard(ctx, projectID, facts); err != nil {
return "", err
}
after, err := projectBlackboardPort(pool, ctxMgr).CurrentVersion(ctx, projectID)
if err != nil {
return before, nil
}
return after, nil
},
}
}
func projectNameLookup(pool *pgxpool.Pool) func(context.Context, int64) (string, error) {
return func(ctx context.Context, projectID int64) (string, error) {
if pool == nil || projectID <= 0 {
return "", nil
}
var name string
err := pool.QueryRow(ctx, `SELECT COALESCE(name, '') FROM projects WHERE id = $1`, projectID).Scan(&name)
if err == pgx.ErrNoRows {
return "", nil
}
return name, err
}
}
+207
View File
@@ -0,0 +1,207 @@
package app
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/taskrun"
)
var runControlLiveKeys = map[string]struct{}{
"workflow.run": {},
"run.resume": {},
}
// RunControlOwner is the U12 live adapter for workflow.run and run.resume.
type RunControlOwner struct {
Access ProjectAccess
Workflow *projectworkflow.Service
TaskRuns *taskrun.Manager
ProjectName func(ctx context.Context, projectID int64) (string, error)
Denied bool
dryRun *intentflow.DryRunAdapter
mu sync.Mutex
receipts map[string]intentflow.Receipt
}
func NewRunControlOwner(access ProjectAccess, pw *projectworkflow.Service, runs *taskrun.Manager) *RunControlOwner {
return &RunControlOwner{
Access: access,
Workflow: pw,
TaskRuns: runs,
dryRun: intentflow.NewRunControlDryRunAdapter(),
receipts: map[string]intentflow.Receipt{},
}
}
func (r *RunControlOwner) isLiveKey(key string) bool {
_, ok := runControlLiveKeys[key]
return ok
}
func (r *RunControlOwner) Authorize(ctx context.Context, exec intentflow.StepExec) error {
if r == nil || r.Denied {
return intentflow.ErrPlanActionForbidden
}
if !r.isLiveKey(exec.ActionKey) {
return r.dryRun.Authorize(ctx, exec)
}
if exec.Principal.UserID <= 0 || exec.Principal.ProjectID <= 0 || exec.Principal.SessionID == "" {
return intentflow.ErrPlanActionForbidden
}
if r.Access != nil {
if err := r.Access(ctx, exec.Principal.ProjectID, exec.Principal.UserID); err != nil {
return intentflow.ErrPlanActionForbidden
}
}
if exec.ActionKey == "run.resume" {
workDir := slotValue(exec, "work_dir")
if workDir == "" {
workDir = strings.TrimSpace(exec.SafeSummary)
}
if workDir == "" {
return intentflow.ErrPlanActionForbidden
}
}
return nil
}
func (r *RunControlOwner) Execute(ctx context.Context, exec intentflow.StepExec) (intentflow.Receipt, error) {
if !r.isLiveKey(exec.ActionKey) {
return r.dryRun.Execute(ctx, exec)
}
if err := r.Authorize(ctx, exec); err != nil {
return intentflow.Receipt{}, err
}
r.mu.Lock()
if rec, ok := r.receipts[exec.IdempotencyKey]; ok {
r.mu.Unlock()
if rec.ActionDigest != exec.ActionDigest {
return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict
}
return rec, nil
}
r.mu.Unlock()
ref, err := r.runLive(ctx, exec)
if err != nil {
return intentflow.Receipt{}, err
}
rec := intentflow.Receipt{
IdempotencyKey: exec.IdempotencyKey,
ActionDigest: exec.ActionDigest,
ResultRef: ref,
ResultDigest: fmt.Sprintf("run_control:%s", exec.ActionKey),
SafeSummary: exec.ActionKey + " accepted",
}
r.mu.Lock()
defer r.mu.Unlock()
if existing, ok := r.receipts[exec.IdempotencyKey]; ok {
if existing.ActionDigest != exec.ActionDigest {
return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict
}
return existing, nil
}
r.receipts[exec.IdempotencyKey] = rec
return rec, nil
}
func (r *RunControlOwner) runLive(ctx context.Context, exec intentflow.StepExec) (string, error) {
switch exec.ActionKey {
case "workflow.run":
if r.Workflow == nil {
return "", fmt.Errorf("run control: workflow service missing")
}
workflowKey := slotValue(exec, "workflow_key")
if workflowKey == "" {
workflowKey = "main"
}
runID := planRunID(exec.IdempotencyKey)
startedBy := exec.Principal.UserID
created, err := r.Workflow.CreateWorkflowRun(ctx, &projectworkflow.WorkflowRun{
RunID: runID,
ProjectID: exec.Principal.ProjectID,
WorkflowKey: workflowKey,
StartedBy: &startedBy,
Status: "running",
})
if err != nil {
return "", err
}
return "workflow_run:" + created.RunID, nil
case "run.resume":
if r.TaskRuns == nil {
return "", fmt.Errorf("run control: taskrun manager missing")
}
workDir := slotValue(exec, "work_dir")
if workDir == "" {
workDir = strings.TrimSpace(exec.SafeSummary)
}
if workDir == "" {
return "", intentflow.ErrPlanActionForbidden
}
if existing, err := r.TaskRuns.FindByIdempotencyKey(ctx, exec.IdempotencyKey); err != nil {
return "", err
} else if existing != nil && existing.RunID != "" {
return existing.RunID, nil
}
projectName := ""
if r.ProjectName != nil {
projectName, _ = r.ProjectName(ctx, exec.Principal.ProjectID)
}
runID := planRunID(exec.IdempotencyKey)
rec := &taskrun.RunRecord{
RunID: runID,
SessionID: exec.Principal.SessionID,
ProjectID: exec.Principal.ProjectID,
UserID: exec.Principal.UserID,
IdempotencyKey: exec.IdempotencyKey,
TaskKey: "intake",
Status: "pending",
}
created, err := r.TaskRuns.CreateAndStart(ctx, runID, rec, bizdev.TaskRunRequest{
TaskKey: "intake",
UserID: exec.Principal.UserID,
ProjectID: exec.Principal.ProjectID,
SessionID: exec.Principal.SessionID,
ProjectName: projectName,
}, bizdev.StageRunRequest{}, workDir)
if err != nil {
return "", err
}
return created.RunID, nil
default:
return "", intentflow.ErrPlanActionForbidden
}
}
func (r *RunControlOwner) LookupReceipt(ctx context.Context, idempotencyKey string) (intentflow.Receipt, bool, error) {
if r == nil {
return intentflow.Receipt{}, false, nil
}
r.mu.Lock()
rec, ok := r.receipts[idempotencyKey]
r.mu.Unlock()
if ok {
return rec, true, nil
}
return r.dryRun.LookupReceipt(ctx, idempotencyKey)
}
func slotValue(exec intentflow.StepExec, key string) string {
if len(exec.Slots) == 0 {
return ""
}
var slots map[string]string
if json.Unmarshal(exec.Slots, &slots) != nil {
return ""
}
return strings.TrimSpace(slots[key])
}
+49
View File
@@ -0,0 +1,49 @@
package app
import (
"context"
"testing"
"chat-agent-system/internal/app/intentflow"
)
func TestRunControlOwnerForbiddenWithoutAccess(t *testing.T) {
owner := NewRunControlOwner(func(context.Context, int64, int64) error {
return intentflow.ErrPlanActionForbidden
}, nil, nil)
_, err := owner.Execute(context.Background(), intentflow.StepExec{
ActionKey: "workflow.run",
IdempotencyKey: "rc-auth", ActionDigest: "d",
Principal: intentflow.Principal{UserID: 1, ProjectID: 2, SessionID: "s"},
})
if err != intentflow.ErrPlanActionForbidden {
t.Fatalf("want forbidden, got %v", err)
}
}
func TestRunControlOwnerResumeMissingWorkDirFailClosed(t *testing.T) {
owner := NewRunControlOwner(func(context.Context, int64, int64) error { return nil }, nil, nil)
_, err := owner.Execute(context.Background(), intentflow.StepExec{
ActionKey: "run.resume",
IdempotencyKey: "rc-resume", ActionDigest: "d",
Principal: intentflow.Principal{UserID: 1, ProjectID: 2, SessionID: "s"},
})
if err != intentflow.ErrPlanActionForbidden {
t.Fatalf("missing work_dir must fail closed, got %v", err)
}
}
func TestRunControlOwnerNonLiveKeyUsesDryRun(t *testing.T) {
owner := NewRunControlOwner(nil, nil, nil)
rec, err := owner.Execute(context.Background(), intentflow.StepExec{
ActionKey: "workflow.switch",
StepID: "s", IdempotencyKey: "rc-sw", ActionDigest: "d",
Principal: intentflow.Principal{UserID: 1, ProjectID: 2, SessionID: "s"},
})
if err != nil {
t.Fatal(err)
}
if rec.SafeSummary == "" {
t.Fatal("expected dry-run summary")
}
}
+8 -8
View File
@@ -59,20 +59,20 @@ var featureMaturityCatalog = map[string]featureMaturity{
reason: "空值拒绝所有 elevated tier;* 放开全部;逗号分隔项目 ID",
},
"MULTI_INTENT_U9_SEGMENT": {
state: implDryRun, adapter: adapterUnavailable,
reason: "仅有 PI proposer,尚未进入真实聊天分段;提议请求已使用 reasoning profile",
state: implLive, adapter: adapterReady,
reason: "HandleTurn 在规则无法分段且 elevated tier 时调用 PI proposer;失败 fail-closed;RulesCanDecide 跳过 LLM",
},
"MULTI_INTENT_U10_MUTATE": {
state: implDryRun, adapter: adapterUnavailable,
reason: "适配器仍是 dry-run/validated-only",
state: implLive, adapter: adapterReady,
reason: "project.fact.supplement 经 MutateOwner 写项目黑板(ctxmgr 端口);需 ExpectedResourceVersion",
},
"MULTI_INTENT_U11_DOCGOV": {
state: implDryRun, adapter: adapterUnavailable,
reason: "适配器仍是 dry-run/validated-only",
state: implLive, adapter: adapterDegraded,
reason: "document.claim / document.review.submit 接 docgov 引擎;其余 docgov key 仍 dry-run;项目 143 只读",
},
"MULTI_INTENT_U12_RUN_CONTROL": {
state: implDryRun, adapter: adapterUnavailable,
reason: "适配器仍是 dry-run/validated-only",
state: implLive, adapter: adapterDegraded,
reason: "workflow.run / run.resume 接 projectworkflow 与 taskrun;其余 run-control key 仍 dry-run",
},
"SEMANTIC_SUMMARY_SHADOW": {
state: implShadow, adapter: adapterDegraded,
+6 -2
View File
@@ -196,8 +196,12 @@ func TestFeatures_ConfiguredValueDoesNotImplyReadiness(t *testing.T) {
t.Fatalf("confirm tier value must not imply rollout-ready: %+v", tier)
}
u9 := byKey["MULTI_INTENT_U9_SEGMENT"]
if u9.Value != "true" || u9.AdapterReadiness != "unavailable" {
t.Fatalf("U9 flag must not imply a live segmenter: %+v", u9)
if u9.Value != "true" || u9.ImplementationState != "live" || u9.AdapterReadiness != "ready" {
t.Fatalf("U9 flag on must reflect live HandleTurn wiring: %+v", u9)
}
u10 := byKey["MULTI_INTENT_U10_MUTATE"]
if u10.ImplementationState != "live" || u10.AdapterReadiness != "ready" {
t.Fatalf("U10 live mutate adapter: %+v", u10)
}
if byKey["MULTI_INTENT_ENABLED"].ImplementationState != "live" || byKey["MULTI_INTENT_ENABLED"].AdapterReadiness != "ready" {
t.Fatalf("legacy multi-intent remains the live path: %+v", byKey["MULTI_INTENT_ENABLED"])