From 9c44ab9eeb0c79e3b4828696a1ca0511f490417f Mon Sep 17 00:00:00 2001 From: Paulo Date: Sun, 27 Sep 2026 17:23:33 +0200 Subject: [PATCH] Read and write decisions through the Druks Model surface Druks now gives an app row a Model base. A StoredSubject gets its table, timestamps, board, and 404. A subject's __str__ is its name, and a model declares its order on the class line. Decision keeps only what is its own. - Decision drops its table name, created_at, create(), get(), get_summary(), list_summaries(), and list_recent(). The table is now panel_decision. updated_at stamps itself. - Decision names itself with its title, so runs, Activity, and the board show the title. It declares newest-first ordering, which the board and all() follow. - The Decisions page and the list route read every decision with all(). - recommendation and outcome are DecisionAction columns. The database refuses any other value. - DecisionSummary is a plain schema for the list route, which returns the rows. - The outcome route and the decision page read the row with get(id=...). A missing decision is a 404 on the route. On the page, it is an empty state that links back to Decisions. - The save methods write through save(). - One regenerated revision creates panel_decision. No install holds Panel data, so the old revision goes. - Require druks>=0.8.0, the first release with this surface. --- .../versions/panel_0001_decisions.py | 44 +++++-- druks_panel/models.py | 42 ++---- druks_panel/pages.py | 121 +++++++++--------- druks_panel/routes.py | 8 +- druks_panel/schemas.py | 7 +- pyproject.toml | 2 +- 6 files changed, 106 insertions(+), 118 deletions(-) diff --git a/druks_panel/migrations/versions/panel_0001_decisions.py b/druks_panel/migrations/versions/panel_0001_decisions.py index 73bc052..e2f2162 100644 --- a/druks_panel/migrations/versions/panel_0001_decisions.py +++ b/druks_panel/migrations/versions/panel_0001_decisions.py @@ -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 ### diff --git a/druks_panel/models.py b/druks_panel/models.py index 3ecf7ff..b61df01 100644 --- a/druks_panel/models.py +++ b/druks_panel/models.py @@ -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, @@ -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() diff --git a/druks_panel/pages.py b/druks_panel/pages.py index 8fa1226..e26c293 100644 --- a/druks_panel/pages.py +++ b/druks_panel/pages.py @@ -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", @@ -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), ], ) diff --git a/druks_panel/routes.py b/druks_panel/routes.py index fc8af19..f076696 100644 --- a/druks_panel/routes.py +++ b/druks_panel/routes.py @@ -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( @@ -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: diff --git a/druks_panel/schemas.py b/druks_panel/schemas.py index 56b1691..b7ed4e6 100644 --- a/druks_panel/schemas.py +++ b/druks_panel/schemas.py @@ -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 @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 8727590..31b8c26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"