merge: candidate intent-plan-u9u12-live into local_dev
This commit is contained in:
@@ -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
@@ -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 ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user