diff --git a/agent/tools.go b/agent/tools.go index 877032a..a7d4721 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -325,14 +325,14 @@ func (t *ToolSet) graphOverviewData() map[string]any { out["task"] = map[string]any{"description": description, "goal": goal} } // coverage:粗略的资产测试覆盖度参考——范围(task_scope)内的资产里,被 fact 碰过的 - // 占比;backlog_sample 是范围内还没被测的资产(供优先补测)。仅任务上下文有。 + // 占比 + by_type(按类型的 总数/已测)。要看未测的具体资产由 agent 按需调 list_untested_assets 自行判断。仅任务上下文有。 if t.as != nil && t.ts != nil && t.taskID > 0 { - if cov, err := t.as.TaskCoverage(t.taskID, t.ts.ID(), 20); err == nil { + if cov, err := t.as.TaskCoverage(t.taskID, t.ts.ID()); err == nil { m := map[string]any{ - "denominator": cov.Denominator, - "tested": cov.Tested, - "backlog_sample": cov.Backlog, - "note": "粗略估计、仅供参考:容器型资产/大量枚举会让它偏低,勿据此认为已测完;优先补测 backlog。", + "denominator": cov.Denominator, + "tested": cov.Tested, + "by_type": cov.ByType, + "note": "coverage资产测试覆盖度(包括接口等各种相关资产),粗略估计、仅供参考:容器型资产/大量枚举会让它偏低,勿据此认为已测完;可以通过相关工具增加任务测试范围", } if cov.ScopeRows == 0 { m["pct"] = nil @@ -1094,5 +1094,7 @@ func (t *ToolSet) PlannerTools() []actool.CoreTool { t.listCompanies(), // add_task_scope:主动把整根域/整公司/某子域/IP 纳入本任务测试范围(覆盖度分母)。 t.addTaskScope(), + // list_untested_assets:按需查本任务范围内未测资产(类型+分页),自行决定补测。 + t.listUntestedAssets(), } } diff --git a/agent/tools_insert.go b/agent/tools_insert.go index 7adb5b0..d442bf2 100644 --- a/agent/tools_insert.go +++ b/agent/tools_insert.go @@ -398,6 +398,51 @@ func (t *ToolSet) addTaskScope() actool.CoreTool { ) } +// listUntestedAssets lets the plan agent pull the CURRENT TASK's in-scope, not-yet- +// tested assets on demand (filter by type, paginated) — so it can decide what to +// test next itself, instead of coverage pushing a backlog list into every prompt. +func (t *ToolSet) listUntestedAssets() actool.CoreTool { + return readTool( + "list_untested_assets", + "查询【本任务】范围内、还没被测过的资产(供你自己判断要不要补测,不代替你决策)。\n"+ + "可选按资产类型过滤:root_domain/subdomain/service/app/endpoint/ip。\n"+ + "分页:page 从 1 起、page_size 默认 10。返回 {assets:[{id,type,label}], total, page, page_size}。仅任务上下文可用。", + obj(map[string]any{ + "type": str("资产类型过滤(可选):root_domain/subdomain/service/app/endpoint/ip"), + "page": intp("页码,从 1 起(默认 1)"), + "page_size": intp("每页数量(默认 10)"), + }), + func(_ context.Context, in json.RawMessage) (actool.Result, error) { + if t.as == nil { + return actool.Errorf("list_untested_assets 未启用: AssetStore 未初始化"), nil + } + if t.taskID <= 0 || t.ts == nil { + return actool.Errorf("list_untested_assets 需要任务上下文"), nil + } + var a struct { + Type string `json:"type"` + Page int `json:"page"` + PageSize int `json:"page_size"` + } + _ = json.Unmarshal(in, &a) + if a.Page <= 0 { + a.Page = 1 + } + if a.PageSize <= 0 { + a.PageSize = 10 + } + offset := (a.Page - 1) * a.PageSize + assets, total, err := t.as.ListUntestedAssets(t.taskID, t.ts.ID(), strings.TrimSpace(a.Type), a.PageSize, offset) + if err != nil { + return actool.Errorf(err.Error()), nil + } + return jsonResult(map[string]any{ + "assets": assets, "total": total, "page": a.Page, "page_size": a.PageSize, + }) + }, + ) +} + // listAssets lets an agent query the asset table. func (t *ToolSet) listAssets() actool.CoreTool { return readTool( @@ -553,6 +598,9 @@ func (t *ToolSet) MainAgentTools() []actool.CoreTool { // asset management (handlers guard nil store internally) t.insertAssets(), t.addCompanyScope(), t.listAssets(), t.addFinding(), t.recordFact(), + t.addTaskScope(), + // list_untested_assets:按需查本任务范围内未测资产(类型+分页),自行决定补测。 + t.listUntestedAssets(), } } diff --git a/db/task_scope.go b/db/task_scope.go index 9f9c5c5..716425f 100644 --- a/db/task_scope.go +++ b/db/task_scope.go @@ -203,18 +203,25 @@ type CoverageAsset struct { Label string `json:"label"` } +// CoverageByType is per-asset-type coverage: total in scope vs tested. +type CoverageByType struct { + Type string `json:"type"` + Total int `json:"total"` + Tested int `json:"tested"` +} + // Coverage is a task's rough asset test coverage — a reference figure for the agent, // NOT a precise metric. Denominator = assets matching any active task_scope row; // Tested = those anchored to at least one fact node in the exploration. type Coverage struct { - ScopeRows int `json:"scope_rows"` // 0 → 范围未锚定 - Denominator int `json:"denominator"` // 范围内资产数 - Tested int `json:"tested"` // 已测(约) - Pct *float64 `json:"pct"` // 覆盖度;分母 0 时 null - Backlog []CoverageAsset `json:"backlog_sample"` + ScopeRows int `json:"scope_rows"` // 0 → 范围未锚定 + Denominator int `json:"denominator"` // 范围内资产数 + Tested int `json:"tested"` // 已测(约) + Pct *float64 `json:"pct"` // 覆盖度;分母 0 时 null + ByType []CoverageByType `json:"by_type"` // 按资产类型的 总数/已测 } -// task_scope→assets match predicate, reused by the count and backlog queries. $1=taskID. +// task_scope→assets match predicate, reused by the count / by-type / untested queries. $1=taskID. const covTargetCTE = ` target AS ( SELECT DISTINCT a.id, a.type, @@ -234,39 +241,74 @@ tested AS ( WHERE en.exploration_id = $2 AND en.kind = 'fact' )` -// TaskCoverage computes rough coverage for a task. taskID indexes task_scope + assets; -// expID indexes the fact anchors. backlogSample caps the untested-asset sample (≤). -func (s *AssetStore) TaskCoverage(taskID, expID int64, backlogSample int) (*Coverage, error) { - if backlogSample <= 0 { - backlogSample = 20 - } - cov := &Coverage{Backlog: []CoverageAsset{}} +// TaskCoverage computes rough per-type coverage for a task. taskID indexes +// task_scope + assets; expID indexes the fact anchors. Reference figure only. +func (s *AssetStore) TaskCoverage(taskID, expID int64) (*Coverage, error) { + cov := &Coverage{ByType: []CoverageByType{}} _ = s.db.QueryRow(`SELECT count(*) FROM task_scope WHERE task_id=$1`, taskID).Scan(&cov.ScopeRows) - - if err := s.db.QueryRow(`WITH `+covTargetCTE+` -SELECT (SELECT count(*) FROM target), - (SELECT count(*) FROM target t WHERE t.id IN (SELECT asset_id FROM tested))`, - taskID, expID).Scan(&cov.Denominator, &cov.Tested); err != nil { + rows, err := s.db.Query(`WITH `+covTargetCTE+` +SELECT t.type, count(*) AS total, + count(*) FILTER (WHERE t.id IN (SELECT asset_id FROM tested)) AS tested +FROM target t GROUP BY t.type ORDER BY t.type`, taskID, expID) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var bt CoverageByType + if err := rows.Scan(&bt.Type, &bt.Total, &bt.Tested); err != nil { + return nil, err + } + cov.ByType = append(cov.ByType, bt) + cov.Denominator += bt.Total + cov.Tested += bt.Tested + } + if err := rows.Err(); err != nil { return nil, err } if cov.Denominator > 0 { p := float64(cov.Tested) / float64(cov.Denominator) cov.Pct = &p } - - rows, err := s.db.Query(`WITH `+covTargetCTE+` -SELECT t.id, t.type, t.label FROM target t -WHERE t.id NOT IN (SELECT asset_id FROM tested) -ORDER BY t.id LIMIT $3`, taskID, expID, backlogSample) - if err != nil { - return cov, nil // counts are still useful even if the sample query fails - } - defer rows.Close() - for rows.Next() { - var a CoverageAsset - if err := rows.Scan(&a.ID, &a.Type, &a.Label); err == nil { - cov.Backlog = append(cov.Backlog, a) - } - } return cov, nil } + +// ListUntestedAssets returns a task's in-scope, not-yet-tested assets, optionally +// filtered by asset type, paginated. Returns the page + the total count. limit<=0 → 10. +func (s *AssetStore) ListUntestedAssets(taskID, expID int64, typ string, limit, offset int) ([]CoverageAsset, int, error) { + if limit <= 0 { + limit = 10 + } + if offset < 0 { + offset = 0 + } + typeFilter := "" + args := []any{taskID, expID} + if typ != "" { + typeFilter = " AND t.type = $3" + args = append(args, typ) + } + var total int + _ = s.db.QueryRow(`WITH `+covTargetCTE+` +SELECT count(*) FROM target t WHERE t.id NOT IN (SELECT asset_id FROM tested)`+typeFilter, args...).Scan(&total) + pageArgs := append(append([]any{}, args...), limit, offset) + limPos := strconv.Itoa(len(args) + 1) + offPos := strconv.Itoa(len(args) + 2) + rows, err := s.db.Query(`WITH `+covTargetCTE+` +SELECT t.id, t.type, t.label FROM target t +WHERE t.id NOT IN (SELECT asset_id FROM tested)`+typeFilter+` +ORDER BY t.id LIMIT $`+limPos+` OFFSET $`+offPos, pageArgs...) + if err != nil { + return nil, total, err + } + defer rows.Close() + out := []CoverageAsset{} + for rows.Next() { + var a CoverageAsset + if err := rows.Scan(&a.ID, &a.Type, &a.Label); err != nil { + return nil, total, err + } + out = append(out, a) + } + return out, total, rows.Err() +} diff --git a/server/server.go b/server/server.go index 93a9370..07045ee 100644 --- a/server/server.go +++ b/server/server.go @@ -1099,7 +1099,7 @@ func (s *Server) taskCoverage(w http.ResponseWriter, r *http.Request) { return } taskID, _ := strconv.ParseInt(t.ID, 10, 64) - cov, err := as.TaskCoverage(taskID, t.ExpID, atoiDefault(r.URL.Query().Get("backlog"), 20)) + cov, err := as.TaskCoverage(taskID, t.ExpID) if err != nil { writeErr(w, 500, err.Error()) return diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx index 725f38a..0644996 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx @@ -57,7 +57,7 @@ export function OverviewTab({ taskId }: { taskId: string }) { denominator: number; tested: number; pct: number | null; - backlog_sample: { id: number; type: string; label: string }[]; + by_type: { type: string; total: number; tested: number }[]; } | null>(null); React.useEffect(() => { @@ -126,16 +126,16 @@ export function OverviewTab({ taskId }: { taskId: string }) { {coverage.pct != null && } - {coverage.backlog_sample.length > 0 && ( -