Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 31 additions & 13 deletions druks_panel/migrations/versions/panel_0001_decisions.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,56 @@
"""panel: decisions table
"""decisions

Revision ID: panel_0001
Revises:
Create Date: 2026-08-29 00:00:00.000000

Create Date: 2026-09-27 18:53:32.944310
"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

revision = "panel_0001"
down_revision = None
branch_labels = None
depends_on = None
revision: str = "panel_0001"
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"panel_decisions",
sa.Column("id", sa.Integer(), nullable=False),
"panel_decision",
sa.Column("title", sa.String(), nullable=False),
sa.Column("question", sa.Text(), nullable=False),
sa.Column("context", sa.Text(), nullable=False),
sa.Column("assessments", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("synthesis", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("recommendation", sa.String(), nullable=True),
sa.Column("outcome", sa.String(), nullable=True),
sa.Column(
"recommendation",
sa.Enum(
"proceed", "revise", "pass", native_enum=False, create_constraint=True, length=64
),
nullable=True,
),
sa.Column(
"outcome",
sa.Enum(
"proceed", "revise", "pass", native_enum=False, create_constraint=True, length=64
),
nullable=True,
),
sa.Column("outcome_note", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###


def downgrade() -> None:
op.drop_table("panel_decisions")
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("panel_decision")
# ### end Alembic commands ###
42 changes: 9 additions & 33 deletions druks_panel/models.py
Original file line number Diff line number Diff line change
@@ -1,51 +1,27 @@
from datetime import datetime
from typing import Any

from druks.db import StoredSubject, db_session
from sqlalchemy import Text, select
from druks.db import StoredSubject
from sqlalchemy import Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column

from druks_panel.schemas import DecisionSummary
from druks_panel.types import DecisionAction


class Decision(StoredSubject):
__tablename__ = "panel_decisions"

class Decision(StoredSubject, ordering=("-created_at", "-id")):
title: Mapped[str]
question: Mapped[str] = mapped_column(Text)
context: Mapped[str] = mapped_column(Text, default="")
assessments: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, default=list)
synthesis: Mapped[dict[str, Any] | None] = mapped_column(JSONB, default=None)
recommendation: Mapped[str | None]
outcome: Mapped[str | None]
recommendation: Mapped[DecisionAction | None]
outcome: Mapped[DecisionAction | None]
outcome_note: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(default=StoredSubject.utc_now)
decided_at: Mapped[datetime | None]

@classmethod
async def create(cls, *, title: str, question: str, context: str = "") -> "Decision":
decision = cls(title=title, question=question, context=context)
db_session().add(decision)
await db_session().flush()
return decision

@classmethod
async def get(cls, decision_id: int) -> "Decision | None":
return await db_session().get(cls, decision_id)

@classmethod
async def list_recent(cls, *, limit: int = 100) -> list["Decision"]:
stmt = select(cls).order_by(cls.created_at.desc(), cls.id.desc()).limit(limit)
return list(await db_session().scalars(stmt))

def get_summary(self) -> DecisionSummary:
return DecisionSummary.model_validate(self)

@classmethod
async def list_summaries(cls, account_id: str | None) -> list[DecisionSummary]:
return [decision.get_summary() for decision in await cls.list_recent()]
def __str__(self) -> str:
return self.title

async def save_panel(
self,
Expand All @@ -57,10 +33,10 @@ async def save_panel(
self.assessments = assessments
self.synthesis = synthesis
self.recommendation = recommendation
await self.session.flush()
await self.save()

async def save_outcome(self, *, action: DecisionAction, note: str) -> None:
self.outcome = action
self.outcome_note = note
self.decided_at = self.utc_now()
await self.session.flush()
await self.save()
121 changes: 57 additions & 64 deletions druks_panel/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def _human_call(decision: Decision, status: SubjectStatus) -> ui.Section:

@ui.page("/")
async def decisions():
recent = await Decision.list_recent()
recent = await Decision.all()
statuses = await Decision.get_statuses([decision.id for decision in recent])
return ui.Page(
"Decisions",
Expand Down Expand Up @@ -203,70 +203,63 @@ async def new_decision():

@ui.page("/decisions/{decision_id}", subject=Decision)
async def decision(decision_id: int):
found = await Decision.get(decision_id)
if found:
status = await found.get_status()
assessments = [AdvisorAssessment.model_validate(item) for item in found.assessments]
return ui.Page(
found.title,
description=found.question,
# The whole page follows: an operator watches each advisor land. A
# parked run publishes nothing, so a redraw cannot wipe the form below.
follows=found,
blocks=[
ui.Facts(
[
ui.Fact("Panel", value=_run_word(status)),
ui.Fact("Recommends", value=_action_word(found.recommendation)),
ui.Fact("Outcome", value=_action_word(found.outcome)),
ui.Fact("Opened", value=ui.TimeValue(found.created_at)),
]
),
ui.Section(
title="Context",
blocks=[ui.Markdown(found.context or "Nobody added context.")],
),
ui.Cards(
title="The advisors",
cards=[
ui.Card(
title=assessment.perspective.title(),
description=assessment.headline,
blocks=[
ui.Facts(
[
ui.Fact("Position", value=_position_word(assessment)),
ui.Fact(
"Confidence",
value=ui.NumberValue(assessment.confidence, unit="%"),
),
]
),
ui.List(
[ui.TextValue(item) for item in assessment.rationale],
title="Rationale",
),
ui.List(
[ui.TextValue(item) for item in assessment.uncertainties],
title="Uncertainties",
),
],
)
for assessment in assessments
],
empty=ui.EmptyState(
"No assessment yet",
description="Each advisor reports here when it finishes.",
),
),
_synthesis(found),
_human_call(found, status),
ui.Link("Everything Druks did about this decision", subject=found),
],
)
decision = await Decision.get(id=decision_id)
status = await decision.get_status()
assessments = [AdvisorAssessment.model_validate(item) for item in decision.assessments]
return ui.Page(
f"Decision {decision_id}",
decision.title,
description=decision.question,
# The whole page follows: an operator watches each advisor land. A
# parked run publishes nothing, so a redraw cannot wipe the form below.
follows=decision,
blocks=[
ui.EmptyState("No such decision", controls=[ui.Link("Decisions", page="decisions")])
ui.Facts(
[
ui.Fact("Panel", value=_run_word(status)),
ui.Fact("Recommends", value=_action_word(decision.recommendation)),
ui.Fact("Outcome", value=_action_word(decision.outcome)),
ui.Fact("Opened", value=ui.TimeValue(decision.created_at)),
]
),
ui.Section(
title="Context",
blocks=[ui.Markdown(decision.context or "Nobody added context.")],
),
ui.Cards(
title="The advisors",
cards=[
ui.Card(
title=assessment.perspective.title(),
description=assessment.headline,
blocks=[
ui.Facts(
[
ui.Fact("Position", value=_position_word(assessment)),
ui.Fact(
"Confidence",
value=ui.NumberValue(assessment.confidence, unit="%"),
),
]
),
ui.List(
[ui.TextValue(item) for item in assessment.rationale],
title="Rationale",
),
ui.List(
[ui.TextValue(item) for item in assessment.uncertainties],
title="Uncertainties",
),
],
)
for assessment in assessments
],
empty=ui.EmptyState(
"No assessment yet",
description="Each advisor reports here when it finishes.",
),
),
_synthesis(decision),
_human_call(decision, status),
ui.Link("Everything Druks did about this decision", subject=decision),
],
)
8 changes: 3 additions & 5 deletions druks_panel/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@


@router.get("", response_model=list[DecisionSummary], response_model_by_alias=True)
async def list_decisions() -> list[DecisionSummary]:
return [decision.get_summary() for decision in await Decision.list_recent()]
async def list_decisions() -> list[Decision]:
return await Decision.all()


@router.post(
Expand All @@ -42,9 +42,7 @@ async def create_decision(body: CreateDecisionRequest) -> CreateDecisionResponse
operation_id="record_outcome",
)
async def record_outcome(decision_id: int, body: DecisionOutcomeRequest) -> dict[str, str]:
decision = await Decision.get(decision_id)
if not decision:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"No decision {decision_id}.")
decision = await Decision.get(id=decision_id)
try:
await RecordDecision.answer(decision, action=body.action, note=body.note)
except WorkflowError as error:
Expand Down
7 changes: 5 additions & 2 deletions druks_panel/schemas.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from datetime import datetime

from druks.schemas import Schema
from druks.workflows import SubjectSummary
from pydantic import BaseModel, ConfigDict, Field

from druks_panel.types import DecisionAction
Expand All @@ -27,7 +26,11 @@ class CreateDecisionResponse(Schema):
run_id: str


class DecisionSummary(SubjectSummary):
class DecisionSummary(Schema):
model_config = ConfigDict(from_attributes=True)

id: int
title: str
question: str
recommendation: DecisionAction | None
outcome: DecisionAction | None
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ license-files = ["LICENSE"]
authors = [{ name = "Paulo Alvarado Garcia" }]
keywords = ["agents", "decisions", "druks", "durable-execution", "workflows"]
requires-python = ">=3.11"
dependencies = ["druks>=0.6.0"]
dependencies = ["druks>=0.8.0"]

[project.urls]
Documentation = "https://docs.druks.ai"
Expand Down