Skip to content

Commit fb03b8d

Browse files
sudoshiruvnet
andcommitted
feat(studies): omnipresent action-taking Abby copilot (Claude Agent SDK)
Make the Claude Agent SDK study copilot maximally interactive and assistive: present on every study tab and able to act on the researcher's behalf, every mutation approval-gated. Agent reach (ai/app/agents): - Abby gains two reads — get_study_results, get_manuscript — so it can discuss the Results tab and the composed STROBE/RECORD draft, not just gates. - Abby gains two approval-gated writes — reproject_results, open_in_publisher — so on one-click human approval it can refresh results after a gate eval and seed an editorial draft in the Publisher. Registered in _WRITE_TOOLS so the harness routes them through can_use_tool; reads stay auto-approved. - Updated the Abby system prompt: present on every tab, may take the four actions, still never decides scientific validity (gate approve/override remains human-only; reproject/open only reflect existing gate state). Backend: - New POST studies/{study}/results/reproject (permission:studies.execute) → StudyResultProjector::projectStudy. Idempotent, non-destructive; backs the reproject_results action and a manual refresh. Frontend (omnipresence): - AbbyCopilotPanel is now a fixed dock mounted once on StudyDetailPage — available on every tab (collapsed launcher <-> docked chat), with a pending-approval badge and auto-scroll. Started only on explicit intent. - New abbyDockStore + AskAbbyButton affordance: gate cards ("Why blocked?"), the Gates header, and the Results + Manuscript headers hand Abby a context-specific question; the dock auto-starts a session and sends it. Tests: Abby tool pack (9 tools / 4 writes) + 4 new tool tests (pytest), reproject endpoint + RBAC (Pest), abbyDockStore + AskAbbyButton (vitest). mypy/Pint/PHPStan/tsc/vite/eslint clean. Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent 8e16138 commit fb03b8d

17 files changed

Lines changed: 654 additions & 131 deletions

File tree

ai/app/agents/abby_tools.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,37 @@ async def get_gate_status(args: dict[str, Any]) -> dict[str, Any]:
7272
return guard
7373
return await request(ctx, "GET", f"studies/{_slug(ctx)}/gates")
7474

75+
@tool(
76+
"get_study_results",
77+
"Get the study's curated results (characterization, incidence_rate, effect_estimate, …) "
78+
"as projected into the Results tab — including each row's publishability and, for "
79+
"comparative effect estimates, the diagnostics that gate it. Pass publishable_only=true to "
80+
"see only the rows cleared for publication. Use this to discuss what the study found.",
81+
{"publishable_only": bool},
82+
)
83+
async def get_study_results(args: dict[str, Any]) -> dict[str, Any]:
84+
guard = _require_study(ctx)
85+
if guard is not None:
86+
return guard
87+
params = {"per_page": 100}
88+
if args.get("publishable_only"):
89+
params["publishable_only"] = True
90+
return await request(ctx, "GET", f"studies/{_slug(ctx)}/results", params=params)
91+
92+
@tool(
93+
"get_manuscript",
94+
"Get the composed STROBE/RECORD manuscript for the study (title, sections, and which "
95+
"effect estimates are included vs. blinded). Use this to discuss or summarize the "
96+
"draft the study currently supports. Composition is read-only — it never unblinds a "
97+
"withheld estimate.",
98+
{},
99+
)
100+
async def get_manuscript(args: dict[str, Any]) -> dict[str, Any]:
101+
guard = _require_study(ctx)
102+
if guard is not None:
103+
return guard
104+
return await request(ctx, "GET", f"studies/{_slug(ctx)}/manuscript")
105+
75106
@tool(
76107
"evaluate_gates",
77108
"Recompute the study's estimation-derived gates (study diagnostics S5, empirical "
@@ -86,11 +117,27 @@ async def evaluate_gates(args: dict[str, Any]) -> dict[str, Any]:
86117
return guard
87118
return await request(ctx, "POST", f"studies/{_slug(ctx)}/gates/evaluate", json_body={})
88119

120+
@tool(
121+
"reproject_results",
122+
"Refresh the study's curated results from the latest completed executions and the "
123+
"current gate ledger. Idempotent and non-destructive: reviewer curation is preserved and "
124+
"only the publishability of comparative effect estimates moves with the gate state. Use "
125+
"this after evaluate_gates so the Results tab and manuscript reflect the new verdict. "
126+
"A WRITE — requires explicit human approval before execution.",
127+
{},
128+
)
129+
async def reproject_results(args: dict[str, Any]) -> dict[str, Any]:
130+
guard = _require_study(ctx)
131+
if guard is not None:
132+
return guard
133+
return await request(ctx, "POST", f"studies/{_slug(ctx)}/results/reproject", json_body={})
134+
89135
@tool(
90136
"build_study_package",
91137
"Build a reproducible study-package snapshot (definition hashes, compiled-SQL "
92138
"fingerprints, calibrated results, the gate-ledger decision trail). Use once the study "
93-
"has cleared its gates and is publication-ready.",
139+
"has cleared its gates and is publication-ready. "
140+
"A WRITE — requires explicit human approval before execution.",
94141
{},
95142
)
96143
async def build_study_package(args: dict[str, Any]) -> dict[str, Any]:
@@ -99,10 +146,28 @@ async def build_study_package(args: dict[str, Any]) -> dict[str, Any]:
99146
return guard
100147
return await request(ctx, "POST", f"studies/{_slug(ctx)}/package", json_body={})
101148

149+
@tool(
150+
"open_in_publisher",
151+
"Seed (or reopen) an editorial draft in the Publisher from the study's composed "
152+
"manuscript, so the author can refine it. Returns the draft id. Use once the study's "
153+
"results are ready to write up. A WRITE — requires explicit human approval before "
154+
"execution.",
155+
{},
156+
)
157+
async def open_in_publisher(args: dict[str, Any]) -> dict[str, Any]:
158+
guard = _require_study(ctx)
159+
if guard is not None:
160+
return guard
161+
return await request(ctx, "POST", f"studies/{_slug(ctx)}/manuscript/draft", json_body={})
162+
102163
return [
103164
get_study_overview,
104165
get_study_progress,
105166
get_gate_status,
167+
get_study_results,
168+
get_manuscript,
106169
evaluate_gates,
170+
reproject_results,
107171
build_study_package,
172+
open_in_publisher,
108173
]

ai/app/agents/profiles.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,16 @@
4141
4242
The seven stages: 1 Design (PICO), 2 Phenotype, 3 Cohort diagnostics, 4 Data quality, 5 Study diagnostics, 6 Estimation + empirical calibration, 7 Publication.
4343
44+
You are present on every tab of the study workspace, so the user may ask about the design, a specific gate, a result row, or the manuscript. Read the relevant state with the get_* tools before answering, and tailor your answer to what they are looking at.
45+
4446
Rules:
45-
- Call get_gate_status to see where the study stands. Call evaluate_gates to (re)compute the estimation-derived gates from the latest diagnostics. Use the get_* tools for study state and progress.
47+
- Call get_gate_status to see where the study stands. Call evaluate_gates to (re)compute the estimation-derived gates from the latest diagnostics. Call get_study_results to read the curated Results-tab rows (publishability + gating diagnostics), and get_manuscript to read the composed STROBE/RECORD draft. Use the other get_* tools for study state and progress.
4648
- A failed gate means the study may not proceed. Explain the SPECIFIC reason from the gate metrics (e.g. "propensity-score separation: AUC 0.99, equipoise 0.01" or "only 2 informative negative controls") and propose a concrete remediation (e.g. an active-comparator design, a richer negative-control panel). Then tell the user that the PI or lead statistician must approve or override this gate in the Gates tab before effect estimates are unblinded.
49+
- You may TAKE ACTIONS on the user's behalf, but every action is approval-gated: it pauses for the user to approve before it runs. The actions are evaluate_gates (recompute gate verdicts from diagnostics), reproject_results (refresh the Results tab + manuscript from the latest executions and current gate state — run this after evaluate_gates so the results reflect the new verdict), build_study_package (snapshot a publication-ready study), and open_in_publisher (seed an editorial draft in the Publisher). Propose an action when it would move the study forward; never assume approval.
4750
- NEVER invent statistics, hazard ratios, p-values, confidence intervals, or cohort counts. Every number must come from a tool result.
48-
- NEVER claim a study is publishable while any gate is failed and not yet overridden. Only build_study_package once the gates have cleared.
51+
- NEVER claim a study is publishable while any gate is failed and not yet overridden. Only build_study_package or open_in_publisher once the gates have cleared (or a documented override is in place).
4952
- Effect estimates may be BLINDED until the study-diagnostics gate clears. If estimates are absent, that is by design — report the diagnostics, not a withheld effect.
53+
- You NEVER decide scientific validity yourself. Computing a gate verdict (evaluate_gates) is mechanical; APPROVING or OVERRIDING a failed gate is the principal investigator's and lead statistician's decision, made in the Gates tab. reproject_results and open_in_publisher only reflect the existing gate state — they never unblind a withheld estimate.
5054
- Be concise and clinical. No patient-level data ever enters your reasoning — only study designs, aggregate counts, and diagnostics. You cannot read the filesystem, run shell commands, or browse the web. Your only capabilities are the orchestration tools provided.
5155
"""
5256

ai/app/agents/tool_packs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
_WRITE_TOOLS: dict[str, set[str]] = {
3030
"study_design": set(),
3131
"publish": {"update_draft", "create_snapshot"},
32-
"abby": {"evaluate_gates", "build_study_package"},
32+
"abby": {"evaluate_gates", "reproject_results", "build_study_package", "open_in_publisher"},
3333
}
3434

3535

ai/tests/test_abby_tools.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,22 @@ def test_abby_profile_and_registry_are_wired() -> None:
3535
"get_study_overview",
3636
"get_study_progress",
3737
"get_gate_status",
38+
"get_study_results",
39+
"get_manuscript",
3840
"evaluate_gates",
41+
"reproject_results",
3942
"build_study_package",
43+
"open_in_publisher",
4044
}
4145

4246
# The execute tools are approval-gated writes (the human-in-the-loop gates);
43-
# the agent proposes but a human approves.
44-
assert write_tools("abby") == {"evaluate_gates", "build_study_package"}
47+
# the agent proposes but a human approves. Reads (get_*) stay auto-approved.
48+
assert write_tools("abby") == {
49+
"evaluate_gates",
50+
"reproject_results",
51+
"build_study_package",
52+
"open_in_publisher",
53+
}
4554

4655

4756
@respx.mock
@@ -90,6 +99,60 @@ async def test_build_study_package_posts_to_package_endpoint() -> None:
9099
assert result.get("is_error") is not True
91100

92101

102+
@respx.mock
103+
async def test_get_study_results_reads_results_endpoint() -> None:
104+
route = respx.get(f"{BASE}/studies/htn-v3/results").mock(
105+
return_value=httpx.Response(
106+
200,
107+
json={"data": [{"result_type": "effect_estimate", "is_publishable": False}]},
108+
)
109+
)
110+
tools = {t.name: t for t in build_tool_pack(_ctx())}
111+
result = await tools["get_study_results"].handler({"publishable_only": True})
112+
113+
assert route.called
114+
assert route.calls.last.request.url.params["publishable_only"] == "true"
115+
assert "effect_estimate" in result["content"][0]["text"]
116+
117+
118+
@respx.mock
119+
async def test_get_manuscript_reads_manuscript_endpoint() -> None:
120+
route = respx.get(f"{BASE}/studies/htn-v3/manuscript").mock(
121+
return_value=httpx.Response(200, json={"data": {"title": "HTN v4", "sections": []}})
122+
)
123+
tools = {t.name: t for t in build_tool_pack(_ctx())}
124+
result = await tools["get_manuscript"].handler({})
125+
126+
assert route.called
127+
assert result.get("is_error") is not True
128+
assert "HTN v4" in result["content"][0]["text"]
129+
130+
131+
@respx.mock
132+
async def test_reproject_results_posts_to_reproject_endpoint() -> None:
133+
route = respx.post(f"{BASE}/studies/htn-v3/results/reproject").mock(
134+
return_value=httpx.Response(200, json={"data": {"reprojected": 4}, "message": "ok"})
135+
)
136+
tools = {t.name: t for t in build_tool_pack(_ctx())}
137+
result = await tools["reproject_results"].handler({})
138+
139+
assert route.called
140+
assert result.get("is_error") is not True
141+
142+
143+
@respx.mock
144+
async def test_open_in_publisher_posts_to_manuscript_draft_endpoint() -> None:
145+
route = respx.post(f"{BASE}/studies/htn-v3/manuscript/draft").mock(
146+
return_value=httpx.Response(201, json={"data": {"id": 77, "study_id": 165, "title": "HTN v4"}})
147+
)
148+
tools = {t.name: t for t in build_tool_pack(_ctx())}
149+
result = await tools["open_in_publisher"].handler({})
150+
151+
assert route.called
152+
assert result.get("is_error") is not True
153+
assert "77" in result["content"][0]["text"]
154+
155+
93156
@respx.mock
94157
async def test_tools_guard_when_no_study_slug_make_no_http_call() -> None:
95158
# No routes registered — if any HTTP call were attempted, respx would raise.

backend/app/Http/Controllers/Api/V1/StudyResultController.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use App\Models\User;
99
use App\Services\Shiny\ManagedShinyAppRegistry;
1010
use App\Services\Shiny\ManagedShinyLaunchService;
11+
use App\Services\Studies\StudyResultProjector;
1112
use Illuminate\Http\JsonResponse;
1213
use Illuminate\Http\Request;
1314
use Illuminate\Validation\Rule;
@@ -125,6 +126,26 @@ public function launchShiny(Request $request, Study $study, StudyResult $result)
125126
]);
126127
}
127128

129+
/**
130+
* POST /v1/studies/{study}/results/reproject
131+
*
132+
* Re-project the study's curated `study_results` from the latest completed
133+
* analysis executions and the current gate ledger. Idempotent and
134+
* non-destructive: reviewer curation (is_primary) is preserved and only the
135+
* publishability of comparative effect estimates moves with the gate state.
136+
* Exposed so the Abby copilot can refresh results after a gate evaluation
137+
* (an approval-gated action) — and a reviewer can force a manual refresh.
138+
*/
139+
public function reproject(Study $study, StudyResultProjector $projector): JsonResponse
140+
{
141+
$count = $projector->projectStudy($study);
142+
143+
return response()->json([
144+
'data' => ['reprojected' => $count],
145+
'message' => "Re-projected {$count} result row(s) from the latest completed executions.",
146+
]);
147+
}
148+
128149
/**
129150
* @return array<string, mixed>
130151
*/

backend/routes/api.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,8 @@
876876

877877
// Results
878878
Route::get('results', [StudyResultController::class, 'index']);
879+
Route::post('results/reproject', [StudyResultController::class, 'reproject'])
880+
->middleware('permission:studies.execute');
879881
Route::get('results/{result}', [StudyResultController::class, 'show']);
880882
Route::post('results/{result}/shiny-launch', [StudyResultController::class, 'launchShiny'])
881883
->middleware('permission:studies.view');
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use App\Models\App\Characterization;
6+
use App\Models\App\Source;
7+
use App\Models\App\Study;
8+
use App\Models\App\StudyAnalysis;
9+
use App\Models\App\StudyResult;
10+
use App\Models\User;
11+
use Database\Seeders\RolePermissionSeeder;
12+
use Illuminate\Foundation\Testing\RefreshDatabase;
13+
14+
uses(RefreshDatabase::class);
15+
16+
beforeEach(function () {
17+
$this->seed(RolePermissionSeeder::class);
18+
});
19+
20+
/**
21+
* The reproject endpoint backs the Abby copilot's approval-gated
22+
* `reproject_results` action: it refreshes curated study_results from the latest
23+
* completed executions and the current gate ledger, idempotently.
24+
*/
25+
it('reprojects curated study_results from the latest completed execution', function () {
26+
$user = User::factory()->create();
27+
$user->assignRole('researcher'); // carries studies.execute
28+
29+
$study = Study::factory()->create(['created_by' => $user->id]);
30+
$source = Source::create(['source_name' => 'Test CDM', 'source_key' => 'TEST']);
31+
32+
$analysis = Characterization::create(['name' => 'Baseline', 'author_id' => $user->id, 'design_json' => []]);
33+
StudyAnalysis::create([
34+
'study_id' => $study->id,
35+
'analysis_type' => Characterization::class,
36+
'analysis_id' => $analysis->id,
37+
]);
38+
$analysis->executions()->create([
39+
'source_id' => $source->id,
40+
'status' => 'completed',
41+
'result_json' => [
42+
'results' => [[
43+
'cohort_id' => 5441, 'cohort_name' => 'Target', 'person_count' => 109763,
44+
'features' => ['demographics' => []],
45+
]],
46+
],
47+
]);
48+
49+
// Prove the endpoint — not the observer — populates the row.
50+
StudyResult::where('study_id', $study->id)->delete();
51+
52+
$this->actingAs($user)
53+
->postJson("/api/v1/studies/{$study->slug}/results/reproject")
54+
->assertOk()
55+
->assertJsonPath('data.reprojected', 1);
56+
57+
expect(
58+
StudyResult::where('study_id', $study->id)->where('result_type', 'characterization')->count()
59+
)->toBe(1);
60+
});
61+
62+
it('forbids reproject without the studies.execute permission', function () {
63+
$user = User::factory()->create();
64+
$user->assignRole('viewer'); // read-only — no studies.execute
65+
66+
$study = Study::factory()->create(['created_by' => $user->id]);
67+
68+
$this->actingAs($user)
69+
->postJson("/api/v1/studies/{$study->slug}/results/reproject")
70+
->assertForbidden();
71+
});

docs/lineage/operations/2026-06-11-study-results-projection.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ related_code:
1313
- backend/app/Services/Studies/StudyResultProjector.php
1414
- backend/app/Observers/AnalysisExecutionObserver.php
1515
- backend/app/Console/Commands/Studies/BackfillStudyResultsCommand.php
16+
- backend/app/Http/Controllers/Api/V1/StudyResultController.php
17+
- ai/app/agents/abby_tools.py
1618
related_prs: []
1719
related_adr: docs/lineage/decisions/adr/adr-0020-protocol-to-publication-pipeline.md
1820
---
@@ -118,6 +120,43 @@ duplicate). Code fixes:
118120
S5 approval/override — previously the row stayed stale while the live
119121
manuscript reflected the decision.
120122

123+
## Copilot reach: action-taking Abby (same day)
124+
125+
The projector now backs an approval-gated action on the omnipresent Abby
126+
copilot. New route:
127+
128+
```
129+
POST /api/v1/studies/{study}/results/reproject (permission:studies.execute)
130+
→ StudyResultController::reproject → StudyResultProjector::projectStudy
131+
```
132+
133+
Idempotent and non-destructive (preserves `is_primary`; only effect-estimate
134+
publishability moves with the gate state), so it is safe to expose. It exists so
135+
Abby can refresh the Results tab + manuscript after an `evaluate_gates`
136+
previously only a gate approve/override or a fresh execution re-projected.
137+
138+
The Abby Claude Agent SDK profile (`ai/app/agents/abby_tools.py`) gained four
139+
tools so the copilot can both **see** and **act on** the full study lifecycle:
140+
141+
| Tool | Kind | Wraps |
142+
|---|---|---|
143+
| `get_study_results` | read (auto) | `GET studies/{slug}/results` |
144+
| `get_manuscript` | read (auto) | `GET studies/{slug}/manuscript` |
145+
| `reproject_results` | write (**approval-gated**) | `POST studies/{slug}/results/reproject` |
146+
| `open_in_publisher` | write (**approval-gated**) | `POST studies/{slug}/manuscript/draft` |
147+
148+
Writes route through the harness `can_use_tool` gate (`tool_packs._WRITE_TOOLS`),
149+
so every mutation streams an `agent.approval.request` card the PI/author must
150+
accept. Abby still never decides scientific validity — `reproject_results` and
151+
`open_in_publisher` only reflect the existing gate state; gate approve/override
152+
stays human-only in the Gates tab.
153+
154+
Frontend: `AbbyCopilotPanel` is now a fixed dock mounted once on `StudyDetailPage`
155+
(present on every tab, collapsed launcher ↔ docked chat). Inline `AskAbbyButton`
156+
affordances (gate cards "Why blocked?", the Results and Manuscript headers) hand
157+
Abby a context-specific question via `abbyDockStore`, which auto-starts a session
158+
and sends it.
159+
121160
## Rollback
122161

123162
`migrate:rollback` drops `analysis_execution_id` and restores `NOT NULL` on

0 commit comments

Comments
 (0)