diff --git a/modules/encounter_api.py b/modules/encounter_api.py index deed066..c1264ad 100644 --- a/modules/encounter_api.py +++ b/modules/encounter_api.py @@ -86,14 +86,32 @@ def adaptive_intake_plan(): """ payload = request.get_json(silent=True) or {} project = payload.get("project", payload) if isinstance(payload, dict) else {} - plan = build_intake_plan(project or {}) + project = project if isinstance(project, dict) else {} + plan = build_intake_plan(project) + plan["conditional"] = [] + plan["rationale"] = "SafeBARS uses the six core protocol questions; AI review runs automatically after submission." return jsonify({"success": True, **plan}) @encounter_api.post("/sessions") def create_session(): try: - encounter_session = encounter_engine.create_session(request.get_json(silent=True) or {}) + raw_payload = request.get_json(silent=True) or {} + payload = raw_payload if isinstance(raw_payload, dict) else {} + project = payload.get("project", {}) + project = project if isinstance(project, dict) else {} + artifacts = payload.get("artifacts", {}) + artifacts = artifacts if isinstance(artifacts, dict) else {} + payload = { + **payload, + "project": {**project, "uses_ai": True}, + "artifacts": { + key: value for key, value in artifacts.items() + if key != "ai_governance" + }, + "use_llm": True, + } + encounter_session = encounter_engine.create_session(payload) except ValueError as exc: return jsonify({"success": False, "error": str(exc)}), 400 except Exception as exc: diff --git a/modules/encounter_engine.py b/modules/encounter_engine.py index 5d747b3..212dc42 100644 --- a/modules/encounter_engine.py +++ b/modules/encounter_engine.py @@ -780,11 +780,14 @@ def create_protocol_version(self, session_id: str) -> Optional[Dict[str, Any]]: ) current_version = int(source.get("lineage", {}).get("version_number", 1) or 1) payload = { - "project": source.get("project", {}), - "artifacts": source.get("artifacts", {}), + "project": {**source.get("project", {}), "uses_ai": True}, + "artifacts": { + **source.get("artifacts", {}), + "ai_governance": "", + }, "intake_transcript": source.get("intake_transcript", []), "selected_scenarios": source.get("selected_scenarios", []), - "use_llm": source.get("use_llm", False), + "use_llm": True, "application_profile_id": source.get("application_profile_id", ""), "tradeoff_deliberations": source.get("tradeoff_deliberations", {}), "lineage": { @@ -1464,9 +1467,9 @@ def _build_audit_plan( "title": "Probe for additional passage-grounded gaps", "goal": "Find at most two non-checklist gaps while remaining grounded to submitted passages.", "reason": ( - "A provider is configured and the researcher enabled this optional probe." + "The always-on AI review path found a configured provider for this bounded probe." if llm_enabled - else "Skipped because no configured provider was enabled; deterministic tracing remains active." + else "The AI critic was requested automatically, but no provider is configured; deterministic tracing remains active." ), "priority": "low", "status": "queued" if llm_enabled else "skipped", diff --git a/modules/encounter_report.py b/modules/encounter_report.py index 52b87f0..6333fe5 100644 --- a/modules/encounter_report.py +++ b/modules/encounter_report.py @@ -857,11 +857,12 @@ def build_ethics_application_docx(session: Dict[str, Any]) -> bytes: if project.get("uses_ai") or assessment.get("uses_ai"): document.add_heading("7. AI use and risk-management appendix", level=1) _add_label_paragraph(document, "AI role described by researcher", project.get("context")) - _add_label_paragraph( - document, - "Submitted AI ethics-review supplement", - artifacts.get("ai_governance"), - ) + if str(artifacts.get("ai_governance", "")).strip(): + _add_label_paragraph( + document, + "Legacy AI governance notes", + artifacts.get("ai_governance"), + ) _add_label_paragraph( document, "Review basis", @@ -1041,11 +1042,12 @@ def build_research_design_docx(session: Dict[str, Any]) -> bytes: if project.get("uses_ai") or assessment.get("uses_ai"): document.add_heading("7. AI role, human oversight, and failure response", level=1) _add_label_paragraph(document, "Submitted AI role", project.get("context")) - _add_label_paragraph( - document, - "Submitted AI ethics-review supplement", - artifacts.get("ai_governance"), - ) + if str(artifacts.get("ai_governance", "")).strip(): + _add_label_paragraph( + document, + "Legacy AI governance notes", + artifacts.get("ai_governance"), + ) _add_label_paragraph( document, "Design basis", diff --git a/templates/safebars_v2.html b/templates/safebars_v2.html index f50dd69..c2ff8e5 100644 --- a/templates/safebars_v2.html +++ b/templates/safebars_v2.html @@ -152,34 +152,6 @@ textarea { min-height: 84px; resize: vertical; } .textarea-short { min-height: 64px; } - .artifact-group { - margin: 12px 0 14px; - border-top: 1px solid var(--line); - } - - details.artifact { - border-bottom: 1px solid var(--line); - padding: 0; - } - - details.artifact summary { - list-style: none; - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - min-height: 42px; - font-size: 12px; - font-weight: 650; - cursor: pointer; - } - - details.artifact summary::-webkit-details-marker { display: none; } - details.artifact[open] summary .chevron { transform: rotate(90deg); } - .chevron { transition: transform 120ms; } - .artifact-body { padding: 0 0 11px; } - .artifact-body textarea { min-height: 96px; } - .btn { min-height: 36px; border: 1px solid var(--line-strong); @@ -239,47 +211,6 @@ .export-option-title { display: block; font-size: 12px; font-weight: 700; } .export-option-note { display: block; margin-top: 1px; color: var(--muted); font-size: 10px; } - .switch-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin: 12px 0; - color: #35414c; - font-size: 12px; - } - - .switch { - position: relative; - width: 38px; - height: 22px; - flex: 0 0 auto; - } - - .switch input { opacity: 0; width: 0; height: 0; } - .switch span { - position: absolute; - inset: 0; - border-radius: 11px; - background: #b9c1c8; - transition: 120ms; - } - - .switch span::after { - content: ""; - position: absolute; - width: 16px; - height: 16px; - left: 3px; - top: 3px; - border-radius: 50%; - background: #fff; - transition: 120ms; - } - - .switch input:checked + span { background: var(--teal); } - .switch input:checked + span::after { transform: translateX(16px); } - .boundary-note { display: flex; gap: 9px; @@ -1179,7 +1110,20 @@ .uncertainty { margin-top: 9px; color: var(--muted); font-size: 11px; } .passage-quote { margin-top: 7px; padding: 7px 9px; border-left: 2px solid #9fb1c0; color: #46535f; background: #f3f6f8; font-size: 11px; } .passage-quote-head { display: flex; align-items: center; justify-content: space-between; gap: 7px; margin-bottom: 3px; } - .source-action { border: 0; padding: 2px 4px; background: transparent; color: var(--blue); font: inherit; font-size: 9px; font-weight: 750; cursor: pointer; } + .source-action { + min-height: 44px; + display: inline-flex; + align-items: center; + flex: 0 0 auto; + border: 0; + padding: 8px 10px; + background: transparent; + color: var(--blue); + font: inherit; + font-size: 11px; + font-weight: 750; + cursor: pointer; + } textarea.source-focus, input.source-focus { outline: 3px solid rgba(46,116,181,.22); border-color: var(--blue); } .issue-decision textarea { min-height: 66px; font-size: 12px; } .issue-decision .button-row { gap: 5px; flex-wrap: wrap; } @@ -1563,16 +1507,122 @@ } .materials-details > summary::-webkit-details-marker { display: none; } .materials-details > summary > span:first-child { display: inline-flex; align-items: center; gap: 7px; } - .materials-summary-note { color: var(--muted); font-size: 10px; font-weight: 600; } + .materials-summary-note { color: var(--muted); font-size: 11px; font-weight: 600; } .materials-body { padding: 4px 11px 2px; border-top: 1px solid var(--line); } - .artifact-group { margin-bottom: 4px; } - details.artifact summary { min-height: 45px; } - .switch-row { - min-height: 43px; - margin: 8px 0; - padding: 7px 0; - border-bottom: 1px solid #e6edf2; + .source-subheading { + color: #2d4155; + font-size: 12px; + font-weight: 780; + } + .artifact-editor { + margin: 15px -2px 9px; + padding-top: 13px; + border-top: 1px solid var(--line); + } + .artifact-editor-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; + } + .artifact-editor-head p { + margin: 3px 0 0; + color: var(--muted); + font-size: 11px; + line-height: 1.4; } + .ai-active-badge { + display: inline-flex; + align-items: center; + gap: 4px; + flex: 0 0 auto; + padding: 4px 6px; + border: 1px solid #add4cd; + border-radius: 5px; + color: #0b655d; + background: var(--green-soft); + font-size: 10px; + font-weight: 780; + line-height: 1.15; + } + .artifact-editor-label { + display: block; + margin-bottom: 5px; + color: #43566a; + font-size: 11px; + font-weight: 740; + letter-spacing: .04em; + text-transform: uppercase; + } + .artifact-editor-select { + min-height: 44px; + margin-bottom: 9px; + font-weight: 700; + } + .artifact-editor-panels { + border: 1px solid var(--line); + border-radius: 8px; + background: #f9fbfc; + overflow: hidden; + } + .artifact-editor-panel { padding: 10px; } + .artifact-panel-heading { + display: grid; + grid-template-columns: 32px minmax(0, 1fr); + align-items: center; + gap: 8px; + margin-bottom: 8px; + } + .artifact-panel-icon { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border: 1px solid #c2d9dc; + border-radius: 7px; + color: var(--teal); + background: var(--teal-soft); + } + .artifact-panel-heading label { + display: block; + color: #283d52; + font-size: 11px; + font-weight: 750; + } + .artifact-panel-heading small { + display: block; + margin-top: 1px; + color: var(--muted); + font-size: 11px; + line-height: 1.35; + } + .artifact-editor-panel textarea { + min-height: 142px; + background: #fff; + } + .artifact-reference { + margin-top: 7px; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 11px; + } + .artifact-reference summary { + min-height: 44px; + display: flex; + align-items: center; + cursor: pointer; + color: var(--teal); + font-weight: 750; + } + .artifact-reference p { margin: 0 0 7px; line-height: 1.45; } + .artifact-editor-status { + margin: -4px 1px 8px; + color: var(--muted); + font-size: 11px; + text-align: right; + } + .artifact-editor-status.complete { color: var(--green); font-weight: 750; } .boundary-note { border-radius: 0 7px 7px 0; } .workspace-hero { @@ -1921,6 +1971,17 @@ .more-menu, .export-menu { position: fixed; top: 118px; right: 10px; left: 10px; width: auto; } .intake-panel { padding: 18px 14px 28px; } .materials-details:not([open]) { background: #f8fafb; } + .artifact-editor-head { display: block; } + .artifact-editor-head p, + .artifact-editor-label, + .artifact-panel-heading small, + .artifact-reference, + .artifact-editor-status, + .ai-active-badge { font-size: 12px; } + .ai-active-badge { margin-top: 7px; } + .artifact-editor-panel textarea { min-height: 165px; } + .passage-quote-head { align-items: flex-start; flex-wrap: wrap; } + .source-action { font-size: 12px; } .main-panel { padding-inline: 10px; } .workspace-hero { border-radius: 11px; padding: 23px 17px; } .workspace-hero h2 { font-size: 29px; } @@ -2066,7 +2127,7 @@

Study session setup

-

Six short questions scaffold your review context, plan, participants, procedure, safeguards, and data. AI-related projects can then add a separate governance section.

+

Six short questions scaffold the protocol. AI-assisted review runs automatically after the materials are submitted.

No source material added yet0%
@@ -2093,56 +2154,57 @@

Study session setup

-
-
- Recruitment message -
-
-
- Consent language -
-
-
- Interview questions -
-
-
- Workshop or activity plan -
-
-
- Safety procedure -
-
-
- Follow-up and data use -
-
- -
+ AI review · automatic - -
- Project researches or uses AI - -
-
- Optional bounded LLM critic · off by default - + + +
1 of 6 · not added
+ +
+
+
Invitation, eligibility, contact route, compensation, and pressure safeguards.
+ +
+ + + + + +
+ +
+ + @@ -2480,7 +2542,7 @@

Common final task submission

For researchers

Prepare and stress-test a sensitive human-facing protocol.

-
1
Complete Quick Intake

Answer six short questions about the review context, research plan, people, procedures, safeguards, and data. AI projects receive one structured ethics-review supplement.

+
1
Complete Quick Intake

Answer six protocol questions. AI-assisted review runs automatically after submission.

2
Review the populated fields

Correct the protocol materials in the left panel before analysis.

3
Build encounter map

Create the case, index passages, and inspect the participant journey.

4
Run selected traces

Choose breakdown scenarios and run the audit to identify issues and human handoffs.

@@ -2553,13 +2615,13 @@

Plain-language guide to interface terms

activity: "artifactActivity", safety: "artifactSafety", follow_up: "artifactFollowUp", - ai_governance: "artifactAiGovernance", }; + const artifactEditorOrder = Object.keys(artifactInputs); let intakePlan = null; // server-driven question schema; falls back to the built-in steps below const coreIntakeSteps = [ - {id: "context", question: "Which research area and ethics-review context applies, and what research plan will be submitted?", hint: "Use two sections if possible:\nReview context: research area; university or organisation; school or department; ethics committee or review pathway; country or jurisdiction.\nProject plan: aim, setting, method, why human participation is needed, and either describe the AI role or write 'No AI'.", min: 30, detectAi: true, fallbackTarget: "projectContext", sections: [ + {id: "context", question: "Which research area and ethics-review context applies, and what research plan will be submitted?", hint: "Use two sections if possible:\nReview context: research area; university or organisation; school or department; ethics committee or review pathway; country or jurisdiction.\nProject plan: aim, setting, method, and why human participation is needed.", min: 30, fallbackTarget: "projectContext", sections: [ {target: "projectReviewContext", labels: ["review context", "research area", "institution", "university", "organisation", "organization", "school", "department", "committee", "jurisdiction"]}, {target: "projectContext", labels: ["project plan", "plan", "aim", "purpose", "method", "research"]}, ]}, @@ -2576,20 +2638,52 @@

Plain-language guide to interface terms

{id: "follow_up", target: "artifactFollowUp", question: "What happens after participation and to the research data?", hint: "Cover debriefing, complaints, follow-up, access, storage, security, retention, deletion, reporting, and withdrawal after collection.", min: 30}, ]; - const aiIntakeStep = {id: "ai_governance", target: "artifactAiGovernance", question: "Complete the AI ethics-review supplement: how is AI used, governed, disclosed, tested, and corrected?", hint: "Use short labelled lines: AI role & decision authority; data source & intended population; participant disclosure & consent; performance & subgroup bias; privacy & security; human oversight & monitoring; stopping/fallback; correction, complaints & accountable owner.", min: 30}; - function activeIntakeSteps() { - const core = (intakePlan && Array.isArray(intakePlan.core)) ? intakePlan.core : coreIntakeSteps; - let aiStep = (intakePlan && Array.isArray(intakePlan.conditional)) - ? intakePlan.conditional.find(s => s.id === "ai_governance") - : null; - if (!aiStep) aiStep = aiIntakeStep; // built-in fallback preserves live reactivity - return [...core, ...($("usesAi").checked ? [aiStep] : [])]; + return (intakePlan && Array.isArray(intakePlan.core)) ? intakePlan.core : coreIntakeSteps; } const $ = (id) => document.getElementById(id); const escapeHtml = (value = "") => String(value).replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char])); + function updateArtifactEditorStatus() { + const select = $("artifactEditorSelect"); + if (!select) return; + const key = select.value; + const index = Math.max(0, artifactEditorOrder.indexOf(key)); + const value = ($(artifactInputs[key])?.value || "").trim(); + $("artifactEditorStatus").textContent = `${index + 1} of ${artifactEditorOrder.length} · ${value ? "added" : "not added"}`; + $("artifactEditorStatus").classList.toggle("complete", !!value); + } + + function updateAiModeStatus() { + const legacySession = state.session + && (state.session.use_llm === false || state.session.project?.uses_ai === false); + const provider = state.options?.active_provider; + if (legacySession) { + $("aiModeText").textContent = "Legacy record · create AI version"; + $("aiModeBadge").title = "This saved record predates always-on AI review. Create a new protocol version to apply the current AI workflow without rewriting its audit history."; + return; + } + $("aiModeText").textContent = provider + ? "AI review · automatic" + : "AI review · model setup needed"; + $("aiModeBadge").title = provider + ? "A configured bounded model critic runs automatically." + : "No external model is configured. Framework routing and deterministic safeguards remain active."; + } + + function selectArtifactEditor(key, focusEditor = false) { + if (!artifactInputs[key]) key = artifactEditorOrder[0]; + $("artifactEditorSelect").value = key; + document.querySelectorAll("[data-artifact-panel]").forEach(panel => { + const active = panel.dataset.artifactPanel === key; + panel.hidden = !active; + panel.classList.toggle("active", active); + }); + updateArtifactEditorStatus(); + if (focusEditor) requestAnimationFrame(() => $(artifactInputs[key])?.focus()); + } + function studyConditionValue() { if (!STUDY_MODE) return ""; return state.session?.study_manifest?.condition || $("studyCondition").value; @@ -2609,12 +2703,6 @@

Plain-language guide to interface terms

$("exportControl").hidden = generalChat; $("guidedIntakeButton").hidden = generalChat; $("guidedIntakeCopy").hidden = generalChat; - $("useLlm").checked = true; - $("useLlm").disabled = true; - const provider = state.options?.active_provider; - $("llmLabel").textContent = provider - ? `Study model fixed · ${provider.label} · ${provider.model}` - : "Study model required · no provider configured"; $("buildMapButton").innerHTML = generalChat ? `Create study workspace` : `Build encounter map`; @@ -2679,24 +2767,21 @@

Plain-language guide to interface terms

"artifactSafety", "artifactFollowUp", ]; - if ($("usesAi").checked) fieldIds.push("artifactAiGovernance"); const filled = fieldIds.filter(id => ($(id)?.value || "").trim()).length; const total = fieldIds.length; const percent = Math.round((filled / total) * 100); - $("materialProgressBar").style.width = `${percent}%`; - $("materialProgressPercent").textContent = `${percent}%`; - $("materialFieldCount").textContent = `${filled} of ${total} fields`; - $("materialProgressText").textContent = filled - ? `${filled} source field${filled === 1 ? "" : "s"} ready for review` - : "No source material added yet"; + const progress = document.querySelector(".material-progress"); + if (progress.dataset.filled !== String(filled)) { + progress.dataset.filled = String(filled); + $("materialProgressBar").style.width = `${percent}%`; + $("materialProgressPercent").textContent = `${percent}%`; + $("materialFieldCount").textContent = `${filled} of ${total} fields`; + $("materialProgressText").textContent = filled + ? `${filled} source field${filled === 1 ? "" : "s"} ready for review` + : "No source material added yet"; + } $("workspaceHero").classList.toggle("has-session", !!state.session); - } - - function updateAiSupplementVisibility() { - const panel = $("aiGovernanceArtifact"); - if (!panel) return; - panel.hidden = !$("usesAi").checked; - if (!panel.hidden && $("artifactAiGovernance").value.trim()) panel.open = true; + updateArtifactEditorStatus(); } function collectPayload() { @@ -2708,11 +2793,11 @@

Plain-language guide to interface terms

review_context: $("projectReviewContext").value.trim(), context: $("projectContext").value.trim(), target_people: $("targetPeople").value.trim(), - uses_ai: $("usesAi").checked, + uses_ai: true, }, artifacts, selected_scenarios: selectedScenarioIds(), - use_llm: $("useLlm").checked, + use_llm: true, intake_transcript: state.intakeTranscript, }; if (STUDY_MODE) { @@ -2741,9 +2826,6 @@

Plain-language guide to interface terms

$("projectReviewContext").value = project.review_context || ""; $("projectContext").value = project.context || ""; $("targetPeople").value = project.target_people || ""; - $("usesAi").checked = !!project.uses_ai; - updateAiSupplementVisibility(); - $("useLlm").checked = !!state.session.use_llm && !$("useLlm").disabled; Object.entries(artifactInputs).forEach(([key, id]) => $(id).value = state.session.artifacts?.[key] || ""); state.intakeTranscript = state.session.intake_transcript || []; state.intakeComplete = state.intakeTranscript.some(item => /intake (?:is )?complete|quick intake complete/i.test(item.text || "")); @@ -2932,7 +3014,7 @@

Plain-language guide to interface terms

["studyId", "studyParticipantId", "studyCondition", "studyCaseId", "studyOrder", "studyConsentConfirmed"] .forEach(id => $(id).disabled = locked); if (locked) { - ["projectTitle", "projectReviewContext", "projectContext", "targetPeople", ...Object.values(artifactInputs), "usesAi", "useLlm", "guidedIntakeButton", "buildMapButton"] + ["projectTitle", "projectReviewContext", "projectContext", "targetPeople", ...Object.values(artifactInputs), "guidedIntakeButton", "buildMapButton"] .forEach(id => { if ($(id)) $(id).disabled = true; }); } const status = manifest?.task_status || "not_configured"; @@ -3009,11 +3091,9 @@

Plain-language guide to interface terms

$("projectReviewContext").value = sample.project.review_context || ""; $("projectContext").value = sample.project.context; $("targetPeople").value = sample.project.target_people; - $("usesAi").checked = false; - updateAiSupplementVisibility(); Object.entries(artifactInputs).forEach(([key, id]) => $(id).value = sample.artifacts[key] || ""); - document.querySelectorAll("details.artifact").forEach((details, index) => details.open = index < 3); $("materialsDetails").open = true; + selectArtifactEditor("recruitment"); updateMaterialProgress(); markAnalysisDirty(); toast("Sample protocol loaded."); @@ -3043,13 +3123,13 @@

Plain-language guide to interface terms

if (!step) { state.intakeComplete = true; const coreCount = (intakePlan && Array.isArray(intakePlan.core)) ? intakePlan.core.length : coreIntakeSteps.length; - appendIntakeMessage("bot", `Quick intake complete: ${coreCount} core questions${$("usesAi").checked ? " plus one AI follow-up" : ""}. Review or refine the populated fields before building the encounter map.`); + appendIntakeMessage("bot", `Quick intake complete: ${coreCount} protocol questions answered. Review or refine the populated fields before building the encounter map.`); $("intakeProgress").textContent = "Complete · review the structured fields or continue directly"; showIntakeCompletion(); renderWorkflow(); return; } - $("intakeProgress").textContent = `Step ${state.intakeStep + 1} of ${steps.length} · ${step.id.replaceAll("_", " ")}${step.id === "ai_governance" ? " (AI only)" : ""}`; + $("intakeProgress").textContent = `Step ${state.intakeStep + 1} of ${steps.length} · ${step.id.replaceAll("_", " ")}`; appendIntakeMessage("bot", step.question); $("intakeAnswer").value = ""; $("intakeAnswer").placeholder = step.hint; @@ -3067,7 +3147,7 @@

Plain-language guide to interface terms

review_context: $("projectReviewContext").value, context: $("projectContext").value, target_people: $("targetPeople").value, - uses_ai: $("usesAi").checked, + uses_ai: true, }, }), }); @@ -3090,7 +3170,7 @@

Plain-language guide to interface terms

$("intakeAnswer").disabled = false; $("intakeAnswer").hidden = false; $("intakeCompletePanel").hidden = true; - appendIntakeMessage("bot", "I will use six short questions to organise your protocol. You may skip a question and edit every populated field later. If AI is involved, one structured ethics-review supplement will appear."); + appendIntakeMessage("bot", "I will use six short questions to organise your protocol. You may skip a question and edit every populated field later."); askCurrentIntakeQuestion(); refreshIcons(); } @@ -3147,13 +3227,6 @@

Plain-language guide to interface terms

} function applyIntakeAnswer(step, answer) { - if (step.detectAi) { - const normalized = answer.toLowerCase(); - const explicitlyNoAi = /\b(no ai|without ai|does not use ai|do not use ai|not using ai)\b/.test(normalized); - const mentionsAi = /\b(ai|artificial intelligence|llm|language model|machine learning|algorithmic|automated model)\b/.test(normalized); - $("usesAi").checked = mentionsAi && !explicitlyNoAi; - updateAiSupplementVisibility(); - } if (step.sections) applySectionAnswer(answer, step.sections, step.fallbackTarget); else if (step.appendTarget) { const existing = $(step.appendTarget).value.trim(); @@ -3309,7 +3382,7 @@

Plain-language guide to interface terms

project: previous.project, artifacts: previous.artifacts, selected_scenarios: scenarioIds, - use_llm: previous.use_llm, + use_llm: true, }), }); state.session = created.session; @@ -3394,16 +3467,23 @@

Plain-language guide to interface terms

return ids.length ? `
Evidence from${ids.map(id => `${escapeHtml(passageLabel(id))}`).join("")}
` : ""; } function sourceQuotes(ids = []) { - return ids.map(id => passage(id)).filter(Boolean).map(item => `
${escapeHtml(passageLabel(item.id))}
${escapeHtml(item.text)}
`).join(""); + return ids.map(id => passage(id)).filter(Boolean).map(item => { + const action = artifactInputs[item.artifact_type] + ? `` + : `Saved legacy material`; + return `
${escapeHtml(passageLabel(item.id))}${action}
${escapeHtml(item.text)}
`; + }).join(""); } function bindSourceNavigation() { document.querySelectorAll("[data-focus-artifact]").forEach(button => button.onclick = () => { - const field = $(artifactInputs[button.dataset.focusArtifact]); + const artifactKey = button.dataset.focusArtifact; + const field = $(artifactInputs[artifactKey]); if (!field) return; - const disclosure = field.closest("details"); - if (disclosure) disclosure.open = true; + $("materialsDetails").open = true; + selectArtifactEditor(artifactKey); requestAnimationFrame(() => { - field.scrollIntoView({behavior:"smooth", block:"center"}); + const behavior = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth"; + field.scrollIntoView({behavior, block:"center"}); field.focus({preventScroll:true}); field.classList.add("source-focus"); setTimeout(() => field.classList.remove("source-focus"), 1600); @@ -3585,6 +3665,7 @@

Plain-language guide to interface terms

} function renderAll() { + updateAiModeStatus(); renderStudyCondition(); if (!isGeneralChatCondition()) { renderCounts(); @@ -4311,25 +4392,21 @@

Plain-language guide to interface terms

} async function initialize() { + const hasStoredSession = Boolean(sessionStorage.getItem(currentSessionStorageKey)); + if (window.matchMedia("(max-width: 860px)").matches && !hasStoredSession) { + $("materialsDetails").open = false; + } try { const evidenceRequest = loadTechnicalEvidence(); state.options = await api("/api/safebars/v2/options"); - const provider = state.options.active_provider; - if (STUDY_MODE) { - $("useLlm").disabled = true; - $("useLlm").checked = true; - renderStudyCondition(); - } else { - $("useLlm").disabled = !state.options.llm_configured; - $("useLlm").checked = false; - $("llmLabel").textContent = provider ? `Optional LLM critic · ${provider.label} · off by default` : "Optional LLM critic · unavailable"; - } + updateAiModeStatus(); + if (STUDY_MODE) renderStudyCondition(); const restored = await restoreCurrentSession(); if (!restored) { renderScenarioOptions(); renderStudyInstrumentation(); } - if (window.matchMedia("(max-width: 860px)").matches && !state.session) $("materialsDetails").open = false; + selectArtifactEditor($("artifactEditorSelect").value || "recruitment"); updateMaterialProgress(); await evidenceRequest; } catch (error) { toast(error.message, true); } @@ -4423,6 +4500,7 @@

Plain-language guide to interface terms

}); } $("applicationProfile").addEventListener("change", updateApplicationProfile); + $("artifactEditorSelect").addEventListener("change", event => selectArtifactEditor(event.target.value)); $("saveTradeoffsButton").addEventListener("click", saveTradeoffDeliberations); $("saveMapButton").addEventListener("click", () => saveMap(false)); $("updatePlanButton").addEventListener("click", () => updateAuditPlan(false)); @@ -4433,12 +4511,7 @@

Plain-language guide to interface terms

markAnalysisDirty(); updateMaterialProgress(); })); - $("usesAi").addEventListener("change", () => { - updateAiSupplementVisibility(); - markAnalysisDirty(); - updateMaterialProgress(); - }); - updateAiSupplementVisibility(); + selectArtifactEditor("recruitment"); initialize(); diff --git a/tests/test_adaptive_intake.py b/tests/test_adaptive_intake.py index 520b3a4..15d0612 100644 --- a/tests/test_adaptive_intake.py +++ b/tests/test_adaptive_intake.py @@ -114,7 +114,7 @@ def test_plan_endpoint_adds_ai_follow_up(self): ) self.assertEqual(resp.status_code, 200) data = resp.get_json() - self.assertEqual([s["id"] for s in data["conditional"]], ["ai_governance"]) + self.assertEqual(data["conditional"], []) def test_plan_endpoint_accepts_flat_payload(self): resp = self.client.post( @@ -122,7 +122,7 @@ def test_plan_endpoint_accepts_flat_payload(self): json={"title": "AI chatbot", "context": "LLM coach"}, ) self.assertEqual(resp.status_code, 200) - self.assertEqual([s["id"] for s in resp.get_json()["conditional"]], ["ai_governance"]) + self.assertEqual(resp.get_json()["conditional"], []) if __name__ == "__main__": diff --git a/tests/test_encounter_api.py b/tests/test_encounter_api.py index 055c9ab..e213ab4 100644 --- a/tests/test_encounter_api.py +++ b/tests/test_encounter_api.py @@ -11,14 +11,26 @@ from modules.encounter_engine import EncounterStore, SAMPLE_PROJECT +class UnconfiguredTestLLM: + providers = {} + active_provider_id = None + + @staticmethod + def is_configured(): + return False + + class EncounterApiTest(unittest.TestCase): def setUp(self): self.temp_dir = tempfile.TemporaryDirectory() encounter_engine.store = EncounterStore(str(Path(self.temp_dir.name) / "api.db")) + self.original_llm_client = encounter_engine.llm_client + encounter_engine.llm_client = UnconfiguredTestLLM() app.config.update(TESTING=True, SAFEBARS_REQUIRE_ROLE_AUTH=True) self.client = app.test_client() def tearDown(self): + encounter_engine.llm_client = self.original_llm_client self.temp_dir.cleanup() def test_complete_api_workflow(self): @@ -29,9 +41,13 @@ def test_complete_api_workflow(self): payload = json.loads(json.dumps(SAMPLE_PROJECT)) payload["use_llm"] = False + payload["artifacts"]["ai_governance"] = "Legacy clients must not add an eleventh public field." created = self.client.post("/api/safebars/v2/sessions", json=payload) self.assertEqual(created.status_code, 201) created_payload = created.get_json() + self.assertTrue(created_payload["session"]["project"]["uses_ai"]) + self.assertTrue(created_payload["session"]["use_llm"]) + self.assertEqual(created_payload["session"]["artifacts"]["ai_governance"], "") session_id = created_payload["session"]["id"] researcher_headers = { "X-SafeBARS-Access": created_payload["access"]["researcher_token"] @@ -162,6 +178,8 @@ def test_complete_api_workflow(self): self.assertEqual(versioned.status_code, 201) self.assertEqual(versioned.get_json()["session"]["lineage"]["parent_session_id"], session_id) self.assertEqual(versioned.get_json()["session"]["lineage"]["version_number"], 2) + self.assertTrue(versioned.get_json()["session"]["project"]["uses_ai"]) + self.assertTrue(versioned.get_json()["session"]["use_llm"]) self.assertEqual( versioned.get_json()["session"]["tradeoff_deliberations"]["recruitment_reach"]["value"], 70, @@ -372,7 +390,6 @@ def test_workspace_and_v1_routes_are_available(self): self.assertIn(b"Keep your record", workspace.data) self.assertIn(b"Six short questions", workspace.data) self.assertIn(b"Research area and ethics-review context", workspace.data) - self.assertIn(b"AI ethics-review supplement", workspace.data) self.assertNotIn(b"What are you studying", workspace.data) self.assertIn(b"Intake completed", workspace.data) self.assertIn(b"Done \xc2\xb7 review populated fields", workspace.data) @@ -401,7 +418,21 @@ def test_workspace_and_v1_routes_are_available(self): self.assertIn(b"Unsaved material changes", workspace.data) self.assertIn(b"Excluded from scope", workspace.data) self.assertIn(b"hadUnsavedMaterialChanges", workspace.data) - self.assertIn(b"Optional bounded LLM critic", workspace.data) + self.assertNotIn(b"Project researches or uses AI", workspace.data) + self.assertNotIn(b"Optional bounded LLM critic", workspace.data) + self.assertNotIn(b'id="usesAi"', workspace.data) + self.assertNotIn(b'id="useLlm"', workspace.data) + self.assertNotIn(b'id="llmLabel"', workspace.data) + self.assertIn(b"AI review \xc2\xb7 automatic", workspace.data) + self.assertNotIn(b"Zhipu", workspace.data) + self.assertIn(b'id="artifactEditorSelect"', workspace.data) + self.assertIn(b"0 of 10 fields", workspace.data) + self.assertIn(b"1 of 6 \xc2\xb7 not added", workspace.data) + self.assertIn(b"keeps all six connected", workspace.data) + self.assertNotIn(b'data-artifact-panel="ai_governance"', workspace.data) + self.assertNotIn(b'id="artifactAiGovernance"', workspace.data) + self.assertNotIn(b"AI ethics-review supplement", workspace.data) + self.assertIn(b'for="artifactRecruitment"', workspace.data) self.assertIn(b"Run this check again", workspace.data) self.assertIn(b"The plan has already run", workspace.data) self.assertNotIn(b">V1", workspace.data) diff --git a/tests/test_ethics_application.py b/tests/test_ethics_application.py index ddeeb47..4690c82 100644 --- a/tests/test_ethics_application.py +++ b/tests/test_ethics_application.py @@ -62,7 +62,7 @@ def test_ai_application_cites_review_sources_and_preserves_supplement(self): document = Document(BytesIO(build_ethics_application_docx(session))) text = "\n".join(paragraph.text for paragraph in document.paragraphs) - self.assertIn("Submitted AI ethics-review supplement", text) + self.assertIn("Legacy AI governance notes", text) self.assertIn("Makridis et al. (2023)", text) self.assertIn("Connelly et al. (2025)", text) self.assertIn("AI human-subjects review questions", text) diff --git a/tests/test_study_instrumentation.py b/tests/test_study_instrumentation.py index 8b21dfe..f06d5d5 100644 --- a/tests/test_study_instrumentation.py +++ b/tests/test_study_instrumentation.py @@ -378,7 +378,8 @@ def test_nonstudy_sessions_remain_compatible(self): self.assertEqual(created.status_code, 201) body = created.get_json() self.assertNotIn("study_manifest", body["session"]) - self.assertFalse(body["session"]["use_llm"]) + self.assertTrue(body["session"]["use_llm"]) + self.assertTrue(body["session"]["project"]["uses_ai"]) headers = {"X-SafeBARS-Access": body["access"]["researcher_token"]} status = self.client.get( f"/api/safebars/v2/sessions/{body['session']['id']}/study",