mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
feat: show and filter the decided_by actor in the approval records
The approval list and detail return decided_by exactly as stored, and the decided_by filter matches that same value. Rows decided before the column existed show "Legacy" and match no decided_by filter; their decision_source stays in its own column and nothing is rewritten. UI: a "Decided by" column (Human, Rule, Model, System) and a matching filter beside the decision-source filter, including "AI-approved only" for model. Types and the API query carry decided_by the same way decision_source does.
This commit is contained in:
+7
-8
@@ -255,14 +255,12 @@ const approvalDecisionSource = `COALESCE(NULLIF(ip.decision_source,''), CASE
|
||||
WHEN ip.rule_id IS NOT NULL THEN 'rule'
|
||||
WHEN lower(ip.reason) LIKE '` + ModelReasonPrefix + `%' OR ip.reason LIKE '` + legacyModelReasonPrefix + `%' THEN 'model' ELSE 'unknown' END)`
|
||||
|
||||
// Rows decided before decided_by existed keep it empty; show their decision
|
||||
// source instead of rewriting history. A pending row has no deciding actor yet,
|
||||
// so its empty value is returned as is.
|
||||
const approvalDecidedBy = `CASE WHEN ip.decided_by<>'' OR ip.status='pending' THEN ip.decided_by ELSE ` + approvalDecisionSource + ` END`
|
||||
|
||||
// decided_by is returned exactly as stored, never merged with decision_source:
|
||||
// rows decided before the column existed keep it empty, so the displayed value
|
||||
// is always the one the decided_by filter matches.
|
||||
const approvalRowColumns = `ip.id, ip.rule_id, ip.conversation_id, ip.task_id, ip.agent_name,
|
||||
ip.tool_name, ip.tool_input, ip.status, ip.reason, ip.decided_at, ip.created_at, ` + approvalDecisionSource + `,
|
||||
` + approvalDecidedBy + `,
|
||||
ip.decided_by,
|
||||
COALESCE(c.title,'') AS conv_title,
|
||||
COALESCE(c.agent_key,'') AS conv_agent_key,
|
||||
COALESCE(ir.name,'') AS rule_name`
|
||||
@@ -374,8 +372,9 @@ func (d *DB) listInterceptsPage(taskID string, page, size int, filter InterceptA
|
||||
add("ip.task_id", taskID)
|
||||
add("ip.status", filter.Status)
|
||||
add(approvalDecisionSource, filter.DecisionSource)
|
||||
// Stored actor only: the legacy display fallback would let decided_by=model
|
||||
// match human decisions and timeouts recorded before the column existed.
|
||||
// Stored actor only, the same value the rows display: deriving it from
|
||||
// decision_source would let decided_by=model match human decisions and
|
||||
// timeouts recorded before the column existed.
|
||||
add("ip.decided_by", filter.DecidedBy)
|
||||
where := ""
|
||||
if len(conditions) > 0 {
|
||||
|
||||
@@ -129,22 +129,41 @@ func TestInterceptDecidedBy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Rows decided before the column existed: displayed through decision_source,
|
||||
// never rewritten, and not matched by the stored-actor filter.
|
||||
// Rows decided before the column existed: decided_by stays empty everywhere it
|
||||
// is read, decision_source keeps its own column, nothing is rewritten, and no
|
||||
// decided_by filter matches them.
|
||||
if _, err := d.Exec(`UPDATE intercept_pending SET decided_by='' WHERE id IN ($1,$2,$3)`, ruleID, modelID, timeoutID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for id, source := range map[int64]string{ruleID: "rule", modelID: "model", timeoutID: "model"} {
|
||||
legacy := map[int64]string{ruleID: "rule", modelID: "model", timeoutID: "model"}
|
||||
for id, source := range legacy {
|
||||
detail, err := d.GetInterceptDetail(id)
|
||||
if err != nil || detail.DecidedBy != source || detail.DecisionSource != source {
|
||||
t.Fatalf("legacy fallback for %d: %+v err=%v, want %q", id, detail, err, source)
|
||||
if err != nil || detail == nil || detail.DecidedBy != "" || detail.DecisionSource != source {
|
||||
t.Fatalf("legacy detail for %d: %+v err=%v, want decided_by \"\" and decision_source %q", id, detail, err, source)
|
||||
}
|
||||
stored, err := d.GetInterceptPending(id)
|
||||
if err != nil || stored.DecidedBy != "" {
|
||||
if err != nil || stored == nil || stored.DecidedBy != "" {
|
||||
t.Fatalf("legacy row %d rewritten: %+v err=%v", id, stored, err)
|
||||
}
|
||||
}
|
||||
if got := filtered(InterceptApprovalFilter{DecidedBy: "model"}); len(got) != 0 {
|
||||
t.Fatalf("legacy rows matched decided_by=model: %v", got)
|
||||
rows, err = d.ListTaskIntercepts(scope)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if source, ok := legacy[r.ID]; ok && (r.DecidedBy != "" || r.DecisionSource != source) {
|
||||
t.Fatalf("legacy list row %d: decided_by=%q decision_source=%q, want \"\" and %q", r.ID, r.DecidedBy, r.DecisionSource, source)
|
||||
}
|
||||
}
|
||||
for _, actor := range []string{"human", "rule", "model", "system"} {
|
||||
for _, id := range filtered(InterceptApprovalFilter{DecidedBy: actor}) {
|
||||
if _, ok := legacy[id]; ok {
|
||||
t.Fatalf("legacy row %d matched decided_by=%s", id, actor)
|
||||
}
|
||||
}
|
||||
}
|
||||
// decision_source still finds them through its own filter.
|
||||
if got := filtered(InterceptApprovalFilter{DecisionSource: "rule"}); !slices.Contains(got, ruleID) {
|
||||
t.Fatalf("legacy rule row missing from decision_source=rule: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,13 +207,25 @@ func TestInterceptDecidedByHTTP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A row decided before the column existed falls back to decision_source.
|
||||
// A row decided before the column existed keeps an empty decided_by; its
|
||||
// decision_source stays in its own field and no decided_by filter returns it.
|
||||
if _, err := d.Exec(`UPDATE intercept_pending SET decided_by='' WHERE id=$1`, ruleID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := do(http.MethodGet, fmt.Sprintf("/api/intercept/history/%d", ruleID), "")
|
||||
var legacy item
|
||||
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &legacy) != nil || legacy.DecidedBy == nil || *legacy.DecidedBy != "rule" {
|
||||
var legacy struct {
|
||||
DecidedBy *string `json:"decided_by"`
|
||||
DecisionSource string `json:"decision_source"`
|
||||
}
|
||||
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &legacy) != nil ||
|
||||
legacy.DecidedBy == nil || *legacy.DecidedBy != "" || legacy.DecisionSource != "rule" {
|
||||
t.Fatalf("legacy detail: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if items, total := list(task + "?decided_by=rule"); total != 0 || len(items) != 0 {
|
||||
t.Fatalf("legacy row matched decided_by=rule: %+v", items)
|
||||
}
|
||||
items, _ = list(task + "?decision_source=rule")
|
||||
if len(items) != 1 || items[0].ID != ruleID || *items[0].DecidedBy != "" {
|
||||
t.Fatalf("legacy row under decision_source=rule: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,23 @@ function StatusBadge({ status }: { status: string }) {
|
||||
return <Badge variant={variant}>{labels[status] ?? status}</Badge>;
|
||||
}
|
||||
|
||||
const decidedByLabels: Record<string, string> = { human: "Human", rule: "Rule", model: "Model", system: "System" };
|
||||
|
||||
// decided_by is shown exactly as stored, so a row always sits under the
|
||||
// "Decided by" filter option matching its badge. A decided row without an actor
|
||||
// predates the column: it reads "Legacy" and matches no option.
|
||||
function DecidedByBadge({ row }: { row: InterceptApprovalRow }) {
|
||||
if (row.status === "pending") return <span className="text-muted-foreground">—</span>;
|
||||
if (!row.decided_by) {
|
||||
return (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
Legacy
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="outline">{decidedByLabels[row.decided_by] ?? row.decided_by}</Badge>;
|
||||
}
|
||||
|
||||
function MatchCell({ row, showReason = true }: { row: InterceptApprovalRow; showReason?: boolean }) {
|
||||
const reason = row.reason?.replace(/^\[model\]\s*/, "");
|
||||
return (
|
||||
@@ -383,6 +400,10 @@ export function ApprovalDetail({
|
||||
<dd>{fmtTime(row.created_at)}</dd>
|
||||
<dt className="text-muted-foreground">Decided at</dt>
|
||||
<dd>{fmtTime(current.decided_at)}</dd>
|
||||
<dt className="text-muted-foreground">Decided by</dt>
|
||||
<dd>
|
||||
<DecidedByBadge row={current} />
|
||||
</dd>
|
||||
{audit?.rule_name ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Rule snapshot</dt>
|
||||
@@ -527,10 +548,10 @@ function subscribeColumns(onChange: () => void) {
|
||||
}
|
||||
function visibleColumnCount() {
|
||||
const matches = approvalBreakpoints.map((query) => window.matchMedia(query).matches);
|
||||
return 3 + Number(matches[0]) + Number(matches[1]) + 2 * Number(matches[2]) + 2 * Number(matches[3]);
|
||||
return 3 + Number(matches[0]) + Number(matches[1]) + 3 * Number(matches[2]) + 2 * Number(matches[3]);
|
||||
}
|
||||
function serverColumnCount() {
|
||||
return 9;
|
||||
return 10;
|
||||
}
|
||||
|
||||
function ApprovalTable({
|
||||
@@ -570,6 +591,7 @@ function ApprovalTable({
|
||||
<TableHead className="hidden lg:table-cell">Matched rule</TableHead>
|
||||
<TableHead className="hidden xl:table-cell">Arguments</TableHead>
|
||||
<TableHead className="w-24">Status</TableHead>
|
||||
<TableHead className="hidden w-24 lg:table-cell">Decided by</TableHead>
|
||||
<TableHead className="hidden w-36 lg:table-cell">Requested at</TableHead>
|
||||
<TableHead className="hidden w-36 xl:table-cell">Decided at</TableHead>
|
||||
</TableRow>
|
||||
@@ -623,6 +645,9 @@ function ApprovalTable({
|
||||
<TableCell>
|
||||
<StatusBadge status={row.status} />
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<DecidedByBadge row={row} />
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground text-xs lg:table-cell">
|
||||
{fmtTime(row.created_at)}
|
||||
</TableCell>
|
||||
@@ -663,7 +688,7 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
const request = React.useRef(0);
|
||||
const decisionLock = React.useRef(false);
|
||||
const filterID = React.useId();
|
||||
const filtered = Boolean(filter.status || filter.decision_source);
|
||||
const filtered = Boolean(filter.status || filter.decision_source || filter.decided_by);
|
||||
|
||||
// A task can stay mounted while the user switches between task details.
|
||||
// Reset the cursor so the new scope always starts at its newest records.
|
||||
@@ -749,7 +774,9 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
// Optimistically drop the decided item from the independent pending queue
|
||||
// for instant feedback; the re-fetch below reconciles with server truth.
|
||||
setRows((prev) =>
|
||||
prev.map((row) => (row.id === id ? { ...row, status: decision, decided_at: new Date().toISOString() } : row)),
|
||||
prev.map((row) =>
|
||||
row.id === id ? { ...row, status: decision, decided_by: "human", decided_at: new Date().toISOString() } : row,
|
||||
),
|
||||
);
|
||||
setPendingRows((prev) => prev.filter((row) => row.id !== id));
|
||||
setRevision((v) => v + 1);
|
||||
@@ -840,6 +867,31 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field className="w-full sm:w-40">
|
||||
<FieldLabel htmlFor={`${filterID}-decided-by`}>Decided by</FieldLabel>
|
||||
<Select
|
||||
value={filter.decided_by ?? "all"}
|
||||
onValueChange={(value) =>
|
||||
changeFilter({
|
||||
...filter,
|
||||
decided_by: value === "all" ? undefined : (value as InterceptApprovalFilter["decided_by"]),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`${filterID}-decided-by`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">All actors</SelectItem>
|
||||
<SelectItem value="model">AI-approved only</SelectItem>
|
||||
<SelectItem value="human">Human</SelectItem>
|
||||
<SelectItem value="rule">Rule</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{filtered ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => changeFilter({})}>
|
||||
Clear filters
|
||||
@@ -885,7 +937,7 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
<EmptyTitle>{filtered ? "No approval records match the filters" : "No approval records"}</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{filtered
|
||||
? "Adjust the approval status or decision source, or clear the filters to see all records."
|
||||
? "Adjust the approval status, decision source or deciding actor, or clear the filters to see all records."
|
||||
: "Records will appear here once a rule or model makes an approval decision."}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
|
||||
@@ -231,6 +231,7 @@ function interceptPageQuery(page: number, size: number, filter: InterceptApprova
|
||||
const query = new URLSearchParams({ page: String(page), size: String(size) });
|
||||
if (filter.status) query.set("status", filter.status);
|
||||
if (filter.decision_source) query.set("decision_source", filter.decision_source);
|
||||
if (filter.decided_by) query.set("decided_by", filter.decided_by);
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -3496,6 +3496,7 @@ interceptHistory.unshift({
|
||||
tool_input: { path: "reports/summary.md", content: "# Check summary\n\nThis round of verification is complete; existing evidence and follow-up suggestions are organized." },
|
||||
status: "allowed",
|
||||
decision_source: "model",
|
||||
decided_by: "model",
|
||||
reason: "[model] Write the existing check conclusions into a local report without modifying business data.",
|
||||
created_at: T("2026-07-26T08:00:00Z"),
|
||||
decided_at: T("2026-07-26T08:00:02Z"),
|
||||
|
||||
@@ -2431,16 +2431,19 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
const decisionSource = q.get("decision_source") || "";
|
||||
if (status && !["pending", "allowed", "denied", "timeout"].includes(status)) throw new Error("invalid approval status");
|
||||
if (decisionSource && !["model", "rule", "unknown"].includes(decisionSource)) throw new Error("invalid decision source");
|
||||
const decidedBy = q.get("decided_by") || "";
|
||||
if (decidedBy && !["human", "rule", "model", "system"].includes(decidedBy)) throw new Error("invalid decided_by");
|
||||
const filtered = mockInterceptHistory.filter((row) => {
|
||||
const source =
|
||||
row.decision_source || (row.rule_id ? "rule" : row.reason?.startsWith("[model]") ? "model" : "unknown");
|
||||
return (
|
||||
(seg[1] !== "task" || row.task_id === decodeURIComponent(seg[2])) &&
|
||||
(!status || row.status === status) &&
|
||||
(!decisionSource || source === decisionSource)
|
||||
(!decisionSource || source === decisionSource) &&
|
||||
(!decidedBy || row.decided_by === decidedBy)
|
||||
);
|
||||
});
|
||||
if (!q.has("page") && !q.has("size") && !status && !decisionSource)
|
||||
if (!q.has("page") && !q.has("size") && !status && !decisionSource && !decidedBy)
|
||||
return { items: filtered, total: filtered.length };
|
||||
const page = Math.max(1, Number(q.get("page")) || 1);
|
||||
const size = Math.min(100, Math.max(1, Number(q.get("size")) || 20));
|
||||
|
||||
@@ -1347,6 +1347,9 @@ export interface AssetInterceptRule {
|
||||
|
||||
export interface InterceptPending {
|
||||
decision_source?: "rule" | "model" | "unknown" | "";
|
||||
// Actor that settled the request, exactly as stored: "" while pending and on
|
||||
// rows decided before the column existed (shown as "Legacy").
|
||||
decided_by?: "human" | "rule" | "model" | "system" | "";
|
||||
id: number;
|
||||
rule_id?: number;
|
||||
conversation_id?: number;
|
||||
@@ -1391,6 +1394,7 @@ export interface JudgeUsage {
|
||||
export interface InterceptApprovalFilter {
|
||||
status?: InterceptPending["status"];
|
||||
decision_source?: "rule" | "model" | "unknown";
|
||||
decided_by?: "human" | "rule" | "model" | "system";
|
||||
}
|
||||
|
||||
// InterceptApprovalRow enriches InterceptPending with conversation/task and rule context.
|
||||
|
||||
Reference in New Issue
Block a user