Spaces:
Running
Running
feat: audit trail for AI drafts + disabled-button reasons everywhere
Browse files- ConstructGeneration.items stores the ORIGINAL drafted items (PI request
2026-08-07): run metadata now shows the as-drafted text alongside the
researcher's saved/edited items
- Draft-items button disabled until Name + Description exist, reason
inline (same pattern as Save; errors in the top banner go unseen)
- Run button gets a 'To run, still needed: ...' line when disabled
- AI generation quota shown from tab-open via auth/me, not only after
the first draft
backend/app/main.py
CHANGED
|
@@ -789,7 +789,10 @@ def generate_construct_items(
|
|
| 789 |
return GenerateItemsOut(
|
| 790 |
items=draft.items,
|
| 791 |
notes=draft.notes,
|
| 792 |
-
|
|
|
|
|
|
|
|
|
|
| 793 |
generations_used_today=used + 1,
|
| 794 |
max_generations_per_day=cap,
|
| 795 |
)
|
|
|
|
| 789 |
return GenerateItemsOut(
|
| 790 |
items=draft.items,
|
| 791 |
notes=draft.notes,
|
| 792 |
+
# items ride inside the stamp so the client echoes the full audit
|
| 793 |
+
# record on save: what the AI drafted, distinct from what the
|
| 794 |
+
# researcher edits and saves.
|
| 795 |
+
generation=ConstructGeneration(**stamp, items=draft.items),
|
| 796 |
generations_used_today=used + 1,
|
| 797 |
max_generations_per_day=cap,
|
| 798 |
)
|
backend/app/schemas.py
CHANGED
|
@@ -55,6 +55,10 @@ class ConstructGeneration(BaseModel):
|
|
| 55 |
model: str = Field(min_length=1, max_length=120)
|
| 56 |
prompt_version: str = Field(min_length=1, max_length=40)
|
| 57 |
generated_at: str = Field(min_length=1, max_length=40)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
|
| 60 |
class ConstructCreate(BaseModel):
|
|
|
|
| 55 |
model: str = Field(min_length=1, max_length=120)
|
| 56 |
prompt_version: str = Field(min_length=1, max_length=40)
|
| 57 |
generated_at: str = Field(min_length=1, max_length=40)
|
| 58 |
+
# The items exactly as drafted, BEFORE the researcher's edits - the audit
|
| 59 |
+
# trail that lets metadata show original draft vs. saved items (PI request
|
| 60 |
+
# 2026-08-07). Caps sized to the generation limits, not user needs.
|
| 61 |
+
items: list[str] | None = Field(default=None, max_length=30)
|
| 62 |
|
| 63 |
|
| 64 |
class ConstructCreate(BaseModel):
|
backend/static/assets/{index-T4JgSqf6.js → index-C3sV34vl.js}
RENAMED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/static/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<link rel="icon" type="image/svg+xml" href="/ccr-logo.svg" />
|
| 7 |
<title>CCR Platform - Contextualized Construct Representations</title>
|
| 8 |
-
<script type="module" crossorigin src="/assets/index-
|
| 9 |
<link rel="stylesheet" crossorigin href="/assets/index-Ckv10U9Q.css">
|
| 10 |
</head>
|
| 11 |
<body>
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<link rel="icon" type="image/svg+xml" href="/ccr-logo.svg" />
|
| 7 |
<title>CCR Platform - Contextualized Construct Representations</title>
|
| 8 |
+
<script type="module" crossorigin src="/assets/index-C3sV34vl.js"></script>
|
| 9 |
<link rel="stylesheet" crossorigin href="/assets/index-Ckv10U9Q.css">
|
| 10 |
</head>
|
| 11 |
<body>
|
backend/tests/test_item_generation.py
CHANGED
|
@@ -193,12 +193,15 @@ def test_saved_generated_construct_carries_provenance(client, monkeypatch):
|
|
| 193 |
register(client, "genprov@test.edu")
|
| 194 |
|
| 195 |
draft = client.post("/api/constructs/generate-items", json=GEN_BODY).json()
|
|
|
|
|
|
|
|
|
|
| 196 |
created = client.post(
|
| 197 |
"/api/constructs",
|
| 198 |
json={
|
| 199 |
"name": "Gratitude (generated)",
|
| 200 |
"description": GEN_BODY["description"],
|
| 201 |
-
"items":
|
| 202 |
"generation": draft["generation"],
|
| 203 |
},
|
| 204 |
).json()
|
|
@@ -220,6 +223,9 @@ def test_saved_generated_construct_carries_provenance(client, monkeypatch):
|
|
| 220 |
assert snapshot["source_type"] == "llm_generated"
|
| 221 |
assert snapshot["generation"]["prompt_version"] == item_generation.PROMPT_VERSION
|
| 222 |
assert "AI-generated" in snapshot["items_source_note"]
|
|
|
|
|
|
|
|
|
|
| 223 |
client.cookies.clear()
|
| 224 |
|
| 225 |
|
|
|
|
| 193 |
register(client, "genprov@test.edu")
|
| 194 |
|
| 195 |
draft = client.post("/api/constructs/generate-items", json=GEN_BODY).json()
|
| 196 |
+
# The stamp carries the as-drafted items (the audit record).
|
| 197 |
+
assert draft["generation"]["items"] == FAKE_DRAFT.items
|
| 198 |
+
edited = [draft["items"][0] + " (edited)"] + draft["items"][1:]
|
| 199 |
created = client.post(
|
| 200 |
"/api/constructs",
|
| 201 |
json={
|
| 202 |
"name": "Gratitude (generated)",
|
| 203 |
"description": GEN_BODY["description"],
|
| 204 |
+
"items": edited, # researcher edits; original draft stays in generation
|
| 205 |
"generation": draft["generation"],
|
| 206 |
},
|
| 207 |
).json()
|
|
|
|
| 223 |
assert snapshot["source_type"] == "llm_generated"
|
| 224 |
assert snapshot["generation"]["prompt_version"] == item_generation.PROMPT_VERSION
|
| 225 |
assert "AI-generated" in snapshot["items_source_note"]
|
| 226 |
+
# Audit trail: metadata shows BOTH the original draft and the saved items.
|
| 227 |
+
assert snapshot["generation"]["items"] == FAKE_DRAFT.items
|
| 228 |
+
assert snapshot["items"][0]["text"].endswith("(edited)")
|
| 229 |
client.cookies.clear()
|
| 230 |
|
| 231 |
|
frontend/src/Workspace.jsx
CHANGED
|
@@ -447,6 +447,22 @@ export default function Workspace({ project, auth, onAuthRefresh, onProjectChang
|
|
| 447 |
: "Run CCR analysis"}
|
| 448 |
</button>
|
| 449 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
{auth && !auth.signed_in && auth.usage?.max_runs_per_day != null && (
|
| 451 |
<p className="small muted">
|
| 452 |
{Math.min(auth.usage.runs_used_today, auth.usage.max_runs_per_day)} of{" "}
|
|
@@ -552,6 +568,17 @@ function NewConstructForm({ auth, constructs, source, onSourceChange, onCreated,
|
|
| 552 |
const itemsRef = useRef(null);
|
| 553 |
const setSource = onSourceChange;
|
| 554 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
// Simple library name-match (v1 guardrail, PI-approved): if a validated
|
| 556 |
// scale with a similar name exists, say so before anyone generates.
|
| 557 |
const nameQuery = name.trim().toLowerCase();
|
|
@@ -654,12 +681,15 @@ function NewConstructForm({ auth, constructs, source, onSourceChange, onCreated,
|
|
| 654 |
description: description.trim(),
|
| 655 |
items: parsed.map((i) => i.text),
|
| 656 |
reverse_scored: parsed.map((i) => i.reverse),
|
| 657 |
-
// provenance stamp when AI-drafted (schema fields only)
|
|
|
|
|
|
|
| 658 |
generation: genInfo
|
| 659 |
? {
|
| 660 |
model: genInfo.model,
|
| 661 |
prompt_version: genInfo.prompt_version,
|
| 662 |
generated_at: genInfo.generated_at,
|
|
|
|
| 663 |
}
|
| 664 |
: undefined,
|
| 665 |
});
|
|
@@ -777,18 +807,32 @@ function NewConstructForm({ auth, constructs, source, onSourceChange, onCreated,
|
|
| 777 |
))}
|
| 778 |
</select>
|
| 779 |
</label>
|
|
|
|
|
|
|
|
|
|
| 780 |
<button
|
| 781 |
type="button"
|
| 782 |
className="primary"
|
| 783 |
onClick={handleGenerate}
|
| 784 |
-
disabled={
|
|
|
|
|
|
|
| 785 |
>
|
| 786 |
{generating ? "Drafting…" : "Draft items"}
|
| 787 |
</button>
|
| 788 |
</div>
|
| 789 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 790 |
<p className="small muted">
|
| 791 |
-
{
|
| 792 |
</p>
|
| 793 |
)}
|
| 794 |
{genInfo && (
|
|
|
|
| 447 |
: "Run CCR analysis"}
|
| 448 |
</button>
|
| 449 |
</div>
|
| 450 |
+
{/* Disabled-until-valid with the reason inline (same pattern as the
|
| 451 |
+
construct form's Save) - a dead button with no visible cause is
|
| 452 |
+
the top tester confusion. */}
|
| 453 |
+
{!canRun && !running && !fileMissing && (
|
| 454 |
+
<p className="small muted">
|
| 455 |
+
To run, still needed:{" "}
|
| 456 |
+
{[
|
| 457 |
+
!corpusId && "a dataset (Step 1)",
|
| 458 |
+
corpusId && !textColumn && "a text column (Step 1)",
|
| 459 |
+
constructIds.length === 0 && "at least one construct (Step 2)",
|
| 460 |
+
]
|
| 461 |
+
.filter(Boolean)
|
| 462 |
+
.join(", ")}
|
| 463 |
+
.
|
| 464 |
+
</p>
|
| 465 |
+
)}
|
| 466 |
{auth && !auth.signed_in && auth.usage?.max_runs_per_day != null && (
|
| 467 |
<p className="small muted">
|
| 468 |
{Math.min(auth.usage.runs_used_today, auth.usage.max_runs_per_day)} of{" "}
|
|
|
|
| 568 |
const itemsRef = useRef(null);
|
| 569 |
const setSource = onSourceChange;
|
| 570 |
|
| 571 |
+
// Quota shown from the first tab-open (auth/me carries it), replaced by the
|
| 572 |
+
// fresher count from each generate response.
|
| 573 |
+
const quotaInfo =
|
| 574 |
+
genUsage ||
|
| 575 |
+
(auth?.usage?.max_generations_per_day != null
|
| 576 |
+
? {
|
| 577 |
+
used: auth.usage.generations_used_today,
|
| 578 |
+
max: auth.usage.max_generations_per_day,
|
| 579 |
+
}
|
| 580 |
+
: null);
|
| 581 |
+
|
| 582 |
// Simple library name-match (v1 guardrail, PI-approved): if a validated
|
| 583 |
// scale with a similar name exists, say so before anyone generates.
|
| 584 |
const nameQuery = name.trim().toLowerCase();
|
|
|
|
| 681 |
description: description.trim(),
|
| 682 |
items: parsed.map((i) => i.text),
|
| 683 |
reverse_scored: parsed.map((i) => i.reverse),
|
| 684 |
+
// provenance stamp when AI-drafted (schema fields only); `items` is
|
| 685 |
+
// the ORIGINAL draft - the audit record of what the AI produced
|
| 686 |
+
// before the researcher's edits
|
| 687 |
generation: genInfo
|
| 688 |
? {
|
| 689 |
model: genInfo.model,
|
| 690 |
prompt_version: genInfo.prompt_version,
|
| 691 |
generated_at: genInfo.generated_at,
|
| 692 |
+
items: genInfo.items,
|
| 693 |
}
|
| 694 |
: undefined,
|
| 695 |
});
|
|
|
|
| 807 |
))}
|
| 808 |
</select>
|
| 809 |
</label>
|
| 810 |
+
{/* Disabled-until-valid with the reason inline - same pattern as
|
| 811 |
+
Save; a clickable button whose error lands in the far-away top
|
| 812 |
+
banner reads as broken. */}
|
| 813 |
<button
|
| 814 |
type="button"
|
| 815 |
className="primary"
|
| 816 |
onClick={handleGenerate}
|
| 817 |
+
disabled={
|
| 818 |
+
generating || saving || parsing || !name.trim() || !description.trim()
|
| 819 |
+
}
|
| 820 |
>
|
| 821 |
{generating ? "Drafting…" : "Draft items"}
|
| 822 |
</button>
|
| 823 |
</div>
|
| 824 |
+
{(!name.trim() || !description.trim()) && (
|
| 825 |
+
<p className="small muted">
|
| 826 |
+
To draft, first fill in
|
| 827 |
+
{!name.trim() ? " the Name" : ""}
|
| 828 |
+
{!name.trim() && !description.trim() ? " and" : ""}
|
| 829 |
+
{!description.trim() ? " the Description (a few sentences on what the construct means)" : ""}
|
| 830 |
+
{" "}above.
|
| 831 |
+
</p>
|
| 832 |
+
)}
|
| 833 |
+
{quotaInfo && (
|
| 834 |
<p className="small muted">
|
| 835 |
+
{quotaInfo.used} of {quotaInfo.max} AI generations used today.
|
| 836 |
</p>
|
| 837 |
)}
|
| 838 |
{genInfo && (
|