refactor(coverage): 覆盖度改按类型统计 + 新增 list_untested_assets 工具

- 去掉 backlog(避免把未测清单塞进提示词引导 plan),改由 plan 自行判断。
- Coverage 去 Backlog、加 by_type [{type,total,tested}];TaskCoverage 改 GROUP BY type,
  累加得总分母/总已测。
- 新增 db.ListUntestedAssets(范围内未测资产,可按类型过滤 + 分页)与 list_untested_assets
  工具(type + page/page_size,默认页大小 10),绑 planner、随 BuiltinToolSeeds 播种。
- graph_overview 的 coverage 现为 {denominator,tested,pct,by_type,note};note 提示按需调
  list_untested_assets 自行判断,不再推送 backlog。
- /api/tasks/{id}/coverage 去掉 backlog 参数;前端覆盖度卡改为按类型「已测/总数」徽章。

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Autumn-27andClaude Opus 5 committed 2026-08-08 11:50:51 -04:00
1 parent aeadd0d48b
commit 4c4f65f40a
6 files changed
+144 -52

No files matched your search

+8 -6
View File
@@ -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(),
}
}
+48
View File
@@ -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(),
}
}
+75 -33
View File
@@ -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()
}
+1 -1
View File
@@ -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
@@ -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 }) {
</span>
</div>
{coverage.pct != null && <Progress value={Math.round(coverage.pct * 100)} />}
{coverage.backlog_sample.length > 0 && (
<div className="text-xs">
<div className="text-muted-foreground mb-1">未测(优先补测):</div>
<div className="flex flex-wrap gap-1">
{coverage.backlog_sample.slice(0, 12).map((a) => (
<span key={a.id} className="bg-muted rounded px-1.5 py-0.5 font-mono">
{a.label || a.type}
{coverage.by_type.length > 0 && (
<div className="flex flex-wrap gap-1.5 text-xs">
{coverage.by_type.map((b) => (
<span key={b.type} className="bg-muted rounded px-1.5 py-0.5">
<span className="text-muted-foreground">{b.type}</span>{" "}
<span className="tabular-nums font-medium">
{b.tested}/{b.total}
</span>
))}
</div>
</span>
))}
</div>
)}
</CardContent>
+2 -2
View File
@@ -115,14 +115,14 @@ export const api = {
setActive: (id: string) => post<{ active: string }>("/active", { id }),
// ---- stats ----
stats: (task?: string) => get<Stats>(`/stats${tq(task)}`),
// 资产测试覆盖度(粗估,供参考):范围内资产里被 fact 碰过的占比 + 未测 backlog 取样。
// 资产测试覆盖度(粗估,供参考):范围内资产被 fact 碰过的占比 + 按类型的 总数/已测。
taskCoverage: (id: string) =>
get<{
scope_rows: number;
denominator: number;
tested: number;
pct: number | null;
backlog_sample: { id: number; type: string; label: string }[];
by_type: { type: string; total: number; tested: number }[];
}>(`/tasks/${id}/coverage`),
// ---- assets ----