feat(approvals): enhance approval model with task titles and confidence as float

This commit is contained in:
Abhimanyu Saharan
2026-02-12 19:57:04 +05:30
parent 8bd606a8dc
commit 032b77afb8
13 changed files with 370 additions and 40 deletions

View File

@@ -26,6 +26,7 @@ from app.db.pagination import paginate
from app.db.session import async_session_maker, get_session
from app.models.agents import Agent
from app.models.approvals import Approval
from app.models.tasks import Task
from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalStatus, ApprovalUpdate
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.activity_log import record_activity
@@ -96,10 +97,36 @@ async def _approval_task_ids_map(
return mapping
def _approval_to_read(approval: Approval, *, task_ids: list[UUID]) -> ApprovalRead:
async def _task_titles_by_id(
session: AsyncSession,
*,
task_ids: set[UUID],
) -> dict[UUID, str]:
if not task_ids:
return {}
rows = list(
await session.exec(
select(col(Task.id), col(Task.title)).where(col(Task.id).in_(task_ids)),
),
)
return {task_id: title for task_id, title in rows}
def _approval_to_read(
approval: Approval,
*,
task_ids: list[UUID],
task_titles: list[str],
) -> ApprovalRead:
primary_task_id = task_ids[0] if task_ids else None
model = ApprovalRead.model_validate(approval, from_attributes=True)
return model.model_copy(update={"task_id": primary_task_id, "task_ids": task_ids})
return model.model_copy(
update={
"task_id": primary_task_id,
"task_ids": task_ids,
"task_titles": task_titles,
},
)
async def _approval_reads(
@@ -107,8 +134,17 @@ async def _approval_reads(
approvals: Sequence[Approval],
) -> list[ApprovalRead]:
mapping = await _approval_task_ids_map(session, approvals)
title_by_id = await _task_titles_by_id(
session,
task_ids={task_id for task_ids in mapping.values() for task_id in task_ids},
)
return [
_approval_to_read(approval, task_ids=mapping.get(approval.id, [])) for approval in approvals
_approval_to_read(
approval,
task_ids=(task_ids := mapping.get(approval.id, [])),
task_titles=[title_by_id[task_id] for task_id in task_ids if task_id in title_by_id],
)
for approval in approvals
]
@@ -389,7 +425,12 @@ async def create_approval(
)
await session.commit()
await session.refresh(approval)
return _approval_to_read(approval, task_ids=task_ids)
title_by_id = await _task_titles_by_id(session, task_ids=set(task_ids))
return _approval_to_read(
approval,
task_ids=task_ids,
task_titles=[title_by_id[task_id] for task_id in task_ids if task_id in title_by_id],
)
@router.patch("/{approval_id}", response_model=ApprovalRead)

View File

@@ -250,9 +250,7 @@ async def _query_wip(
if not board_ids:
return _wip_series_from_mapping(range_spec, {})
inbox_bucket_col = func.date_trunc(range_spec.bucket, Task.created_at).label(
"inbox_bucket"
)
inbox_bucket_col = func.date_trunc(range_spec.bucket, Task.created_at).label("inbox_bucket")
inbox_statement = (
select(inbox_bucket_col, func.count())
.where(col(Task.status) == "inbox")
@@ -264,9 +262,7 @@ async def _query_wip(
)
inbox_results = (await session.exec(inbox_statement)).all()
status_bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label(
"status_bucket"
)
status_bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("status_bucket")
progress_case = case((col(Task.status) == "in_progress", 1), else_=0)
review_case = case((col(Task.status) == "review", 1), else_=0)
done_case = case((col(Task.status) == "done", 1), else_=0)

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import JSON, Column
from sqlalchemy import JSON, Column, Float
from sqlmodel import Field
from app.core.time import utcnow
@@ -25,7 +25,7 @@ class Approval(QueryModel, table=True):
agent_id: UUID | None = Field(default=None, foreign_key="agents.id", index=True)
action_type: str
payload: dict[str, object] | None = Field(default=None, sa_column=Column(JSON))
confidence: int
confidence: float = Field(sa_column=Column(Float, nullable=False))
rubric_scores: dict[str, int] | None = Field(default=None, sa_column=Column(JSON))
status: str = Field(default="pending", index=True)
created_at: datetime = Field(default_factory=utcnow)

View File

@@ -11,6 +11,7 @@ from sqlmodel import Field, SQLModel
ApprovalStatus = Literal["pending", "approved", "rejected"]
STATUS_REQUIRED_ERROR = "status is required"
LEAD_REASONING_REQUIRED_ERROR = "lead reasoning is required"
RUNTIME_ANNOTATION_TYPES = (datetime, UUID)
@@ -21,7 +22,7 @@ class ApprovalBase(SQLModel):
task_id: UUID | None = None
task_ids: list[UUID] = Field(default_factory=list)
payload: dict[str, object] | None = None
confidence: int
confidence: float = Field(ge=0, le=100)
rubric_scores: dict[str, int] | None = None
status: ApprovalStatus = "pending"
@@ -48,6 +49,21 @@ class ApprovalCreate(ApprovalBase):
agent_id: UUID | None = None
@model_validator(mode="after")
def validate_lead_reasoning(self) -> Self:
"""Ensure each approval request includes explicit lead reasoning."""
payload = self.payload
if isinstance(payload, dict):
reason = payload.get("reason")
if isinstance(reason, str) and reason.strip():
return self
decision = payload.get("decision")
if isinstance(decision, dict):
nested_reason = decision.get("reason")
if isinstance(nested_reason, str) and nested_reason.strip():
return self
raise ValueError(LEAD_REASONING_REQUIRED_ERROR)
class ApprovalUpdate(SQLModel):
"""Payload for mutating approval status."""
@@ -67,6 +83,7 @@ class ApprovalRead(ApprovalBase):
id: UUID
board_id: UUID
task_titles: list[str] = Field(default_factory=list)
agent_id: UUID | None = None
created_at: datetime
resolved_at: datetime | None = None

View File

@@ -36,10 +36,21 @@ def _memory_to_read(memory: BoardMemory) -> BoardMemoryRead:
return BoardMemoryRead.model_validate(memory, from_attributes=True)
def _approval_to_read(approval: Approval, *, task_ids: list[UUID]) -> ApprovalRead:
def _approval_to_read(
approval: Approval,
*,
task_ids: list[UUID],
task_titles: list[str],
) -> ApprovalRead:
model = ApprovalRead.model_validate(approval, from_attributes=True)
primary_task_id = task_ids[0] if task_ids else None
return model.model_copy(update={"task_id": primary_task_id, "task_ids": task_ids})
return model.model_copy(
update={
"task_id": primary_task_id,
"task_ids": task_ids,
"task_titles": task_titles,
},
)
def _task_to_card(
@@ -137,13 +148,21 @@ async def build_board_snapshot(session: AsyncSession, board: Board) -> BoardSnap
session,
approval_ids=approval_ids,
)
task_title_by_id = {task.id: task.title for task in tasks}
approval_reads = [
_approval_to_read(
approval,
task_ids=task_ids_by_approval.get(
approval.id,
[approval.task_id] if approval.task_id is not None else [],
task_ids=(
linked_task_ids := task_ids_by_approval.get(
approval.id,
[approval.task_id] if approval.task_id is not None else [],
)
),
task_titles=[
task_title_by_id[task_id]
for task_id in linked_task_ids
if task_id in task_title_by_id
],
)
for approval in approvals
]

View File

@@ -5,16 +5,16 @@ from __future__ import annotations
import hashlib
from typing import Mapping
CONFIDENCE_THRESHOLD = 80
CONFIDENCE_THRESHOLD = 80.0
MIN_PLANNING_SIGNALS = 2
def compute_confidence(rubric_scores: Mapping[str, int]) -> int:
def compute_confidence(rubric_scores: Mapping[str, int]) -> float:
"""Compute aggregate confidence from rubric score components."""
return int(sum(rubric_scores.values()))
return float(sum(rubric_scores.values()))
def approval_required(*, confidence: int, is_external: bool, is_risky: bool) -> bool:
def approval_required(*, confidence: float, is_external: bool, is_risky: bool) -> bool:
"""Return whether an action must go through explicit approval."""
return is_external or is_risky or confidence < CONFIDENCE_THRESHOLD

View File

@@ -0,0 +1,39 @@
"""make approval confidence float
Revision ID: e2f9c6b4a1d3
Revises: d8c1e5a4f7b2
Create Date: 2026-02-12 20:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "e2f9c6b4a1d3"
down_revision = "d8c1e5a4f7b2"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column(
"approvals",
"confidence",
existing_type=sa.Integer(),
type_=sa.Float(),
existing_nullable=False,
)
def downgrade() -> None:
op.alter_column(
"approvals",
"confidence",
existing_type=sa.Float(),
type_=sa.Integer(),
existing_nullable=False,
)

View File

@@ -51,22 +51,25 @@ async def test_create_approval_rejects_duplicate_pending_for_same_task() -> None
async with await _make_session(engine) as session:
board, task_ids = await _seed_board_with_tasks(session, task_count=1)
task_id = task_ids[0]
await approvals_api.create_approval(
created = await approvals_api.create_approval(
payload=ApprovalCreate(
action_type="task.execute",
task_id=task_id,
payload={"reason": "Initial execution needs confirmation."},
confidence=80,
status="pending",
),
board=board,
session=session,
)
assert created.task_titles == [f"task-{task_id}"]
with pytest.raises(HTTPException) as exc:
await approvals_api.create_approval(
payload=ApprovalCreate(
action_type="task.retry",
task_id=task_id,
payload={"reason": "Retry should still be gated."},
confidence=77,
status="pending",
),
@@ -91,22 +94,25 @@ async def test_create_approval_rejects_pending_conflict_from_linked_task_ids() -
async with await _make_session(engine) as session:
board, task_ids = await _seed_board_with_tasks(session, task_count=2)
task_a, task_b = task_ids
await approvals_api.create_approval(
created = await approvals_api.create_approval(
payload=ApprovalCreate(
action_type="task.batch_execute",
task_ids=[task_a, task_b],
payload={"reason": "Batch operation requires sign-off."},
confidence=85,
status="pending",
),
board=board,
session=session,
)
assert created.task_titles == [f"task-{task_a}", f"task-{task_b}"]
with pytest.raises(HTTPException) as exc:
await approvals_api.create_approval(
payload=ApprovalCreate(
action_type="task.execute",
task_id=task_b,
payload={"reason": "Single task overlaps with pending batch."},
confidence=70,
status="pending",
),
@@ -135,6 +141,7 @@ async def test_update_approval_rejects_reopening_to_pending_with_existing_pendin
payload=ApprovalCreate(
action_type="task.execute",
task_id=task_id,
payload={"reason": "Primary pending approval is active."},
confidence=83,
status="pending",
),
@@ -145,6 +152,7 @@ async def test_update_approval_rejects_reopening_to_pending_with_existing_pendin
payload=ApprovalCreate(
action_type="task.review",
task_id=task_id,
payload={"reason": "Review decision completed earlier."},
confidence=90,
status="approved",
),

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.schemas.approvals import ApprovalCreate
def test_approval_create_requires_confidence_score() -> None:
with pytest.raises(ValidationError, match="confidence"):
ApprovalCreate.model_validate(
{
"action_type": "task.update",
"payload": {"reason": "Missing confidence should fail."},
},
)
@pytest.mark.parametrize("confidence", [-1.0, 101.0])
def test_approval_create_rejects_out_of_range_confidence(confidence: float) -> None:
with pytest.raises(ValidationError, match="confidence"):
ApprovalCreate.model_validate(
{
"action_type": "task.update",
"payload": {"reason": "Confidence must be in range."},
"confidence": confidence,
},
)
def test_approval_create_requires_lead_reasoning() -> None:
with pytest.raises(ValidationError, match="lead reasoning is required"):
ApprovalCreate.model_validate(
{
"action_type": "task.update",
"confidence": 80,
},
)
def test_approval_create_accepts_nested_decision_reason() -> None:
model = ApprovalCreate.model_validate(
{
"action_type": "task.update",
"confidence": 80,
"payload": {"decision": {"reason": "Needs manual approval."}},
},
)
assert model.payload == {"decision": {"reason": "Needs manual approval."}}
def test_approval_create_accepts_float_confidence() -> None:
model = ApprovalCreate.model_validate(
{
"action_type": "task.update",
"confidence": 88.75,
"payload": {"reason": "Fractional confidence should be preserved."},
},
)
assert model.confidence == 88.75

View File

@@ -252,7 +252,8 @@ export default function DashboardPage() {
const searchParams = useSearchParams();
const selectedRangeParam = searchParams.get("range");
const selectedRange: RangeKey =
selectedRangeParam && DASHBOARD_RANGE_SET.has(selectedRangeParam as RangeKey)
selectedRangeParam &&
DASHBOARD_RANGE_SET.has(selectedRangeParam as RangeKey)
? (selectedRangeParam as RangeKey)
: DEFAULT_RANGE;
const metricsQuery = useDashboardMetricsApiV1MetricsDashboardGet<
@@ -401,10 +402,7 @@ export default function DashboardPage() {
</div>
<div className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
<ChartCard
title="Completed Tasks"
subtitle="Throughput"
>
<ChartCard title="Completed Tasks" subtitle="Throughput">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={throughputSeries}
@@ -449,10 +447,7 @@ export default function DashboardPage() {
</ResponsiveContainer>
</ChartCard>
<ChartCard
title="Avg Hours to Review"
subtitle="Cycle time"
>
<ChartCard title="Avg Hours to Review" subtitle="Cycle time">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={cycleSeries}
@@ -501,10 +496,7 @@ export default function DashboardPage() {
</ResponsiveContainer>
</ChartCard>
<ChartCard
title="Failed Events"
subtitle="Error rate"
>
<ChartCard title="Failed Events" subtitle="Error rate">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={errorSeries}

View File

@@ -52,9 +52,14 @@ describe("BoardApprovalsPanel", () => {
linked_request: {
tasks: [
{
task_id: "task-1",
title: "Launch onboarding checklist",
description: "Create and validate the v1 onboarding checklist.",
},
{
task_id: "task-2",
title: "Publish onboarding checklist",
},
],
task_ids: ["task-1", "task-2"],
},
@@ -84,7 +89,46 @@ describe("BoardApprovalsPanel", () => {
expect(
screen.getByText("Needs explicit sign-off before rollout."),
).toBeInTheDocument();
expect(screen.getByText("62% score")).toBeInTheDocument();
expect(screen.getByText(/related tasks/i)).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Launch onboarding checklist" }),
).toHaveAttribute("href", "/boards/board-1?taskId=task-1");
expect(
screen.getByRole("link", { name: "Publish onboarding checklist" }),
).toHaveAttribute("href", "/boards/board-1?taskId=task-2");
expect(screen.getByText(/rubric scores/i)).toBeInTheDocument();
expect(screen.getByText("Clarity")).toBeInTheDocument();
});
it("uses schema task_titles for related task links when payload titles are missing", () => {
const approval = {
id: "approval-2",
board_id: "board-1",
action_type: "task.update",
confidence: 88,
status: "pending",
task_id: "task-a",
task_ids: ["task-a", "task-b"],
task_titles: ["Prepare release notes", "Publish release notes"],
created_at: "2026-02-12T11:00:00Z",
resolved_at: null,
payload: {
task_ids: ["task-a", "task-b"],
reason: "Needs sign-off before publishing.",
},
rubric_scores: null,
} as ApprovalRead;
renderWithQueryClient(
<BoardApprovalsPanel boardId="board-1" approvals={[approval]} />,
);
expect(
screen.getByRole("link", { name: "Prepare release notes" }),
).toHaveAttribute("href", "/boards/board-1?taskId=task-a");
expect(
screen.getByRole("link", { name: "Publish release notes" }),
).toHaveAttribute("href", "/boards/board-1?taskId=task-b");
});
});

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query";
@@ -28,9 +29,16 @@ import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime";
import { cn } from "@/lib/utils";
type Approval = ApprovalRead & { status: string };
const normalizeScore = (value: unknown): number => {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
return value;
};
const normalizeApproval = (approval: ApprovalRead): Approval => ({
...approval,
status: approval.status ?? "pending",
confidence: normalizeScore(approval.confidence),
});
type BoardApprovalsPanelProps = {
@@ -237,6 +245,79 @@ const approvalTaskIds = (approval: Approval) => {
return [...new Set(merged)];
};
type RelatedTaskSummary = {
id: string;
title: string;
};
const approvalRelatedTasks = (approval: Approval): RelatedTaskSummary[] => {
const payload = approval.payload ?? {};
const taskIds = approvalTaskIds(approval);
if (taskIds.length === 0) return [];
const apiTaskTitles = (
approval as Approval & { task_titles?: string[] | null }
).task_titles;
const titleByTaskId = new Map<string, string>();
const orderedTitles: string[] = [];
const collectTaskTitles = (path: string[]) => {
const tasks = payloadAtPath(payload, path);
if (!Array.isArray(tasks)) return;
for (const task of tasks) {
if (!isRecord(task)) continue;
const rawTitle = task["title"];
const title = typeof rawTitle === "string" ? rawTitle.trim() : "";
if (!title) continue;
orderedTitles.push(title);
const taskId =
typeof task["task_id"] === "string"
? task["task_id"]
: typeof task["taskId"] === "string"
? task["taskId"]
: typeof task["id"] === "string"
? task["id"]
: null;
if (taskId && taskId.trim()) {
titleByTaskId.set(taskId, title);
}
}
};
collectTaskTitles(["linked_request", "tasks"]);
collectTaskTitles(["linkedRequest", "tasks"]);
const indexedTitles = [
...(Array.isArray(apiTaskTitles) ? apiTaskTitles : []),
...orderedTitles,
...payloadValues(payload, "task_titles"),
...payloadValues(payload, "taskTitles"),
...payloadNestedValues(payload, ["linked_request", "task_titles"]),
...payloadNestedValues(payload, ["linked_request", "taskTitles"]),
...payloadNestedValues(payload, ["linkedRequest", "task_titles"]),
...payloadNestedValues(payload, ["linkedRequest", "taskTitles"]),
]
.map((value) => value.trim())
.filter((value) => value.length > 0);
const singleTitle =
payloadValue(payload, "title") ??
payloadNestedValue(payload, ["task", "title"]) ??
payloadFirstLinkedTaskValue(payload, "title");
return taskIds.map((taskId, index) => {
const resolvedTitle =
titleByTaskId.get(taskId) ??
indexedTitles[index] ??
(taskIds.length === 1 ? singleTitle : null) ??
"Untitled task";
return { id: taskId, title: resolvedTitle };
});
};
const taskHref = (boardId: string, taskId: string) =>
`/boards/${encodeURIComponent(boardId)}?taskId=${encodeURIComponent(taskId)}`;
const approvalSummary = (approval: Approval, boardLabel?: string | null) => {
const payload = approval.payload ?? {};
const taskIds = approvalTaskIds(approval);
@@ -544,6 +625,9 @@ export function BoardApprovalsPanel({
</p>
) : null}
<div className="mt-2 flex items-center gap-2 text-xs text-slate-500">
<span className="rounded bg-slate-100 px-1.5 py-0.5 font-semibold text-slate-700">
{approval.confidence}% score
</span>
<Clock className="h-3.5 w-3.5 opacity-60" />
<span>{formatTimestamp(approval.created_at)}</span>
</div>
@@ -582,10 +666,12 @@ export function BoardApprovalsPanel({
const titleText = titleRow?.value?.trim() ?? "";
const descriptionText = summary.description?.trim() ?? "";
const reasoningText = summary.reason?.trim() ?? "";
const relatedTasks = approvalRelatedTasks(selectedApproval);
const extraRows = summary.rows.filter((row) => {
const normalized = row.label.toLowerCase();
if (normalized === "title") return false;
if (normalized === "task") return false;
if (normalized === "tasks") return false;
if (normalized === "assignee") return false;
return true;
});
@@ -733,6 +819,28 @@ export function BoardApprovalsPanel({
</div>
) : null}
{relatedTasks.length > 0 ? (
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
Related tasks
</p>
<div className="flex flex-wrap gap-2">
{relatedTasks.map((task) => (
<Link
key={`${selectedApproval.id}-task-${task.id}`}
href={taskHref(
selectedApproval.board_id,
task.id,
)}
className="rounded-md border border-slate-200 bg-white px-2 py-1 text-xs text-slate-700 underline-offset-2 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 hover:underline"
>
{task.title}
</Link>
))}
</div>
</div>
) : null}
{extraRows.length > 0 ? (
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">

View File

@@ -57,10 +57,14 @@ export function DashboardSidebar() {
return (
<aside className="flex h-full w-64 flex-col border-r border-slate-200 bg-white">
<div className="flex-1 px-3 py-4">
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-slate-500">Navigation</p>
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
Navigation
</p>
<nav className="mt-3 space-y-4 text-sm">
<div>
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">Overview</p>
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
Overview
</p>
<div className="mt-1 space-y-1">
<Link
href="/dashboard"
@@ -90,7 +94,9 @@ export function DashboardSidebar() {
</div>
<div>
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">Boards</p>
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
Boards
</p>
<div className="mt-1 space-y-1">
<Link
href="/board-groups"