devaanand commited on
Commit
c49bb09
·
1 Parent(s): dc9da41

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
- generation=ConstructGeneration(**stamp),
 
 
 
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-T4JgSqf6.js"></script>
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": draft["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={generating || saving || parsing}
 
 
785
  >
786
  {generating ? "Drafting…" : "Draft items"}
787
  </button>
788
  </div>
789
- {genUsage && (
 
 
 
 
 
 
 
 
 
790
  <p className="small muted">
791
- {genUsage.used} of {genUsage.max} AI generations used today.
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 && (