feat(tasks): add templates findings workflows and batch controls

This commit is contained in:
pphua committed 2026-08-21 21:01:39 +08:00
1 parent b7e0b93023
commit d3183dbbd2
65 files changed
+11603 -2198

No files matched your search

+77 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"testing"
"time"
"github.com/Autumn-27/artex/db"
actool "github.com/Autumn-27/norma/tool"
@@ -23,6 +24,73 @@ func callReadJSON(t *testing.T, tool actool.CoreTool, input string) any {
return out
}
func TestGraphOverviewExpandsAssociatedCompanyScope(t *testing.T) {
d := testDB(t)
defer d.Close()
companies := d.Companies()
companyID, _, err := companies.UpsertCompany(fmt.Sprintf("overview-scope-%d", time.Now().UnixNano()), "")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = companies.DeleteCompany(companyID) })
domain := fmt.Sprintf("overview-scope-%d.invalid", companyID)
ip := fmt.Sprintf("2001:db8:%x::42", companyID%0xffff)
cidr := fmt.Sprintf("2001:db8:%x:1::/64", companyID%0xffff)
icp := fmt.Sprintf("京 ICP 备 %d 号", companyID)
keyword := fmt.Sprintf("Scope Company %d", companyID)
inputs := []db.ScopeInput{
{Kind: "domain", Value: domain},
{Kind: "ip", Value: ip},
{Kind: "cidr", Value: cidr},
{Kind: "icp", Value: icp},
{Kind: "keyword", Value: keyword},
}
added, skipped, invalid, scopeErrors := companies.AddScopeInputs(companyID, inputs, "task context test")
if added != len(inputs) || skipped != 0 || invalid != 0 || len(scopeErrors) != 0 {
t.Fatalf("add company scope: added=%d skipped=%d invalid=%d errors=%v", added, skipped, invalid, scopeErrors)
}
task, err := d.CreateTaskWithOptions("company context", "read configured scope", db.TaskCreateOptions{
CompanyIDs: []int64{companyID},
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
tools := NewToolSet(d.Exploration(task.ExplorationID), "planner")
tools.SetTaskID(task.ID)
tools.SetAssetStore(d.Assets(), companies)
overview := tools.graphOverviewData()
coverage, ok := overview["coverage"].(map[string]any)
if !ok {
t.Fatalf("coverage missing: %#v", overview["coverage"])
}
scopeRows, ok := coverage["scope"].([]map[string]any)
if !ok || len(scopeRows) != 1 {
t.Fatalf("task scope missing: %#v", coverage["scope"])
}
companyScope, ok := scopeRows[0]["company_scope"].([]map[string]any)
if !ok || len(companyScope) != len(inputs) {
t.Fatalf("company scope not expanded: %#v", scopeRows[0])
}
kinds := make(map[string]string, len(companyScope))
for _, rule := range companyScope {
kinds[fmt.Sprint(rule["kind"])] = fmt.Sprint(rule["value"])
}
for _, input := range inputs {
if kinds[input.Kind] != input.Value {
t.Errorf("scope %s=%q want %q", input.Kind, kinds[input.Kind], input.Value)
}
}
keywords, ok := scopeRows[0]["company_keywords"].([]string)
if !ok || len(keywords) != 1 || keywords[0] != keyword {
t.Fatalf("company keywords missing: %#v", scopeRows[0]["company_keywords"])
}
}
func TestBlackboardToolsReadDirectSources(t *testing.T) {
d := testDB(t)
defer d.Close()
@@ -125,7 +193,15 @@ func TestBlackboardToolsReadDirectSources(t *testing.T) {
if related[0]["source_task_id"] != source.ID || related[0]["inherited"] != true {
t.Fatalf("related source provenance: %#v", related[0])
}
if facts, ok := related[0]["recent_facts"].([]map[string]any); !ok || len(facts) == 0 || facts[0]["id"] != sourceFact {
recentFacts, ok := related[0]["recent_facts"].([]map[string]any)
foundSourceFact := false
for _, fact := range recentFacts {
if fact["id"] == sourceFact {
foundSourceFact = true
break
}
}
if !ok || !foundSourceFact {
t.Fatalf("related fact summary: %#v", related[0]["recent_facts"])
}
if findings, ok := related[0]["recent_findings"].([]map[string]any); !ok || len(findings) == 0 || findings[0]["id"] != sourceFinding {
+16 -2
View File
@@ -66,6 +66,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
tbuf.WriteString(text)
}
lastTool := &runTrace{startedAt: time.Now()}
var lastUsage *llm.Usage
var finalText string
var rerr error
@@ -74,10 +75,10 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
flush()
if ctx.Err() != nil { // engine/user cancellation, not a provider failure
sum, detail := terminalText(ctx, &harness.Terminal{Reason: reason, Err: ctx.Err()}, lastTool)
rec(db.Activity{Kind: "result", Summary: firstLine(sum, 400), Detail: detail})
rec(activityWithUsage(db.Activity{Kind: "result", Summary: firstLine(sum, 400), Detail: detail}, lastUsage))
return finalText, reason, ctx.Err()
}
rec(db.Activity{Kind: "result", IsError: true, Summary: "执行出错: " + err.Error(), Detail: err.Error()})
rec(activityWithUsage(db.Activity{Kind: "result", IsError: true, Summary: "执行出错: " + err.Error(), Detail: err.Error()}, lastUsage))
return finalText, reason, err
}
switch ev.Kind {
@@ -111,6 +112,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
// buffered final-answer text must stay for the KindResult de-dup.
if ev.Usage != nil {
u := *ev.Usage
lastUsage = &u
rec(db.Activity{Kind: "usage",
InputTokens: &u.InputTokens, OutputTokens: &u.OutputTokens,
CacheReadTokens: &u.CacheReadTokens, CacheWriteTokens: &u.CacheWriteTokens})
@@ -145,6 +147,18 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
return finalText, reason, rerr
}
func activityWithUsage(activity db.Activity, usage *llm.Usage) db.Activity {
if usage == nil {
return activity
}
u := *usage
activity.InputTokens = &u.InputTokens
activity.OutputTokens = &u.OutputTokens
activity.CacheReadTokens = &u.CacheReadTokens
activity.CacheWriteTokens = &u.CacheWriteTokens
return activity
}
// blocksText concatenates the text of a tool-result's content blocks.
func blocksText(blocks []llm.ContentBlock) string {
var b strings.Builder
+87
View File
@@ -0,0 +1,87 @@
package agent
import (
"context"
"errors"
"iter"
"testing"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/agentcore"
"github.com/Autumn-27/norma/llm"
)
type captureUsageProvider struct {
stream func(context.Context, func(llm.StreamEvent, error) bool)
}
func (p captureUsageProvider) Stream(ctx context.Context, _ llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
return func(yield func(llm.StreamEvent, error) bool) { p.stream(ctx, yield) }
}
func TestCaptureRunPersistsUsageOnProviderFailure(t *testing.T) {
wantErr := errors.New("provider failed after reporting usage")
provider := captureUsageProvider{stream: func(_ context.Context, yield func(llm.StreamEvent, error) bool) {
yield(llm.StreamEvent{Type: llm.SEMessageStart, Usage: llm.Usage{InputTokens: 11, CacheReadTokens: 3}}, nil)
yield(llm.StreamEvent{Type: llm.SETextDelta, Text: "partial"}, nil)
yield(llm.StreamEvent{Type: llm.SEMessageDelta, Usage: llm.Usage{OutputTokens: 7, CacheWriteTokens: 2}}, nil)
yield(llm.StreamEvent{}, wantErr)
}}
var activities []db.Activity
_, _, err := captureRun(context.Background(), agentcore.Options{Provider: provider, MaxTurns: 1}, "test", func(a db.Activity) {
activities = append(activities, a)
})
if !errors.Is(err, wantErr) {
t.Fatalf("captureRun error=%v, want %v", err, wantErr)
}
assertCapturedResultUsage(t, activities, 11, 7, 3, 2)
}
func TestCaptureRunPersistsUsageOnCancellation(t *testing.T) {
started := make(chan struct{})
provider := captureUsageProvider{stream: func(ctx context.Context, yield func(llm.StreamEvent, error) bool) {
yield(llm.StreamEvent{Type: llm.SEMessageStart, Usage: llm.Usage{InputTokens: 13, CacheReadTokens: 5}}, nil)
yield(llm.StreamEvent{Type: llm.SETextDelta, Text: "partial"}, nil)
yield(llm.StreamEvent{Type: llm.SEMessageDelta, Usage: llm.Usage{OutputTokens: 9, CacheWriteTokens: 4}}, nil)
close(started)
<-ctx.Done()
yield(llm.StreamEvent{}, ctx.Err())
}}
ctx, cancel := context.WithCancel(context.Background())
var activities []db.Activity
done := make(chan error, 1)
go func() {
_, _, err := captureRun(ctx, agentcore.Options{Provider: provider, MaxTurns: 1}, "test", func(a db.Activity) {
activities = append(activities, a)
})
done <- err
}()
<-started
cancel()
if err := <-done; !errors.Is(err, context.Canceled) {
t.Fatalf("captureRun error=%v, want context canceled", err)
}
assertCapturedResultUsage(t, activities, 13, 9, 5, 4)
}
func assertCapturedResultUsage(t *testing.T, activities []db.Activity, input, output, read, write int) {
t.Helper()
results := 0
for _, activity := range activities {
if activity.Kind != "result" {
continue
}
results++
if activity.InputTokens == nil || *activity.InputTokens != input ||
activity.OutputTokens == nil || *activity.OutputTokens != output ||
activity.CacheReadTokens == nil || *activity.CacheReadTokens != read ||
activity.CacheWriteTokens == nil || *activity.CacheWriteTokens != write {
t.Fatalf("result usage=%+v, want input=%d output=%d read=%d write=%d", activity, input, output, read, write)
}
}
if results != 1 {
t.Fatalf("result activity count=%d, activities=%+v", results, activities)
}
}
+36
View File
@@ -392,6 +392,42 @@ func (t *ToolSet) graphOverviewData() map[string]any {
e["value"] = r.Net
case r.CompanyID != nil:
e["company_id"] = *r.CompanyID
if t.cs != nil {
if company, err := t.cs.GetCompany(*r.CompanyID); err == nil && company != nil {
e["company_name"] = company.Name
}
if rules, err := t.cs.GetScope(*r.CompanyID); err == nil {
keywords := make([]string, 0)
companyScope := make([]map[string]any, 0, len(rules))
for _, rule := range rules {
value := rule.Raw
if value == "" {
switch rule.Kind {
case "domain":
value = rule.Domain
case "ip", "cidr":
value = rule.Net
default:
value = rule.Value
}
}
entry := map[string]any{"kind": rule.Kind, "value": value}
if rule.Reason != "" {
entry["reason"] = rule.Reason
}
companyScope = append(companyScope, entry)
if rule.Kind == "keyword" && rule.Raw != "" {
keywords = append(keywords, rule.Raw)
}
}
if len(companyScope) > 0 {
e["company_scope"] = companyScope
}
if len(keywords) > 0 {
e["company_keywords"] = keywords
}
}
}
}
scope = append(scope, e)
}