Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
c3cfb17
docs(digest-executable-followups): idea-preflight patches + feature s…
SoundMindsAI May 24, 2026
a380e87
docs(digest-executable-followups): implementation plan (1 GPT-5.5 cyc…
SoundMindsAI May 24, 2026
6e62af2
feat(digest): FollowupItem union + parse/serialize helpers (Story 1.1)
SoundMindsAI May 24, 2026
c6002f2
feat(db): studies parent_proposal lineage + digests JSONB followups (…
SoundMindsAI May 24, 2026
e8e447c
test(db): parent_proposal CHECK + ON DELETE trigger + JSONB migration…
SoundMindsAI May 24, 2026
cd67b25
feat(digest): structured-output followups + prompt updates (Stories 2…
SoundMindsAI May 24, 2026
26c775f
feat(api): DigestResponse + _DigestEmbed suggested_followups discrimi…
SoundMindsAI May 24, 2026
95fde66
feat(api): POST /api/v1/studies accepts optional parent lineage (Stor…
SoundMindsAI May 24, 2026
c606665
feat(ui): kind-discriminated followup cards + Run-followup prefill fl…
SoundMindsAI May 24, 2026
a9e2e45
test(ui): E2E happy path for Run-this-followup flow (Story 6.1)
SoundMindsAI May 24, 2026
7407648
docs(digest-executable-followups): post-implementation docs updates
SoundMindsAI May 24, 2026
53f4a64
fix(digest): use search_space_json string in OpenAI structured-output…
SoundMindsAI May 24, 2026
7e704fa
fix(digest): add FOLLOWUP_KIND_VALUES tuple constant for verify-enum …
SoundMindsAI May 24, 2026
a66652a
fix(digest): apply Gemini Code Assist findings on PR #225
SoundMindsAI May 24, 2026
72afe4a
docs(digest-executable-followups): document search_space_json plan drift
SoundMindsAI May 24, 2026
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
13 changes: 12 additions & 1 deletion architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ backend/
CI / runner-up gap / late-trial 1σ / convergence regime /
per-query outcome helpers; pure-Python orchestrator
returning None on every FR-7 degraded path);
study/followups.py (feat_digest_executable_followups —
FollowupItem discriminated union (narrow/widen/text) +
parse_followup_list defensive ingest + serialize_followup_list
JSONB serializer; the worker validates LLM payloads through
this module, downgrading invalid narrow/widen items to text);
git/{redaction,validation}.py from feat_github_pr_worker
(GitHub PAT redaction + repo_url + config_path validators)
adapters/ engine adapters — protocol.py (SearchAdapter Protocol +
Expand Down Expand Up @@ -193,7 +198,13 @@ migrations/ Alembic config + versions/ (0001 baseline + 0002 clusters
feat_data_table_primitive + 0014 clusters_target_filter
from feat_cluster_target_filter + 0015 trials_per_query_metrics
from feat_pr_metric_confidence — nullable JSONB column +
CHECK constraint enforcing IS NULL OR jsonb_typeof = 'object')
CHECK constraint enforcing IS NULL OR jsonb_typeof = 'object'
+ 0018 studies_parent_proposal + 0019 digests_suggested_followups_jsonb
from feat_digest_executable_followups — paired
studies.parent_proposal_id/parent_proposal_followup_index
columns with CHECK + partial index + BEFORE DELETE trigger
+ digests.suggested_followups column-type change to JSONB
via PL/pgSQL helper functions)
docs/ 00_overview / 01_architecture / 02_product / 03_runbooks /
04_security / 05_quality / 08_guides
```
Expand Down
11 changes: 11 additions & 0 deletions backend/app/api/v1/_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,16 @@ class SeedCompletedStudyRequest(BaseModel):
"Optional per-query metrics for the runner-up trial; pairs with `winner_per_query`."
),
)
suggested_followups: list[dict[str, Any]] | None = Field(
default=None,
description=(
"feat_digest_executable_followups Story 6.1 — optional structured "
"FollowupItem list (`[{kind, rationale, search_space}]`) to seed "
"on the digest. When omitted, the seeder writes two default text-kind "
"items. The E2E Run-followup spec passes a `narrow` item so it can "
"drive the per-card Run button + modal prefill flow."
),
)


class SeedCompletedStudyResponse(BaseModel):
Expand Down Expand Up @@ -164,6 +174,7 @@ async def seed_completed_study( # pragma: no cover - integration only
with_pending_proposal=body.with_pending_proposal,
winner_per_query=body.winner_per_query,
runner_up_per_query=body.runner_up_per_query,
suggested_followups=body.suggested_followups,
)
await db.commit()
return SeedCompletedStudyResponse(
Expand Down
18 changes: 16 additions & 2 deletions backend/app/api/v1/proposals.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
)
from backend.app.db.repo.proposal import _PROPOSAL_SORT_COLUMNS, InvalidStateTransition
from backend.app.db.session import get_db
from backend.app.domain.study.followups import parse_followup_list
from backend.app.services import agent_proposals_dispatch

router = APIRouter()
Expand Down Expand Up @@ -158,7 +159,14 @@ async def _assemble_proposal_detail(db: AsyncSession, proposal: Proposal) -> Pro
narrative=digest.narrative,
parameter_importance=digest.parameter_importance,
recommended_config=digest.recommended_config,
suggested_followups=digest.suggested_followups,
# feat_digest_executable_followups Story 4.1 — wrap the raw
# JSONB through the defensive parser so legacy or malformed
# payloads never crash the response.
suggested_followups=parse_followup_list(
digest.suggested_followups,
study_id=digest.study_id,
proposal_id=proposal.id,
),
generated_at=digest.generated_at,
)

Expand Down Expand Up @@ -288,7 +296,13 @@ async def get_study_digest(
narrative=digest.narrative,
parameter_importance=digest.parameter_importance,
recommended_config=digest.recommended_config,
suggested_followups=digest.suggested_followups,
# feat_digest_executable_followups Story 4.1 — wrap the raw JSONB
# through the defensive parser so legacy or malformed payloads
# never crash the response.
suggested_followups=parse_followup_list(
digest.suggested_followups,
study_id=digest.study_id,
),
generated_by=digest.generated_by,
generated_at=digest.generated_at,
)
Expand Down
44 changes: 40 additions & 4 deletions backend/app/api/v1/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from backend.app.adapters.protocol import TargetInfo
from backend.app.core.settings import get_settings
from backend.app.domain.study.confidence import ConfidenceShape as ConfidenceShape
from backend.app.domain.study.followups import FollowupItem as FollowupItem

# ``ConfidenceShape`` is defined in :mod:`backend.app.domain.study.confidence`
# (the canonical assembler module per Story 1.3). The explicit ``as`` re-export
Expand Down Expand Up @@ -610,13 +611,36 @@ def _validate_auto_followup_depth(self) -> StudyConfigSpec:
return self


class ParentFollowupRef(BaseModel):
"""Optional lineage payload on ``POST /api/v1/studies``.

feat_digest_executable_followups FR-11 — when the operator clicks
"Run this followup" on a proposal's digest card, the create-study
payload carries the parent proposal's id + the 0-based index into
the digest's ``suggested_followups`` array so the spawned study
remembers where it came from.

``proposal_id`` is a UUIDv7 (36-char hex). The exact-length bound
forces malformed strings to surface as 422 ``VALIDATION_ERROR``
rather than reach the DB FK check and emerge as a 404
``PROPOSAL_NOT_FOUND``.
"""

proposal_id: str = Field(min_length=36, max_length=36)
followup_index: int = Field(ge=0)


class CreateStudyRequest(BaseModel):
"""``POST /api/v1/studies`` body.

``search_space`` is validated post-Pydantic-parse via
:class:`backend.app.domain.study.search_space.SearchSpace` so
:exc:`pydantic.ValidationError` produces the spec's 400
``INVALID_SEARCH_SPACE`` (per Story 3.3 task 2).

feat_digest_executable_followups Story 4.2 — optional ``parent`` field
records the parent proposal + followup-index lineage when the study
was spawned from a digest "Run this followup" action (FR-11).
"""

name: str = Field(min_length=1, max_length=256)
Expand All @@ -628,6 +652,7 @@ class CreateStudyRequest(BaseModel):
search_space: dict[str, Any]
objective: ObjectiveSpec
config: StudyConfigSpec
parent: ParentFollowupRef | None = None


class TrialsSummaryShape(BaseModel):
Expand Down Expand Up @@ -939,14 +964,21 @@ class CalibrationResponse(BaseModel):


class DigestResponse(BaseModel):
"""Body of ``GET /api/v1/studies/{id}/digest`` (FR-3 / AC-3)."""
"""Body of ``GET /api/v1/studies/{id}/digest`` (FR-3 / AC-3).

feat_digest_executable_followups Story 4.1 — ``suggested_followups`` is
now a discriminated-union list (NarrowFollowup | WidenFollowup |
TextFollowup), populated by the digest handler via
``parse_followup_list(digest.suggested_followups, ...)`` so legacy or
malformed JSONB payloads never crash the response.
"""

id: str
study_id: str
narrative: str
parameter_importance: dict[str, float]
recommended_config: dict[str, Any]
suggested_followups: list[str]
suggested_followups: list[FollowupItem]
generated_by: str
generated_at: datetime

Expand Down Expand Up @@ -997,13 +1029,17 @@ class _StudySummary(BaseModel):


class _DigestEmbed(BaseModel):
"""Inline digest summary on the proposal-detail response."""
"""Inline digest summary on the proposal-detail response.

feat_digest_executable_followups Story 4.1 — ``suggested_followups`` is
now a discriminated-union list (see ``DigestResponse``).
"""

id: str
narrative: str
parameter_importance: dict[str, float]
recommended_config: dict[str, Any]
suggested_followups: list[str]
suggested_followups: list[FollowupItem]
generated_at: datetime


Expand Down
62 changes: 62 additions & 0 deletions backend/app/api/v1/studies.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
from backend.app.db import repo
from backend.app.db.models import Study
from backend.app.db.session import get_db
from backend.app.domain.study.followups import parse_followup_list
from backend.app.domain.study.search_space import (
MissingDeclaredParamError,
SearchSpace,
Expand Down Expand Up @@ -318,6 +319,65 @@ async def create_study(
False,
)

# 3d. Parent-followup lineage validation
# (feat_digest_executable_followups Story 4.2, FR-11).
# When the operator clicks "Run this followup" the modal POSTs with
# ``parent: {proposal_id, followup_index}``. We validate:
# 1. The proposal exists (404 PROPOSAL_NOT_FOUND, non-retryable).
# 2. The proposal's parent study has a digest already
# (404 DIGEST_NOT_FOUND, retryable — the digest worker may
# still be running).
# 3. The followup_index is within bounds against the digest's
# parsed suggested_followups (422 FOLLOWUP_INDEX_OUT_OF_RANGE,
# non-retryable — caused by a stale UI; operator must refresh).
parent_proposal_id: str | None = None
parent_followup_index: int | None = None
if body.parent is not None:
proposal_row = await repo.get_proposal(db, body.parent.proposal_id)
if proposal_row is None:
raise _err(
404,
"PROPOSAL_NOT_FOUND",
f"proposal {body.parent.proposal_id} not found",
False,
)
if proposal_row.study_id is None:
# Manual proposals have no parent study → no digest → no
# followups. Treat as DIGEST_NOT_FOUND (non-retryable since
# no digest will ever be generated for a manual proposal).
raise _err(
404,
"DIGEST_NOT_FOUND",
f"proposal {proposal_row.id} is manual and has no digest",
False,
)
parent_digest = await repo.get_digest_for_study(db, proposal_row.study_id)
if parent_digest is None:
raise _err(
404,
"DIGEST_NOT_FOUND",
f"proposal {proposal_row.id} has no digest yet",
True,
)
parsed_followups = parse_followup_list(
parent_digest.suggested_followups,
study_id=parent_digest.study_id,
proposal_id=proposal_row.id,
)
if body.parent.followup_index >= len(parsed_followups):
raise _err(
422,
"FOLLOWUP_INDEX_OUT_OF_RANGE",
(
f"parent.followup_index={body.parent.followup_index} exceeds the "
f"digest's suggested_followups length ({len(parsed_followups)}) "
f"for proposal {proposal_row.id}"
),
False,
)
parent_proposal_id = body.parent.proposal_id
parent_followup_index = body.parent.followup_index

# 4. Serialize config with exclude_none + exclude_unset (C3-F1 + Story 1.5).
config_payload = body.config.model_dump(exclude_none=True, exclude_unset=True)

Expand All @@ -337,6 +397,8 @@ async def create_study(
config=config_payload,
status="queued",
optuna_study_name=study_id,
parent_proposal_id=parent_proposal_id,
parent_proposal_followup_index=parent_followup_index,
)
await db.commit()

Expand Down
41 changes: 25 additions & 16 deletions backend/app/db/models/digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,20 @@
the runbook escape hatch (``DELETE FROM digests WHERE study_id = ...`` + Arq
re-enqueue) per spec §19 Decision log.

The ``suggested_followups`` column is ``Mapped[list[str]]`` (NOT
``Optional[list[str]]``) per cycle-1 F1 of the GPT-5.5 plan review — the
migration has ``server_default ARRAY[]::TEXT[]`` so the worker can write an
empty list (zero-trials AC-2 path; capability-fallback AC-11 path) without
the API layer carrying a NULL-vs-empty branch.
The ``suggested_followups`` column is ``Mapped[list[dict[str, Any]]]`` —
JSONB carrying the discriminated-union ``FollowupItem`` shape
(``{kind, rationale, search_space}`` per
``backend.app.domain.study.followups``). Migration 0019 converted the
historical ``ARRAY(Text)`` rows into the structured shape via a PL/pgSQL
helper that wraps each text element as
``{"kind": "text", "rationale": <text>, "search_space": null}``.

The column is NOT NULL with ``server_default '[]'::jsonb`` so the worker
can write an empty list (zero-trials AC-2 path; capability-fallback
AC-11 path) without the API layer carrying a NULL-vs-empty branch.
Defensive read-path: every consumer wraps via
``parse_followup_list()`` so legacy or malformed payloads don't crash
the response.
"""

from __future__ import annotations
Expand All @@ -19,7 +28,7 @@
from typing import Any

from sqlalchemy import DateTime, ForeignKey, String, Text, func, text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column

from backend.app.db.base import Base
Expand All @@ -46,19 +55,19 @@ class Digest(Base):
"""Deterministically computed from the best trial's params filtered to
currently-declared template params (NOT LLM-generated; per FR-5 cycle-1
F5 / cycle-2 F1)."""
suggested_followups: Mapped[list[str]] = mapped_column(
ARRAY(Text),
suggested_followups: Mapped[list[dict[str, Any]]] = mapped_column(
JSONB,
nullable=False,
# Use sqlalchemy.text() (a TextClause), not func.text() (a SQL
# function call). The migration uses sa.text("ARRAY[]::TEXT[]")
# so this matches; ORM server_default is only consumed by
# Base.metadata.create_all (we use Alembic), but keep the two
# in sync for correctness (final-review F5 from GPT-5.5).
server_default=text("ARRAY[]::TEXT[]"),
# Match the migration 0019 default. ORM server_default is only
# consumed by Base.metadata.create_all (we use Alembic), but keep
# the two in sync for correctness.
server_default=text("'[]'::jsonb"),
)
"""List of LLM-suggested next-steps; max 5 entries (enforced in worker
via the structured-output schema's ``maxItems: 5``). NOT NULL with empty
default per cycle-1 F1."""
via the structured-output schema's ``maxItems: 5``). Each element is a
``FollowupItem`` dict (``{kind: 'narrow'|'widen'|'text', rationale: str,
search_space: SearchSpace | null}``). NOT NULL with ``'[]'::jsonb``
default."""
generated_by: Mapped[str] = mapped_column(Text, nullable=False)
"""Provider + model identifier (e.g. ``openai:gpt-4o-2024-08-06``) or
``local:zero_trials`` for the AC-2 placeholder path."""
Expand Down
21 changes: 20 additions & 1 deletion backend/app/db/models/study.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@
- ``optuna_study_name`` UNIQUE — convention is ``str(studies.id)`` so the
Optuna RDB row is trivially traceable to the application row.
- ``parent_study_id`` self-FK — for forks (MVP2).
- ``parent_proposal_id`` / ``parent_proposal_followup_index`` — lineage
for studies spawned from a digest "Run this followup" action
(feat_digest_executable_followups). Both must be NULL or both must
be set with index ≥ 0 (DB CHECK ``studies_parent_proposal_pair_check``);
the BEFORE DELETE trigger on ``proposals`` NULLs the pair atomically
when the parent proposal is hard-deleted.
- ``best_metric`` / ``best_trial_id`` — denormalized for fast study-list
rendering; populated by the orchestrator on completion.
"""
Expand All @@ -26,7 +32,7 @@
from datetime import datetime
from typing import Any

from sqlalchemy import CheckConstraint, DateTime, Float, ForeignKey, String, Text, func
from sqlalchemy import CheckConstraint, DateTime, Float, ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column

Expand Down Expand Up @@ -73,6 +79,19 @@ class Study(Base):
String(36), ForeignKey("studies.id"), nullable=True
)
"""Self-FK for fork lineage (MVP2 surface)."""
parent_proposal_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("proposals.id"), nullable=True
)
"""FK to the parent proposal whose digest spawned this study
(feat_digest_executable_followups). Paired with
``parent_proposal_followup_index``; the DB CHECK
``studies_parent_proposal_pair_check`` enforces both-set-or-both-NULL.
A BEFORE DELETE trigger on ``proposals`` NULLs the pair atomically
when the parent proposal is hard-deleted."""
parent_proposal_followup_index: Mapped[int | None] = mapped_column(Integer, nullable=True)
"""0-based index into the parent digest's ``suggested_followups``
array. Recorded for audit only — the followup payload itself was
inlined into ``search_space`` / ``name`` at study-create time."""
baseline_metric: Mapped[float | None] = mapped_column(Float, nullable=True)
"""Single non-Optuna trial run before Optuna starts; populated by the orchestrator."""
best_metric: Mapped[float | None] = mapped_column(Float, nullable=True)
Expand Down
Loading
Loading