MikelWL commited on
Commit
b690278
Β·
1 Parent(s): 2ffbf6b

Analysis: unify templates into frameworks

Browse files
README.md CHANGED
@@ -99,11 +99,14 @@ After the conversation completes, the app runs post-conversation analysis and po
99
  - Bottom-up findings (emergent themes) with evidence
100
  - Top-down coding (care experience rubric + codebook categories) with evidence
101
 
102
- ### Analysis templates (top-down codebook)
103
-
104
- - The top-down codebook categories are driven by an **analysis template** (stored in SQLite at `DB_PATH`).
105
- - The **active template is selected per run** using the dropdown next to the **πŸ“Š Analysis** header (AI-to-AI / Human-to-AI / Upload Text).
106
- - The **Configuration** panel is used to create/duplicate/delete templates and edit their category lists; selecting a template there is for editing/review only.
 
 
 
107
 
108
  ### Human Chat (Human ↔ Surveyor)
109
 
 
99
  - Bottom-up findings (emergent themes) with evidence
100
  - Top-down coding (care experience rubric + codebook categories) with evidence
101
 
102
+ ### Analysis frameworks
103
+
104
+ - An **analysis framework** (stored in SQLite at `DB_PATH`) defines:
105
+ - bottom-up instructions + attributes
106
+ - rubric attributes
107
+ - top-down attributes + codebook categories
108
+ - The **active framework is selected per run** using the dropdown next to the **πŸ“Š Analysis** header (AI-to-AI / Human-to-AI / Upload Text).
109
+ - The **Configuration** panel is used to create/duplicate/delete frameworks and edit them; selection there is for editing/review only.
110
 
111
  ### Human Chat (Human ↔ Surveyor)
112
 
backend/api/analysis_routes.py CHANGED
@@ -34,7 +34,7 @@ class AnalyzeTextRequest(BaseModel):
34
  source_name: Optional[str] = Field(default=None, description="Optional label for the uploaded/pasted source")
35
  analysis_attributes: Optional[List[str]] = Field(
36
  default=None,
37
- description="Plain-language analysis agent attributes compiled into the analysis system prompt",
38
  )
39
  top_down_codebook_template_id: Optional[str] = Field(
40
  default=None,
@@ -151,7 +151,6 @@ async def _analyze_from_text(
151
  ))
152
 
153
  store = get_persona_store()
154
- effective_analysis_attributes = await store.get_setting("analysis_attributes")
155
  effective_analysis_system_prompt = await store.get_setting("analysis_system_prompt")
156
  override = top_down_codebook_template_id.strip() if isinstance(top_down_codebook_template_id, str) else ""
157
  template_id = await store.get_setting("top_down_codebook_template_id")
@@ -159,14 +158,19 @@ async def _analyze_from_text(
159
  template_record = await store.get_analysis_template(override, include_deleted=False) if override else None
160
  if not template_record and template_id_str:
161
  template_record = await store.get_analysis_template(template_id_str, include_deleted=False)
 
 
162
  resources = await run_resource_agent_analysis(
163
  transcript=transcript,
164
  llm_backend=settings.llm.backend,
165
  host=settings.llm.host,
166
  model=settings.llm.model,
167
  settings=settings,
168
- analysis_attributes=effective_analysis_attributes,
169
  analysis_system_prompt=effective_analysis_system_prompt if isinstance(effective_analysis_system_prompt, str) else None,
 
 
 
 
170
  top_down_template_id=template_record.template_id if template_record else template_id_str,
171
  top_down_template_version_id=template_record.current_version_id if template_record else "",
172
  top_down_template_categories=template_record.categories if template_record else [],
@@ -191,7 +195,10 @@ async def _analyze_from_text(
191
  },
192
  "analysis": {
193
  "analysis_system_prompt": effective_analysis_system_prompt if isinstance(effective_analysis_system_prompt, str) else None,
194
- "analysis_attributes": effective_analysis_attributes,
 
 
 
195
  "top_down_codebook_template_id": template_record.template_id if template_record else template_id_str,
196
  "top_down_codebook_template_version_id": template_record.current_version_id if template_record else "",
197
  "top_down_codebook_template_snapshot": template_record.categories if template_record else [],
 
34
  source_name: Optional[str] = Field(default=None, description="Optional label for the uploaded/pasted source")
35
  analysis_attributes: Optional[List[str]] = Field(
36
  default=None,
37
+ description="(Deprecated) analysis attributes are now configured per-pass server-side",
38
  )
39
  top_down_codebook_template_id: Optional[str] = Field(
40
  default=None,
 
151
  ))
152
 
153
  store = get_persona_store()
 
154
  effective_analysis_system_prompt = await store.get_setting("analysis_system_prompt")
155
  override = top_down_codebook_template_id.strip() if isinstance(top_down_codebook_template_id, str) else ""
156
  template_id = await store.get_setting("top_down_codebook_template_id")
 
158
  template_record = await store.get_analysis_template(override, include_deleted=False) if override else None
159
  if not template_record and template_id_str:
160
  template_record = await store.get_analysis_template(template_id_str, include_deleted=False)
161
+ if not template_record and template_id_str:
162
+ raise HTTPException(status_code=500, detail="Default analysis framework template not found")
163
  resources = await run_resource_agent_analysis(
164
  transcript=transcript,
165
  llm_backend=settings.llm.backend,
166
  host=settings.llm.host,
167
  model=settings.llm.model,
168
  settings=settings,
 
169
  analysis_system_prompt=effective_analysis_system_prompt if isinstance(effective_analysis_system_prompt, str) else None,
170
+ bottom_up_instructions=template_record.bottom_up_instructions if template_record else None,
171
+ bottom_up_attributes=template_record.bottom_up_attributes if template_record else None,
172
+ rubric_attributes=template_record.rubric_attributes if template_record else None,
173
+ top_down_attributes=template_record.top_down_attributes if template_record else None,
174
  top_down_template_id=template_record.template_id if template_record else template_id_str,
175
  top_down_template_version_id=template_record.current_version_id if template_record else "",
176
  top_down_template_categories=template_record.categories if template_record else [],
 
195
  },
196
  "analysis": {
197
  "analysis_system_prompt": effective_analysis_system_prompt if isinstance(effective_analysis_system_prompt, str) else None,
198
+ "bottom_up_instructions": template_record.bottom_up_instructions if template_record else None,
199
+ "bottom_up_attributes": template_record.bottom_up_attributes if template_record else None,
200
+ "rubric_attributes": template_record.rubric_attributes if template_record else None,
201
+ "top_down_attributes": template_record.top_down_attributes if template_record else None,
202
  "top_down_codebook_template_id": template_record.template_id if template_record else template_id_str,
203
  "top_down_codebook_template_version_id": template_record.current_version_id if template_record else "",
204
  "top_down_codebook_template_snapshot": template_record.categories if template_record else [],
backend/api/analysis_template_routes.py CHANGED
@@ -1,4 +1,4 @@
1
- """Top-down codebook analysis templates (CRUD)."""
2
 
3
  from __future__ import annotations
4
 
@@ -10,6 +10,12 @@ from fastapi import APIRouter, HTTPException
10
  from pydantic import BaseModel, Field
11
 
12
  from .storage_service import get_persona_store
 
 
 
 
 
 
13
  from backend.core.default_analysis_templates import DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE, default_top_down_categories
14
 
15
  router = APIRouter(prefix="", tags=["analysis-templates"])
@@ -60,6 +66,10 @@ class AnalysisTemplateDetailResponse(BaseModel):
60
  name: str
61
  is_default: bool = False
62
  version_id: str
 
 
 
 
63
  categories: List[Dict[str, str]] = Field(default_factory=list)
64
 
65
 
@@ -69,6 +79,10 @@ class CreateAnalysisTemplateRequest(BaseModel):
69
 
70
 
71
  class UpdateAnalysisTemplateRequest(BaseModel):
 
 
 
 
72
  categories: List[Dict[str, str]] = Field(default_factory=list)
73
 
74
 
@@ -99,6 +113,10 @@ async def get_template(template_id: str) -> AnalysisTemplateDetailResponse:
99
  await store.upsert_default_analysis_template(
100
  template_id=default_id,
101
  name=str(DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE.get("name") or "Default top-down codebook (v1)"),
 
 
 
 
102
  categories=default_top_down_categories(),
103
  )
104
  except Exception:
@@ -111,6 +129,10 @@ async def get_template(template_id: str) -> AnalysisTemplateDetailResponse:
111
  name=record.name,
112
  is_default=record.is_default,
113
  version_id=record.current_version_id,
 
 
 
 
114
  categories=record.categories,
115
  )
116
 
@@ -123,16 +145,28 @@ async def create_template(payload: CreateAnalysisTemplateRequest) -> AnalysisTem
123
 
124
  store = get_persona_store()
125
  categories: List[Dict[str, str]] = []
 
 
 
 
126
  if payload.clone_from_template_id:
127
  src = await store.get_analysis_template(payload.clone_from_template_id, include_deleted=False)
128
  if not src:
129
  raise HTTPException(status_code=404, detail="clone_from_template_id not found")
130
  categories = src.categories
 
 
 
 
131
  if not categories:
132
  categories = [{"category_id": "category_1", "label": "New category"}]
133
 
134
  template_id, version_id = await store.create_analysis_template(
135
  name=name,
 
 
 
 
136
  categories=_normalize_categories(categories),
137
  is_default=False,
138
  )
@@ -144,6 +178,10 @@ async def create_template(payload: CreateAnalysisTemplateRequest) -> AnalysisTem
144
  name=record.name,
145
  is_default=record.is_default,
146
  version_id=record.current_version_id,
 
 
 
 
147
  categories=record.categories,
148
  )
149
 
@@ -161,8 +199,20 @@ async def update_template(template_id: str, payload: UpdateAnalysisTemplateReque
161
  if not categories:
162
  raise HTTPException(status_code=400, detail="categories are required")
163
 
 
 
 
 
 
164
  try:
165
- await store.update_analysis_template(template_id=template_id, categories=categories)
 
 
 
 
 
 
 
166
  except PermissionError as e:
167
  raise HTTPException(status_code=403, detail=str(e))
168
  except ValueError as e:
@@ -176,6 +226,10 @@ async def update_template(template_id: str, payload: UpdateAnalysisTemplateReque
176
  name=updated.name,
177
  is_default=updated.is_default,
178
  version_id=updated.current_version_id,
 
 
 
 
179
  categories=updated.categories,
180
  )
181
 
 
1
+ """Analysis frameworks (bottom-up + rubric + top-down) templates (CRUD)."""
2
 
3
  from __future__ import annotations
4
 
 
10
  from pydantic import BaseModel, Field
11
 
12
  from .storage_service import get_persona_store
13
+ from backend.core.analysis_knobs import (
14
+ DEFAULT_BOTTOM_UP_INSTRUCTIONS,
15
+ DEFAULT_BOTTOM_UP_ATTRIBUTES,
16
+ DEFAULT_RUBRIC_ATTRIBUTES,
17
+ DEFAULT_TOP_DOWN_ATTRIBUTES,
18
+ )
19
  from backend.core.default_analysis_templates import DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE, default_top_down_categories
20
 
21
  router = APIRouter(prefix="", tags=["analysis-templates"])
 
66
  name: str
67
  is_default: bool = False
68
  version_id: str
69
+ bottom_up_instructions: str = ""
70
+ bottom_up_attributes: List[str] = Field(default_factory=list)
71
+ rubric_attributes: List[str] = Field(default_factory=list)
72
+ top_down_attributes: List[str] = Field(default_factory=list)
73
  categories: List[Dict[str, str]] = Field(default_factory=list)
74
 
75
 
 
79
 
80
 
81
  class UpdateAnalysisTemplateRequest(BaseModel):
82
+ bottom_up_instructions: Optional[str] = None
83
+ bottom_up_attributes: Optional[List[str]] = None
84
+ rubric_attributes: Optional[List[str]] = None
85
+ top_down_attributes: Optional[List[str]] = None
86
  categories: List[Dict[str, str]] = Field(default_factory=list)
87
 
88
 
 
113
  await store.upsert_default_analysis_template(
114
  template_id=default_id,
115
  name=str(DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE.get("name") or "Default top-down codebook (v1)"),
116
+ bottom_up_instructions=str(DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE.get("bottom_up_instructions") or DEFAULT_BOTTOM_UP_INSTRUCTIONS),
117
+ bottom_up_attributes=list(DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE.get("bottom_up_attributes") or DEFAULT_BOTTOM_UP_ATTRIBUTES), # type: ignore[list-item]
118
+ rubric_attributes=list(DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE.get("rubric_attributes") or DEFAULT_RUBRIC_ATTRIBUTES), # type: ignore[list-item]
119
+ top_down_attributes=list(DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE.get("top_down_attributes") or DEFAULT_TOP_DOWN_ATTRIBUTES), # type: ignore[list-item]
120
  categories=default_top_down_categories(),
121
  )
122
  except Exception:
 
129
  name=record.name,
130
  is_default=record.is_default,
131
  version_id=record.current_version_id,
132
+ bottom_up_instructions=record.bottom_up_instructions,
133
+ bottom_up_attributes=record.bottom_up_attributes,
134
+ rubric_attributes=record.rubric_attributes,
135
+ top_down_attributes=record.top_down_attributes,
136
  categories=record.categories,
137
  )
138
 
 
145
 
146
  store = get_persona_store()
147
  categories: List[Dict[str, str]] = []
148
+ bottom_up_instructions = DEFAULT_BOTTOM_UP_INSTRUCTIONS
149
+ bottom_up_attributes = list(DEFAULT_BOTTOM_UP_ATTRIBUTES)
150
+ rubric_attributes = list(DEFAULT_RUBRIC_ATTRIBUTES)
151
+ top_down_attributes = list(DEFAULT_TOP_DOWN_ATTRIBUTES)
152
  if payload.clone_from_template_id:
153
  src = await store.get_analysis_template(payload.clone_from_template_id, include_deleted=False)
154
  if not src:
155
  raise HTTPException(status_code=404, detail="clone_from_template_id not found")
156
  categories = src.categories
157
+ bottom_up_instructions = src.bottom_up_instructions
158
+ bottom_up_attributes = list(src.bottom_up_attributes)
159
+ rubric_attributes = list(src.rubric_attributes)
160
+ top_down_attributes = list(src.top_down_attributes)
161
  if not categories:
162
  categories = [{"category_id": "category_1", "label": "New category"}]
163
 
164
  template_id, version_id = await store.create_analysis_template(
165
  name=name,
166
+ bottom_up_instructions=bottom_up_instructions,
167
+ bottom_up_attributes=bottom_up_attributes,
168
+ rubric_attributes=rubric_attributes,
169
+ top_down_attributes=top_down_attributes,
170
  categories=_normalize_categories(categories),
171
  is_default=False,
172
  )
 
178
  name=record.name,
179
  is_default=record.is_default,
180
  version_id=record.current_version_id,
181
+ bottom_up_instructions=record.bottom_up_instructions,
182
+ bottom_up_attributes=record.bottom_up_attributes,
183
+ rubric_attributes=record.rubric_attributes,
184
+ top_down_attributes=record.top_down_attributes,
185
  categories=record.categories,
186
  )
187
 
 
199
  if not categories:
200
  raise HTTPException(status_code=400, detail="categories are required")
201
 
202
+ bottom_up_instructions = payload.bottom_up_instructions if isinstance(payload.bottom_up_instructions, str) else record.bottom_up_instructions
203
+ bottom_up_attributes = payload.bottom_up_attributes if isinstance(payload.bottom_up_attributes, list) else record.bottom_up_attributes
204
+ rubric_attributes = payload.rubric_attributes if isinstance(payload.rubric_attributes, list) else record.rubric_attributes
205
+ top_down_attributes = payload.top_down_attributes if isinstance(payload.top_down_attributes, list) else record.top_down_attributes
206
+
207
  try:
208
+ await store.update_analysis_template(
209
+ template_id=template_id,
210
+ bottom_up_instructions=bottom_up_instructions,
211
+ bottom_up_attributes=bottom_up_attributes,
212
+ rubric_attributes=rubric_attributes,
213
+ top_down_attributes=top_down_attributes,
214
+ categories=categories,
215
+ )
216
  except PermissionError as e:
217
  raise HTTPException(status_code=403, detail=str(e))
218
  except ValueError as e:
 
226
  name=updated.name,
227
  is_default=updated.is_default,
228
  version_id=updated.current_version_id,
229
+ bottom_up_instructions=updated.bottom_up_instructions,
230
+ bottom_up_attributes=updated.bottom_up_attributes,
231
+ rubric_attributes=updated.rubric_attributes,
232
+ top_down_attributes=updated.top_down_attributes,
233
  categories=updated.categories,
234
  )
235
 
backend/api/conversation_routes.py CHANGED
@@ -28,7 +28,7 @@ class StartConversationRequest(BaseModel):
28
  )
29
  analysis_attributes: Optional[List[str]] = Field(
30
  default=None,
31
- description="Plain-language analysis agent attributes compiled into the analysis system prompt",
32
  )
33
  surveyor_system_prompt: Optional[str] = Field(
34
  default=None,
 
28
  )
29
  analysis_attributes: Optional[List[str]] = Field(
30
  default=None,
31
+ description="(Deprecated, ignored) Legacy analysis attributes. Analysis behavior is controlled via /api/settings (bottom_up_instructions, bottom_up_attributes, rubric_attributes, top_down_attributes).",
32
  )
33
  surveyor_system_prompt: Optional[str] = Field(
34
  default=None,
backend/api/conversation_service.py CHANGED
@@ -46,7 +46,13 @@ from .storage_service import get_persona_store # noqa: E402
46
  from backend.storage import RunRecord # noqa: E402
47
  from backend.core.surveyor_knobs import compile_surveyor_attributes_overlay, compile_question_bank_overlay # noqa: E402
48
  from backend.core.patient_knobs import compile_patient_attributes_overlay # noqa: E402
49
- from backend.core.analysis_knobs import compile_analysis_rules_block # noqa: E402
 
 
 
 
 
 
50
  from backend.core.universal_prompts import DEFAULT_PATIENT_SYSTEM_PROMPT, DEFAULT_SURVEYOR_SYSTEM_PROMPT # noqa: E402
51
 
52
  # Setup logging
@@ -96,8 +102,11 @@ async def run_resource_agent_analysis(
96
  host: str,
97
  model: str,
98
  settings: AppSettings,
99
- analysis_attributes: Optional[List[str]] = None,
100
  analysis_system_prompt: Optional[str] = None,
 
 
 
 
101
  top_down_template_id: Optional[str] = None,
102
  top_down_template_version_id: Optional[str] = None,
103
  top_down_template_categories: Optional[List[Dict[str, str]]] = None,
@@ -154,7 +163,26 @@ async def run_resource_agent_analysis(
154
  "health survey conversation between a surveyor and a patient. Your task is to extract "
155
  "post-hoc insights as strict JSON for a UI."
156
  )
157
- system_prompt = (base + "\n\n" + compile_analysis_rules_block(analysis_attributes)).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
 
159
  template_categories = top_down_template_categories or []
160
  template_categories = [
@@ -284,7 +312,7 @@ async def run_resource_agent_analysis(
284
  await _phase("top_down", "pending")
285
 
286
  await _phase("bottom_up", "running")
287
- bottom_raw = await client.generate(prompt=_bottom_up_prompt(), system_prompt=system_prompt, temperature=0.2)
288
  bottom = json.loads(bottom_raw) if isinstance(bottom_raw, str) else {}
289
  for item in (bottom.get("health_situations", []) or []):
290
  normalized = _normalize_confidence(item.get("confidence"))
@@ -299,7 +327,7 @@ async def run_resource_agent_analysis(
299
  await _phase("bottom_up", "complete")
300
 
301
  await _phase("rubric", "running")
302
- rubric_raw = await client.generate(prompt=_rubric_prompt(), system_prompt=system_prompt, temperature=0.2)
303
  rubric = json.loads(rubric_raw) if isinstance(rubric_raw, str) else {}
304
  care_experience = rubric.get("care_experience") or {}
305
  for key in ("positive", "mixed", "negative", "neutral"):
@@ -314,7 +342,7 @@ async def run_resource_agent_analysis(
314
  await _phase("rubric", "complete")
315
 
316
  await _phase("top_down", "running")
317
- top_raw = await client.generate(prompt=_top_down_prompt(), system_prompt=system_prompt, temperature=0.2)
318
  top = json.loads(top_raw) if isinstance(top_raw, str) else {}
319
 
320
  td_book = top.get("top_down_codebook")
@@ -391,7 +419,10 @@ class ConversationInfo:
391
  surveyor_system_prompt: str = ""
392
  patient_system_prompt: str = ""
393
  analysis_system_prompt: str = ""
394
- analysis_attributes: List[str] = field(default_factory=list)
 
 
 
395
  top_down_template_id: str = ""
396
  top_down_template_version_id: str = ""
397
  top_down_template_categories: List[Dict[str, str]] = field(default_factory=list)
@@ -416,7 +447,10 @@ class HumanChatInfo:
416
  surveyor_system_prompt: str = ""
417
  patient_system_prompt: str = ""
418
  analysis_system_prompt: str = ""
419
- analysis_attributes: List[str] = field(default_factory=list)
 
 
 
420
  top_down_template_id: str = ""
421
  top_down_template_version_id: str = ""
422
  top_down_template_categories: List[Dict[str, str]] = field(default_factory=list)
@@ -485,6 +519,10 @@ class ConversationService:
485
  return {
486
  "template_id": record.template_id,
487
  "template_version_id": record.current_version_id,
 
 
 
 
488
  "categories": record.categories,
489
  }
490
 
@@ -495,9 +533,21 @@ class ConversationService:
495
  return {
496
  "template_id": record.template_id,
497
  "template_version_id": record.current_version_id,
 
 
 
 
498
  "categories": record.categories,
499
  }
500
- return {"template_id": template_id_str, "template_version_id": "", "categories": []}
 
 
 
 
 
 
 
 
501
 
502
  async def start_human_chat(
503
  self,
@@ -509,7 +559,7 @@ class ConversationService:
509
  patient_attributes: Optional[List[str]] = None, # deprecated (persona content is DB-canonical)
510
  surveyor_system_prompt: Optional[str] = None, # deprecated (DB-canonical)
511
  patient_system_prompt: Optional[str] = None, # deprecated (DB-canonical)
512
- analysis_attributes: Optional[List[str]] = None, # deprecated (DB-canonical)
513
  surveyor_attributes: Optional[List[str]] = None,
514
  surveyor_question_bank: Optional[str] = None,
515
  ai_role: Optional[str] = None,
@@ -537,12 +587,23 @@ class ConversationService:
537
  sp = await store.get_setting("surveyor_system_prompt")
538
  pp = await store.get_setting("patient_system_prompt")
539
  asp = await store.get_setting("analysis_system_prompt")
540
- ap = await store.get_setting("analysis_attributes")
541
  resolved_surveyor_prompt = sp if isinstance(sp, str) and sp.strip() else DEFAULT_SURVEYOR_SYSTEM_PROMPT
542
  resolved_patient_prompt = pp if isinstance(pp, str) and pp.strip() else DEFAULT_PATIENT_SYSTEM_PROMPT
543
  resolved_analysis_prompt = asp if isinstance(asp, str) and asp.strip() else ""
544
- resolved_analysis_attrs = [s.strip() for s in ap if isinstance(ap, str) and s.strip()] if isinstance(ap, list) else []
545
  template = await self._resolve_top_down_template(top_down_codebook_template_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
546
 
547
  chat_info = HumanChatInfo(
548
  conversation_id=conversation_id,
@@ -554,7 +615,10 @@ class ConversationService:
554
  surveyor_system_prompt=resolved_surveyor_prompt,
555
  patient_system_prompt=resolved_patient_prompt,
556
  analysis_system_prompt=resolved_analysis_prompt,
557
- analysis_attributes=resolved_analysis_attrs,
 
 
 
558
  top_down_template_id=str(template.get("template_id") or ""),
559
  top_down_template_version_id=str(template.get("template_version_id") or ""),
560
  top_down_template_categories=list(template.get("categories") or []),
@@ -863,7 +927,7 @@ class ConversationService:
863
  patient_attributes: Optional[List[str]] = None, # deprecated (DB-canonical)
864
  surveyor_system_prompt: Optional[str] = None, # deprecated (DB-canonical)
865
  patient_system_prompt: Optional[str] = None, # deprecated (DB-canonical)
866
- analysis_attributes: Optional[List[str]] = None, # deprecated (DB-canonical)
867
  surveyor_attributes: Optional[List[str]] = None,
868
  surveyor_question_bank: Optional[str] = None,
869
  top_down_codebook_template_id: Optional[str] = None) -> bool:
@@ -904,12 +968,23 @@ class ConversationService:
904
  sp = await store.get_setting("surveyor_system_prompt")
905
  pp = await store.get_setting("patient_system_prompt")
906
  asp = await store.get_setting("analysis_system_prompt")
907
- ap = await store.get_setting("analysis_attributes")
908
  resolved_surveyor_prompt = sp if isinstance(sp, str) and sp.strip() else DEFAULT_SURVEYOR_SYSTEM_PROMPT
909
  resolved_patient_prompt = pp if isinstance(pp, str) and pp.strip() else DEFAULT_PATIENT_SYSTEM_PROMPT
910
  resolved_analysis_prompt = asp if isinstance(asp, str) and asp.strip() else ""
911
- resolved_analysis_attrs = [s.strip() for s in ap if isinstance(ap, str) and s.strip()] if isinstance(ap, list) else []
912
  template = await self._resolve_top_down_template(top_down_codebook_template_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
913
 
914
  # Create conversation info
915
  conv_info = ConversationInfo(
@@ -922,7 +997,10 @@ class ConversationService:
922
  surveyor_system_prompt=resolved_surveyor_prompt,
923
  patient_system_prompt=resolved_patient_prompt,
924
  analysis_system_prompt=resolved_analysis_prompt,
925
- analysis_attributes=resolved_analysis_attrs,
 
 
 
926
  top_down_template_id=str(template.get("template_id") or ""),
927
  top_down_template_version_id=str(template.get("template_version_id") or ""),
928
  top_down_template_categories=list(template.get("categories") or []),
@@ -1208,8 +1286,11 @@ class ConversationService:
1208
  host=conv_info.host,
1209
  model=conv_info.model,
1210
  settings=self.settings,
1211
- analysis_attributes=getattr(conv_info, "analysis_attributes", None),
1212
  analysis_system_prompt=getattr(conv_info, "analysis_system_prompt", None),
 
 
 
 
1213
  top_down_template_id=getattr(conv_info, "top_down_template_id", None),
1214
  top_down_template_version_id=getattr(conv_info, "top_down_template_version_id", None),
1215
  top_down_template_categories=getattr(conv_info, "top_down_template_categories", None),
@@ -1263,7 +1344,10 @@ class ConversationService:
1263
  },
1264
  "analysis": {
1265
  "analysis_system_prompt": getattr(conv_info, "analysis_system_prompt", None),
1266
- "analysis_attributes": getattr(conv_info, "analysis_attributes", None),
 
 
 
1267
  "top_down_codebook_template_id": getattr(conv_info, "top_down_template_id", None),
1268
  "top_down_codebook_template_version_id": getattr(conv_info, "top_down_template_version_id", None),
1269
  "top_down_codebook_template_snapshot": getattr(conv_info, "top_down_template_categories", None),
 
46
  from backend.storage import RunRecord # noqa: E402
47
  from backend.core.surveyor_knobs import compile_surveyor_attributes_overlay, compile_question_bank_overlay # noqa: E402
48
  from backend.core.patient_knobs import compile_patient_attributes_overlay # noqa: E402
49
+ from backend.core.analysis_knobs import (
50
+ compile_analysis_rules_block,
51
+ DEFAULT_BOTTOM_UP_INSTRUCTIONS,
52
+ DEFAULT_BOTTOM_UP_ATTRIBUTES,
53
+ DEFAULT_RUBRIC_ATTRIBUTES,
54
+ DEFAULT_TOP_DOWN_ATTRIBUTES,
55
+ ) # noqa: E402
56
  from backend.core.universal_prompts import DEFAULT_PATIENT_SYSTEM_PROMPT, DEFAULT_SURVEYOR_SYSTEM_PROMPT # noqa: E402
57
 
58
  # Setup logging
 
102
  host: str,
103
  model: str,
104
  settings: AppSettings,
 
105
  analysis_system_prompt: Optional[str] = None,
106
+ bottom_up_instructions: Optional[str] = None,
107
+ bottom_up_attributes: Optional[List[str]] = None,
108
+ rubric_attributes: Optional[List[str]] = None,
109
+ top_down_attributes: Optional[List[str]] = None,
110
  top_down_template_id: Optional[str] = None,
111
  top_down_template_version_id: Optional[str] = None,
112
  top_down_template_categories: Optional[List[Dict[str, str]]] = None,
 
163
  "health survey conversation between a surveyor and a patient. Your task is to extract "
164
  "post-hoc insights as strict JSON for a UI."
165
  )
166
+ common_system_prompt = base.strip()
167
+
168
+ resolved_bottom_instructions = (bottom_up_instructions or "").strip() or DEFAULT_BOTTOM_UP_INSTRUCTIONS
169
+ bottom_system_prompt = (
170
+ common_system_prompt
171
+ + "\n\nBottom-up analysis instructions:\n"
172
+ + resolved_bottom_instructions.strip()
173
+ + "\n\n"
174
+ + compile_analysis_rules_block(bottom_up_attributes, defaults=DEFAULT_BOTTOM_UP_ATTRIBUTES)
175
+ ).strip()
176
+ rubric_system_prompt = (
177
+ common_system_prompt
178
+ + "\n\n"
179
+ + compile_analysis_rules_block(rubric_attributes, defaults=DEFAULT_RUBRIC_ATTRIBUTES)
180
+ ).strip()
181
+ top_down_system_prompt = (
182
+ common_system_prompt
183
+ + "\n\n"
184
+ + compile_analysis_rules_block(top_down_attributes, defaults=DEFAULT_TOP_DOWN_ATTRIBUTES)
185
+ ).strip()
186
 
187
  template_categories = top_down_template_categories or []
188
  template_categories = [
 
312
  await _phase("top_down", "pending")
313
 
314
  await _phase("bottom_up", "running")
315
+ bottom_raw = await client.generate(prompt=_bottom_up_prompt(), system_prompt=bottom_system_prompt, temperature=0.2)
316
  bottom = json.loads(bottom_raw) if isinstance(bottom_raw, str) else {}
317
  for item in (bottom.get("health_situations", []) or []):
318
  normalized = _normalize_confidence(item.get("confidence"))
 
327
  await _phase("bottom_up", "complete")
328
 
329
  await _phase("rubric", "running")
330
+ rubric_raw = await client.generate(prompt=_rubric_prompt(), system_prompt=rubric_system_prompt, temperature=0.2)
331
  rubric = json.loads(rubric_raw) if isinstance(rubric_raw, str) else {}
332
  care_experience = rubric.get("care_experience") or {}
333
  for key in ("positive", "mixed", "negative", "neutral"):
 
342
  await _phase("rubric", "complete")
343
 
344
  await _phase("top_down", "running")
345
+ top_raw = await client.generate(prompt=_top_down_prompt(), system_prompt=top_down_system_prompt, temperature=0.2)
346
  top = json.loads(top_raw) if isinstance(top_raw, str) else {}
347
 
348
  td_book = top.get("top_down_codebook")
 
419
  surveyor_system_prompt: str = ""
420
  patient_system_prompt: str = ""
421
  analysis_system_prompt: str = ""
422
+ bottom_up_instructions: str = ""
423
+ bottom_up_attributes: List[str] = field(default_factory=list)
424
+ rubric_attributes: List[str] = field(default_factory=list)
425
+ top_down_attributes: List[str] = field(default_factory=list)
426
  top_down_template_id: str = ""
427
  top_down_template_version_id: str = ""
428
  top_down_template_categories: List[Dict[str, str]] = field(default_factory=list)
 
447
  surveyor_system_prompt: str = ""
448
  patient_system_prompt: str = ""
449
  analysis_system_prompt: str = ""
450
+ bottom_up_instructions: str = ""
451
+ bottom_up_attributes: List[str] = field(default_factory=list)
452
+ rubric_attributes: List[str] = field(default_factory=list)
453
+ top_down_attributes: List[str] = field(default_factory=list)
454
  top_down_template_id: str = ""
455
  top_down_template_version_id: str = ""
456
  top_down_template_categories: List[Dict[str, str]] = field(default_factory=list)
 
519
  return {
520
  "template_id": record.template_id,
521
  "template_version_id": record.current_version_id,
522
+ "bottom_up_instructions": record.bottom_up_instructions,
523
+ "bottom_up_attributes": list(record.bottom_up_attributes),
524
+ "rubric_attributes": list(record.rubric_attributes),
525
+ "top_down_attributes": list(record.top_down_attributes),
526
  "categories": record.categories,
527
  }
528
 
 
533
  return {
534
  "template_id": record.template_id,
535
  "template_version_id": record.current_version_id,
536
+ "bottom_up_instructions": record.bottom_up_instructions,
537
+ "bottom_up_attributes": list(record.bottom_up_attributes),
538
+ "rubric_attributes": list(record.rubric_attributes),
539
+ "top_down_attributes": list(record.top_down_attributes),
540
  "categories": record.categories,
541
  }
542
+ return {
543
+ "template_id": template_id_str,
544
+ "template_version_id": "",
545
+ "bottom_up_instructions": DEFAULT_BOTTOM_UP_INSTRUCTIONS,
546
+ "bottom_up_attributes": list(DEFAULT_BOTTOM_UP_ATTRIBUTES),
547
+ "rubric_attributes": list(DEFAULT_RUBRIC_ATTRIBUTES),
548
+ "top_down_attributes": list(DEFAULT_TOP_DOWN_ATTRIBUTES),
549
+ "categories": [],
550
+ }
551
 
552
  async def start_human_chat(
553
  self,
 
559
  patient_attributes: Optional[List[str]] = None, # deprecated (persona content is DB-canonical)
560
  surveyor_system_prompt: Optional[str] = None, # deprecated (DB-canonical)
561
  patient_system_prompt: Optional[str] = None, # deprecated (DB-canonical)
562
+ analysis_attributes: Optional[List[str]] = None, # deprecated legacy field (ignore)
563
  surveyor_attributes: Optional[List[str]] = None,
564
  surveyor_question_bank: Optional[str] = None,
565
  ai_role: Optional[str] = None,
 
587
  sp = await store.get_setting("surveyor_system_prompt")
588
  pp = await store.get_setting("patient_system_prompt")
589
  asp = await store.get_setting("analysis_system_prompt")
 
590
  resolved_surveyor_prompt = sp if isinstance(sp, str) and sp.strip() else DEFAULT_SURVEYOR_SYSTEM_PROMPT
591
  resolved_patient_prompt = pp if isinstance(pp, str) and pp.strip() else DEFAULT_PATIENT_SYSTEM_PROMPT
592
  resolved_analysis_prompt = asp if isinstance(asp, str) and asp.strip() else ""
 
593
  template = await self._resolve_top_down_template(top_down_codebook_template_id)
594
+ resolved_bottom_instructions = str(template.get("bottom_up_instructions") or "").strip() or DEFAULT_BOTTOM_UP_INSTRUCTIONS
595
+ bua = template.get("bottom_up_attributes")
596
+ ra = template.get("rubric_attributes")
597
+ tda = template.get("top_down_attributes")
598
+ resolved_bottom_attrs = [s.strip() for s in bua if isinstance(s, str) and s.strip()] if isinstance(bua, list) else []
599
+ resolved_rubric_attrs = [s.strip() for s in ra if isinstance(s, str) and s.strip()] if isinstance(ra, list) else []
600
+ resolved_top_down_attrs = [s.strip() for s in tda if isinstance(s, str) and s.strip()] if isinstance(tda, list) else []
601
+ if not resolved_bottom_attrs:
602
+ resolved_bottom_attrs = list(DEFAULT_BOTTOM_UP_ATTRIBUTES)
603
+ if not resolved_rubric_attrs:
604
+ resolved_rubric_attrs = list(DEFAULT_RUBRIC_ATTRIBUTES)
605
+ if not resolved_top_down_attrs:
606
+ resolved_top_down_attrs = list(DEFAULT_TOP_DOWN_ATTRIBUTES)
607
 
608
  chat_info = HumanChatInfo(
609
  conversation_id=conversation_id,
 
615
  surveyor_system_prompt=resolved_surveyor_prompt,
616
  patient_system_prompt=resolved_patient_prompt,
617
  analysis_system_prompt=resolved_analysis_prompt,
618
+ bottom_up_instructions=resolved_bottom_instructions,
619
+ bottom_up_attributes=resolved_bottom_attrs,
620
+ rubric_attributes=resolved_rubric_attrs,
621
+ top_down_attributes=resolved_top_down_attrs,
622
  top_down_template_id=str(template.get("template_id") or ""),
623
  top_down_template_version_id=str(template.get("template_version_id") or ""),
624
  top_down_template_categories=list(template.get("categories") or []),
 
927
  patient_attributes: Optional[List[str]] = None, # deprecated (DB-canonical)
928
  surveyor_system_prompt: Optional[str] = None, # deprecated (DB-canonical)
929
  patient_system_prompt: Optional[str] = None, # deprecated (DB-canonical)
930
+ analysis_attributes: Optional[List[str]] = None, # deprecated legacy field (ignore)
931
  surveyor_attributes: Optional[List[str]] = None,
932
  surveyor_question_bank: Optional[str] = None,
933
  top_down_codebook_template_id: Optional[str] = None) -> bool:
 
968
  sp = await store.get_setting("surveyor_system_prompt")
969
  pp = await store.get_setting("patient_system_prompt")
970
  asp = await store.get_setting("analysis_system_prompt")
 
971
  resolved_surveyor_prompt = sp if isinstance(sp, str) and sp.strip() else DEFAULT_SURVEYOR_SYSTEM_PROMPT
972
  resolved_patient_prompt = pp if isinstance(pp, str) and pp.strip() else DEFAULT_PATIENT_SYSTEM_PROMPT
973
  resolved_analysis_prompt = asp if isinstance(asp, str) and asp.strip() else ""
 
974
  template = await self._resolve_top_down_template(top_down_codebook_template_id)
975
+ resolved_bottom_instructions = str(template.get("bottom_up_instructions") or "").strip() or DEFAULT_BOTTOM_UP_INSTRUCTIONS
976
+ bua = template.get("bottom_up_attributes")
977
+ ra = template.get("rubric_attributes")
978
+ tda = template.get("top_down_attributes")
979
+ resolved_bottom_attrs = [s.strip() for s in bua if isinstance(s, str) and s.strip()] if isinstance(bua, list) else []
980
+ resolved_rubric_attrs = [s.strip() for s in ra if isinstance(s, str) and s.strip()] if isinstance(ra, list) else []
981
+ resolved_top_down_attrs = [s.strip() for s in tda if isinstance(s, str) and s.strip()] if isinstance(tda, list) else []
982
+ if not resolved_bottom_attrs:
983
+ resolved_bottom_attrs = list(DEFAULT_BOTTOM_UP_ATTRIBUTES)
984
+ if not resolved_rubric_attrs:
985
+ resolved_rubric_attrs = list(DEFAULT_RUBRIC_ATTRIBUTES)
986
+ if not resolved_top_down_attrs:
987
+ resolved_top_down_attrs = list(DEFAULT_TOP_DOWN_ATTRIBUTES)
988
 
989
  # Create conversation info
990
  conv_info = ConversationInfo(
 
997
  surveyor_system_prompt=resolved_surveyor_prompt,
998
  patient_system_prompt=resolved_patient_prompt,
999
  analysis_system_prompt=resolved_analysis_prompt,
1000
+ bottom_up_instructions=resolved_bottom_instructions,
1001
+ bottom_up_attributes=resolved_bottom_attrs,
1002
+ rubric_attributes=resolved_rubric_attrs,
1003
+ top_down_attributes=resolved_top_down_attrs,
1004
  top_down_template_id=str(template.get("template_id") or ""),
1005
  top_down_template_version_id=str(template.get("template_version_id") or ""),
1006
  top_down_template_categories=list(template.get("categories") or []),
 
1286
  host=conv_info.host,
1287
  model=conv_info.model,
1288
  settings=self.settings,
 
1289
  analysis_system_prompt=getattr(conv_info, "analysis_system_prompt", None),
1290
+ bottom_up_instructions=getattr(conv_info, "bottom_up_instructions", None),
1291
+ bottom_up_attributes=getattr(conv_info, "bottom_up_attributes", None),
1292
+ rubric_attributes=getattr(conv_info, "rubric_attributes", None),
1293
+ top_down_attributes=getattr(conv_info, "top_down_attributes", None),
1294
  top_down_template_id=getattr(conv_info, "top_down_template_id", None),
1295
  top_down_template_version_id=getattr(conv_info, "top_down_template_version_id", None),
1296
  top_down_template_categories=getattr(conv_info, "top_down_template_categories", None),
 
1344
  },
1345
  "analysis": {
1346
  "analysis_system_prompt": getattr(conv_info, "analysis_system_prompt", None),
1347
+ "bottom_up_instructions": getattr(conv_info, "bottom_up_instructions", None),
1348
+ "bottom_up_attributes": getattr(conv_info, "bottom_up_attributes", None),
1349
+ "rubric_attributes": getattr(conv_info, "rubric_attributes", None),
1350
+ "top_down_attributes": getattr(conv_info, "top_down_attributes", None),
1351
  "top_down_codebook_template_id": getattr(conv_info, "top_down_template_id", None),
1352
  "top_down_codebook_template_version_id": getattr(conv_info, "top_down_template_version_id", None),
1353
  "top_down_codebook_template_snapshot": getattr(conv_info, "top_down_template_categories", None),
backend/api/persona_routes.py CHANGED
@@ -11,7 +11,7 @@ from backend.core.persona_system import get_persona_system
11
  from .storage_service import get_persona_store, refresh_persona_cache
12
  from .conversation_service import get_conversation_service
13
  from backend.core.universal_prompts import DEFAULT_PATIENT_SYSTEM_PROMPT, DEFAULT_SURVEYOR_SYSTEM_PROMPT, DEFAULT_ANALYSIS_SYSTEM_PROMPT
14
- from backend.core.analysis_knobs import DEFAULT_ANALYSIS_ATTRIBUTES
15
 
16
  router = APIRouter(prefix="", tags=["personas"])
17
 
@@ -51,7 +51,6 @@ class SettingsResponse(BaseModel):
51
  surveyor_system_prompt: str
52
  patient_system_prompt: str
53
  analysis_system_prompt: str
54
- analysis_attributes: List[str]
55
  top_down_codebook_template_id: str
56
 
57
 
@@ -59,7 +58,6 @@ class UpdateSettingsRequest(BaseModel):
59
  surveyor_system_prompt: Optional[str] = None
60
  patient_system_prompt: Optional[str] = None
61
  analysis_system_prompt: Optional[str] = None
62
- analysis_attributes: Optional[List[str]] = None
63
  top_down_codebook_template_id: Optional[str] = None
64
 
65
 
@@ -69,8 +67,7 @@ async def get_settings_defaults() -> SettingsResponse:
69
  surveyor_system_prompt=DEFAULT_SURVEYOR_SYSTEM_PROMPT,
70
  patient_system_prompt=DEFAULT_PATIENT_SYSTEM_PROMPT,
71
  analysis_system_prompt=DEFAULT_ANALYSIS_SYSTEM_PROMPT,
72
- analysis_attributes=list(DEFAULT_ANALYSIS_ATTRIBUTES),
73
- top_down_codebook_template_id="top_down_v1",
74
  )
75
 
76
 
@@ -246,13 +243,11 @@ async def get_settings() -> SettingsResponse:
246
  sp = await store.get_setting("surveyor_system_prompt")
247
  pp = await store.get_setting("patient_system_prompt")
248
  asp = await store.get_setting("analysis_system_prompt")
249
- ap = await store.get_setting("analysis_attributes")
250
  td = await store.get_setting("top_down_codebook_template_id")
251
  return SettingsResponse(
252
  surveyor_system_prompt=sp if isinstance(sp, str) else "",
253
  patient_system_prompt=pp if isinstance(pp, str) else "",
254
  analysis_system_prompt=asp if isinstance(asp, str) else "",
255
- analysis_attributes=[s for s in ap if isinstance(ap, str)] if isinstance(ap, list) else [],
256
  top_down_codebook_template_id=td if isinstance(td, str) else "",
257
  )
258
 
@@ -266,11 +261,6 @@ async def update_settings(payload: UpdateSettingsRequest) -> SettingsResponse:
266
  await store.upsert_setting("patient_system_prompt", str(payload.patient_system_prompt))
267
  if payload.analysis_system_prompt is not None:
268
  await store.upsert_setting("analysis_system_prompt", str(payload.analysis_system_prompt))
269
- if payload.analysis_attributes is not None:
270
- await store.upsert_setting(
271
- "analysis_attributes",
272
- [s.strip() for s in (payload.analysis_attributes or []) if isinstance(s, str) and s.strip()],
273
- )
274
  if payload.top_down_codebook_template_id is not None:
275
  await store.upsert_setting("top_down_codebook_template_id", str(payload.top_down_codebook_template_id))
276
  return await get_settings()
 
11
  from .storage_service import get_persona_store, refresh_persona_cache
12
  from .conversation_service import get_conversation_service
13
  from backend.core.universal_prompts import DEFAULT_PATIENT_SYSTEM_PROMPT, DEFAULT_SURVEYOR_SYSTEM_PROMPT, DEFAULT_ANALYSIS_SYSTEM_PROMPT
14
+ from backend.core.default_analysis_templates import DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE
15
 
16
  router = APIRouter(prefix="", tags=["personas"])
17
 
 
51
  surveyor_system_prompt: str
52
  patient_system_prompt: str
53
  analysis_system_prompt: str
 
54
  top_down_codebook_template_id: str
55
 
56
 
 
58
  surveyor_system_prompt: Optional[str] = None
59
  patient_system_prompt: Optional[str] = None
60
  analysis_system_prompt: Optional[str] = None
 
61
  top_down_codebook_template_id: Optional[str] = None
62
 
63
 
 
67
  surveyor_system_prompt=DEFAULT_SURVEYOR_SYSTEM_PROMPT,
68
  patient_system_prompt=DEFAULT_PATIENT_SYSTEM_PROMPT,
69
  analysis_system_prompt=DEFAULT_ANALYSIS_SYSTEM_PROMPT,
70
+ top_down_codebook_template_id=str(DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE.get("template_id") or "top_down_v1"),
 
71
  )
72
 
73
 
 
243
  sp = await store.get_setting("surveyor_system_prompt")
244
  pp = await store.get_setting("patient_system_prompt")
245
  asp = await store.get_setting("analysis_system_prompt")
 
246
  td = await store.get_setting("top_down_codebook_template_id")
247
  return SettingsResponse(
248
  surveyor_system_prompt=sp if isinstance(sp, str) else "",
249
  patient_system_prompt=pp if isinstance(pp, str) else "",
250
  analysis_system_prompt=asp if isinstance(asp, str) else "",
 
251
  top_down_codebook_template_id=td if isinstance(td, str) else "",
252
  )
253
 
 
261
  await store.upsert_setting("patient_system_prompt", str(payload.patient_system_prompt))
262
  if payload.analysis_system_prompt is not None:
263
  await store.upsert_setting("analysis_system_prompt", str(payload.analysis_system_prompt))
 
 
 
 
 
264
  if payload.top_down_codebook_template_id is not None:
265
  await store.upsert_setting("top_down_codebook_template_id", str(payload.top_down_codebook_template_id))
266
  return await get_settings()
backend/core/analysis_knobs.py CHANGED
@@ -1,18 +1,41 @@
1
  from __future__ import annotations
2
 
3
- from typing import Any, List
4
 
5
 
6
- DEFAULT_ANALYSIS_ATTRIBUTES: List[str] = [
 
 
 
 
 
7
  "Use ONLY the provided transcript.",
8
  "Output MUST be valid JSON only (no markdown, no backticks).",
9
  "Evidence must be selected from the provided evidence catalog by evidence_id.",
10
  "Do NOT invent quotes. Do NOT paraphrase evidence. Cite by evidence_id only.",
11
- "For care experience: do not duplicate the same evidence_id across positive/negative/mixed/neutral. If a sentence supports both positive and negative interpretations, put it in care_experience.mixed.",
12
  "confidence must be a number between 0 and 1.",
 
13
  "For health_situations: include a short code label (1-3 words) in addition to the longer summary.",
14
- "For top_down_codebook categories: include a short code label (1-3 words) and cite evidence.",
 
 
 
 
 
 
 
15
  "Prefer fewer, higher-confidence items.",
 
 
 
 
 
 
 
 
 
 
 
16
  ]
17
 
18
 
@@ -29,9 +52,9 @@ def normalize_analysis_attributes(attributes: Any) -> List[str]:
29
  return cleaned
30
 
31
 
32
- def compile_analysis_rules_block(attributes: Any) -> str:
33
  rules = normalize_analysis_attributes(attributes)
34
  if not rules:
35
- rules = DEFAULT_ANALYSIS_ATTRIBUTES
36
  bullets = "\n".join(f"- {line}" for line in rules)
37
  return "Rules:\n" + bullets + "\n"
 
1
  from __future__ import annotations
2
 
3
+ from typing import Any, List, Optional
4
 
5
 
6
+ DEFAULT_BOTTOM_UP_INSTRUCTIONS: str = (
7
+ "You are performing bottom-up thematic analysis. Identify the most prominent themes in the conversation, with no a priori concepts; the themes are emergent from the text."
8
+ )
9
+
10
+
11
+ DEFAULT_BOTTOM_UP_ATTRIBUTES: List[str] = [
12
  "Use ONLY the provided transcript.",
13
  "Output MUST be valid JSON only (no markdown, no backticks).",
14
  "Evidence must be selected from the provided evidence catalog by evidence_id.",
15
  "Do NOT invent quotes. Do NOT paraphrase evidence. Cite by evidence_id only.",
 
16
  "confidence must be a number between 0 and 1.",
17
+ "Prefer fewer, higher-confidence items.",
18
  "For health_situations: include a short code label (1-3 words) in addition to the longer summary.",
19
+ ]
20
+
21
+ DEFAULT_RUBRIC_ATTRIBUTES: List[str] = [
22
+ "Use ONLY the provided transcript.",
23
+ "Output MUST be valid JSON only (no markdown, no backticks).",
24
+ "Evidence must be selected from the provided evidence catalog by evidence_id.",
25
+ "Do NOT invent quotes. Do NOT paraphrase evidence. Cite by evidence_id only.",
26
+ "confidence must be a number between 0 and 1.",
27
  "Prefer fewer, higher-confidence items.",
28
+ "For care experience: do not duplicate the same evidence_id across positive/negative/mixed/neutral. If a sentence supports both positive and negative interpretations, put it in care_experience.mixed.",
29
+ ]
30
+
31
+ DEFAULT_TOP_DOWN_ATTRIBUTES: List[str] = [
32
+ "Use ONLY the provided transcript.",
33
+ "Output MUST be valid JSON only (no markdown, no backticks).",
34
+ "Evidence must be selected from the provided evidence catalog by evidence_id.",
35
+ "Do NOT invent quotes. Do NOT paraphrase evidence. Cite by evidence_id only.",
36
+ "confidence must be a number between 0 and 1.",
37
+ "Prefer fewer, higher-confidence items.",
38
+ "For top_down_codebook categories: include a short code label (1-3 words) and cite evidence.",
39
  ]
40
 
41
 
 
52
  return cleaned
53
 
54
 
55
+ def compile_analysis_rules_block(attributes: Any, *, defaults: Optional[List[str]] = None) -> str:
56
  rules = normalize_analysis_attributes(attributes)
57
  if not rules:
58
+ rules = list(defaults or [])
59
  bullets = "\n".join(f"- {line}" for line in rules)
60
  return "Rules:\n" + bullets + "\n"
backend/core/default_analysis_templates.py CHANGED
@@ -2,10 +2,20 @@ from __future__ import annotations
2
 
3
  from typing import Dict, List
4
 
 
 
 
 
 
 
5
 
6
  DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE: Dict[str, object] = {
7
  "template_id": "top_down_v1",
8
  "name": "Default top-down codebook (v1)",
 
 
 
 
9
  "categories": [
10
  {"category_id": "symptoms_concerns", "label": "Symptoms/concerns"},
11
  {"category_id": "daily_management", "label": "Daily management"},
@@ -20,4 +30,3 @@ def default_top_down_categories() -> List[Dict[str, str]]:
20
  if isinstance(cats, list):
21
  return [c for c in cats if isinstance(c, dict)] # type: ignore[return-value]
22
  return []
23
-
 
2
 
3
  from typing import Dict, List
4
 
5
+ from backend.core.analysis_knobs import (
6
+ DEFAULT_BOTTOM_UP_INSTRUCTIONS,
7
+ DEFAULT_BOTTOM_UP_ATTRIBUTES,
8
+ DEFAULT_RUBRIC_ATTRIBUTES,
9
+ DEFAULT_TOP_DOWN_ATTRIBUTES,
10
+ )
11
 
12
  DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE: Dict[str, object] = {
13
  "template_id": "top_down_v1",
14
  "name": "Default top-down codebook (v1)",
15
+ "bottom_up_instructions": DEFAULT_BOTTOM_UP_INSTRUCTIONS,
16
+ "bottom_up_attributes": list(DEFAULT_BOTTOM_UP_ATTRIBUTES),
17
+ "rubric_attributes": list(DEFAULT_RUBRIC_ATTRIBUTES),
18
+ "top_down_attributes": list(DEFAULT_TOP_DOWN_ATTRIBUTES),
19
  "categories": [
20
  {"category_id": "symptoms_concerns", "label": "Symptoms/concerns"},
21
  {"category_id": "daily_management", "label": "Daily management"},
 
30
  if isinstance(cats, list):
31
  return [c for c in cats if isinstance(c, dict)] # type: ignore[return-value]
32
  return []
 
backend/core/persona_seed.py CHANGED
@@ -4,7 +4,12 @@ from typing import Any, Dict, List
4
 
5
  from backend.core.default_personas import DEFAULT_PATIENT_PERSONAS, DEFAULT_SURVEYOR_PERSONAS
6
  from backend.core.universal_prompts import DEFAULT_PATIENT_SYSTEM_PROMPT, DEFAULT_SURVEYOR_SYSTEM_PROMPT, DEFAULT_ANALYSIS_SYSTEM_PROMPT
7
- from backend.core.analysis_knobs import DEFAULT_ANALYSIS_ATTRIBUTES
 
 
 
 
 
8
  from backend.core.default_analysis_templates import DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE, default_top_down_categories
9
  from backend.storage.sqlite_persona_store import SQLitePersonaStore
10
 
@@ -19,10 +24,6 @@ async def seed_defaults_overwrite(*, store: SQLitePersonaStore) -> None:
19
  if not (isinstance(existing_patient_prompt, str) and existing_patient_prompt.strip()):
20
  await store.upsert_setting("patient_system_prompt", DEFAULT_PATIENT_SYSTEM_PROMPT)
21
 
22
- existing_analysis_attrs = await store.get_setting("analysis_attributes")
23
- if not (isinstance(existing_analysis_attrs, list) and any(isinstance(x, str) and x.strip() for x in existing_analysis_attrs)):
24
- await store.upsert_setting("analysis_attributes", DEFAULT_ANALYSIS_ATTRIBUTES)
25
-
26
  existing_analysis_prompt = await store.get_setting("analysis_system_prompt")
27
  if not (isinstance(existing_analysis_prompt, str) and existing_analysis_prompt.strip()):
28
  await store.upsert_setting("analysis_system_prompt", DEFAULT_ANALYSIS_SYSTEM_PROMPT)
@@ -34,6 +35,10 @@ async def seed_defaults_overwrite(*, store: SQLitePersonaStore) -> None:
34
  await store.upsert_default_analysis_template(
35
  template_id=template_id,
36
  name=template_name,
 
 
 
 
37
  categories=default_top_down_categories(),
38
  )
39
  except Exception as e:
@@ -46,6 +51,33 @@ async def seed_defaults_overwrite(*, store: SQLitePersonaStore) -> None:
46
  if not (isinstance(active_template, str) and active_template.strip()):
47
  await store.upsert_setting("top_down_codebook_template_id", template_id)
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  # Personas (overwrite by writing a new version each startup)
50
  for p in (DEFAULT_SURVEYOR_PERSONAS + DEFAULT_PATIENT_PERSONAS):
51
  await store.upsert_default_persona(
 
4
 
5
  from backend.core.default_personas import DEFAULT_PATIENT_PERSONAS, DEFAULT_SURVEYOR_PERSONAS
6
  from backend.core.universal_prompts import DEFAULT_PATIENT_SYSTEM_PROMPT, DEFAULT_SURVEYOR_SYSTEM_PROMPT, DEFAULT_ANALYSIS_SYSTEM_PROMPT
7
+ from backend.core.analysis_knobs import (
8
+ DEFAULT_BOTTOM_UP_INSTRUCTIONS,
9
+ DEFAULT_BOTTOM_UP_ATTRIBUTES,
10
+ DEFAULT_RUBRIC_ATTRIBUTES,
11
+ DEFAULT_TOP_DOWN_ATTRIBUTES,
12
+ )
13
  from backend.core.default_analysis_templates import DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE, default_top_down_categories
14
  from backend.storage.sqlite_persona_store import SQLitePersonaStore
15
 
 
24
  if not (isinstance(existing_patient_prompt, str) and existing_patient_prompt.strip()):
25
  await store.upsert_setting("patient_system_prompt", DEFAULT_PATIENT_SYSTEM_PROMPT)
26
 
 
 
 
 
27
  existing_analysis_prompt = await store.get_setting("analysis_system_prompt")
28
  if not (isinstance(existing_analysis_prompt, str) and existing_analysis_prompt.strip()):
29
  await store.upsert_setting("analysis_system_prompt", DEFAULT_ANALYSIS_SYSTEM_PROMPT)
 
35
  await store.upsert_default_analysis_template(
36
  template_id=template_id,
37
  name=template_name,
38
+ bottom_up_instructions=str(DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE.get("bottom_up_instructions") or DEFAULT_BOTTOM_UP_INSTRUCTIONS),
39
+ bottom_up_attributes=list(DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE.get("bottom_up_attributes") or DEFAULT_BOTTOM_UP_ATTRIBUTES), # type: ignore[list-item]
40
+ rubric_attributes=list(DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE.get("rubric_attributes") or DEFAULT_RUBRIC_ATTRIBUTES), # type: ignore[list-item]
41
+ top_down_attributes=list(DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE.get("top_down_attributes") or DEFAULT_TOP_DOWN_ATTRIBUTES), # type: ignore[list-item]
42
  categories=default_top_down_categories(),
43
  )
44
  except Exception as e:
 
51
  if not (isinstance(active_template, str) and active_template.strip()):
52
  await store.upsert_setting("top_down_codebook_template_id", template_id)
53
 
54
+ # Migrate older analysis templates (created before bottom-up/rubric/top-down settings were part of the template content)
55
+ # by writing a new version that includes the new fields (keeps categories as-is).
56
+ try:
57
+ templates = await store.list_analysis_template_records(include_deleted=False)
58
+ for t in templates:
59
+ needs = (
60
+ not isinstance(t.bottom_up_instructions, str)
61
+ or not t.bottom_up_instructions.strip()
62
+ or not (isinstance(t.bottom_up_attributes, list) and any(isinstance(x, str) and x.strip() for x in t.bottom_up_attributes))
63
+ or not (isinstance(t.rubric_attributes, list) and any(isinstance(x, str) and x.strip() for x in t.rubric_attributes))
64
+ or not (isinstance(t.top_down_attributes, list) and any(isinstance(x, str) and x.strip() for x in t.top_down_attributes))
65
+ )
66
+ if not needs:
67
+ continue
68
+ await store.update_analysis_template(
69
+ template_id=t.template_id,
70
+ bottom_up_instructions=t.bottom_up_instructions.strip() if isinstance(t.bottom_up_instructions, str) and t.bottom_up_instructions.strip() else DEFAULT_BOTTOM_UP_INSTRUCTIONS,
71
+ bottom_up_attributes=t.bottom_up_attributes if t.bottom_up_attributes else list(DEFAULT_BOTTOM_UP_ATTRIBUTES),
72
+ rubric_attributes=t.rubric_attributes if t.rubric_attributes else list(DEFAULT_RUBRIC_ATTRIBUTES),
73
+ top_down_attributes=t.top_down_attributes if t.top_down_attributes else list(DEFAULT_TOP_DOWN_ATTRIBUTES),
74
+ categories=t.categories,
75
+ allow_default=True,
76
+ )
77
+ except Exception as e:
78
+ import logging
79
+ logging.getLogger(__name__).warning(f"Failed to migrate analysis templates to framework format: {e}")
80
+
81
  # Personas (overwrite by writing a new version each startup)
82
  for p in (DEFAULT_SURVEYOR_PERSONAS + DEFAULT_PATIENT_PERSONAS):
83
  await store.upsert_default_persona(
backend/storage/models.py CHANGED
@@ -71,4 +71,8 @@ class AnalysisTemplateRecord:
71
  current_version_id: str
72
  created_at: str
73
  updated_at: str
 
 
 
 
74
  categories: List[Dict[str, str]]
 
71
  current_version_id: str
72
  created_at: str
73
  updated_at: str
74
+ bottom_up_instructions: str
75
+ bottom_up_attributes: List[str]
76
+ rubric_attributes: List[str]
77
+ top_down_attributes: List[str]
78
  categories: List[Dict[str, str]]
backend/storage/sqlite_persona_store.py CHANGED
@@ -26,6 +26,10 @@ def _clean_str_list(value: Any) -> List[str]:
26
  return out
27
 
28
 
 
 
 
 
29
  def _clean_category_list(value: Any) -> List[Dict[str, str]]:
30
  if not isinstance(value, list):
31
  return []
@@ -199,6 +203,10 @@ class SQLitePersonaStore:
199
  current_version_id=row[4],
200
  created_at=row[5],
201
  updated_at=row[6],
 
 
 
 
202
  categories=_clean_category_list(content.get("categories")),
203
  )
204
  )
@@ -252,6 +260,10 @@ class SQLitePersonaStore:
252
  current_version_id=row[4],
253
  created_at=row[5],
254
  updated_at=row[6],
 
 
 
 
255
  categories=_clean_category_list(content.get("categories")),
256
  )
257
 
@@ -260,6 +272,10 @@ class SQLitePersonaStore:
260
  *,
261
  template_id: str,
262
  name: str,
 
 
 
 
263
  categories: List[Dict[str, str]],
264
  ) -> Tuple[str, str]:
265
  if not isinstance(template_id, str) or not template_id.strip():
@@ -269,10 +285,23 @@ class SQLitePersonaStore:
269
  cleaned_categories = _clean_category_list(categories)
270
  if not cleaned_categories:
271
  raise ValueError("categories are required")
 
 
 
 
272
 
273
  now = _now_iso()
274
  version_id = str(uuid4())
275
- content_json = json.dumps({"categories": cleaned_categories}, ensure_ascii=False)
 
 
 
 
 
 
 
 
 
276
 
277
  async with aiosqlite.connect(self.db_path) as db:
278
  await db.execute("PRAGMA foreign_keys=ON;")
@@ -314,6 +343,10 @@ class SQLitePersonaStore:
314
  self,
315
  *,
316
  name: str,
 
 
 
 
317
  categories: List[Dict[str, str]],
318
  is_default: bool = False,
319
  ) -> Tuple[str, str]:
@@ -322,11 +355,24 @@ class SQLitePersonaStore:
322
  cleaned_categories = _clean_category_list(categories)
323
  if not cleaned_categories:
324
  raise ValueError("categories are required")
 
 
 
 
325
 
326
  now = _now_iso()
327
  template_id = str(uuid4())
328
  version_id = str(uuid4())
329
- content_json = json.dumps({"categories": cleaned_categories}, ensure_ascii=False)
 
 
 
 
 
 
 
 
 
330
 
331
  async with aiosqlite.connect(self.db_path) as db:
332
  await db.execute("PRAGMA foreign_keys=ON;")
@@ -356,17 +402,35 @@ class SQLitePersonaStore:
356
  self,
357
  *,
358
  template_id: str,
 
 
 
 
359
  categories: List[Dict[str, str]],
 
360
  ) -> str:
361
  if not isinstance(template_id, str) or not template_id.strip():
362
  raise ValueError("template_id is required")
363
  cleaned_categories = _clean_category_list(categories)
364
  if not cleaned_categories:
365
  raise ValueError("categories are required")
 
 
 
 
366
 
367
  now = _now_iso()
368
  version_id = str(uuid4())
369
- content_json = json.dumps({"categories": cleaned_categories}, ensure_ascii=False)
 
 
 
 
 
 
 
 
 
370
 
371
  async with aiosqlite.connect(self.db_path) as db:
372
  await db.execute("PRAGMA foreign_keys=ON;")
@@ -379,7 +443,7 @@ class SQLitePersonaStore:
379
  raise ValueError("template not found")
380
  if bool(row[1]):
381
  raise ValueError("template not found")
382
- if bool(row[0]):
383
  raise PermissionError("default template is immutable")
384
 
385
  await db.execute(
 
26
  return out
27
 
28
 
29
+ def _clean_str(value: Any) -> str:
30
+ return value.strip() if isinstance(value, str) else ""
31
+
32
+
33
  def _clean_category_list(value: Any) -> List[Dict[str, str]]:
34
  if not isinstance(value, list):
35
  return []
 
203
  current_version_id=row[4],
204
  created_at=row[5],
205
  updated_at=row[6],
206
+ bottom_up_instructions=_clean_str(content.get("bottom_up_instructions")),
207
+ bottom_up_attributes=_clean_str_list(content.get("bottom_up_attributes")),
208
+ rubric_attributes=_clean_str_list(content.get("rubric_attributes")),
209
+ top_down_attributes=_clean_str_list(content.get("top_down_attributes")),
210
  categories=_clean_category_list(content.get("categories")),
211
  )
212
  )
 
260
  current_version_id=row[4],
261
  created_at=row[5],
262
  updated_at=row[6],
263
+ bottom_up_instructions=_clean_str(content.get("bottom_up_instructions")),
264
+ bottom_up_attributes=_clean_str_list(content.get("bottom_up_attributes")),
265
+ rubric_attributes=_clean_str_list(content.get("rubric_attributes")),
266
+ top_down_attributes=_clean_str_list(content.get("top_down_attributes")),
267
  categories=_clean_category_list(content.get("categories")),
268
  )
269
 
 
272
  *,
273
  template_id: str,
274
  name: str,
275
+ bottom_up_instructions: str,
276
+ bottom_up_attributes: List[str],
277
+ rubric_attributes: List[str],
278
+ top_down_attributes: List[str],
279
  categories: List[Dict[str, str]],
280
  ) -> Tuple[str, str]:
281
  if not isinstance(template_id, str) or not template_id.strip():
 
285
  cleaned_categories = _clean_category_list(categories)
286
  if not cleaned_categories:
287
  raise ValueError("categories are required")
288
+ bui = _clean_str(bottom_up_instructions)
289
+ bua = _clean_str_list(bottom_up_attributes)
290
+ ra = _clean_str_list(rubric_attributes)
291
+ tda = _clean_str_list(top_down_attributes)
292
 
293
  now = _now_iso()
294
  version_id = str(uuid4())
295
+ content_json = json.dumps(
296
+ {
297
+ "bottom_up_instructions": bui,
298
+ "bottom_up_attributes": bua,
299
+ "rubric_attributes": ra,
300
+ "top_down_attributes": tda,
301
+ "categories": cleaned_categories,
302
+ },
303
+ ensure_ascii=False,
304
+ )
305
 
306
  async with aiosqlite.connect(self.db_path) as db:
307
  await db.execute("PRAGMA foreign_keys=ON;")
 
343
  self,
344
  *,
345
  name: str,
346
+ bottom_up_instructions: str,
347
+ bottom_up_attributes: List[str],
348
+ rubric_attributes: List[str],
349
+ top_down_attributes: List[str],
350
  categories: List[Dict[str, str]],
351
  is_default: bool = False,
352
  ) -> Tuple[str, str]:
 
355
  cleaned_categories = _clean_category_list(categories)
356
  if not cleaned_categories:
357
  raise ValueError("categories are required")
358
+ bui = _clean_str(bottom_up_instructions)
359
+ bua = _clean_str_list(bottom_up_attributes)
360
+ ra = _clean_str_list(rubric_attributes)
361
+ tda = _clean_str_list(top_down_attributes)
362
 
363
  now = _now_iso()
364
  template_id = str(uuid4())
365
  version_id = str(uuid4())
366
+ content_json = json.dumps(
367
+ {
368
+ "bottom_up_instructions": bui,
369
+ "bottom_up_attributes": bua,
370
+ "rubric_attributes": ra,
371
+ "top_down_attributes": tda,
372
+ "categories": cleaned_categories,
373
+ },
374
+ ensure_ascii=False,
375
+ )
376
 
377
  async with aiosqlite.connect(self.db_path) as db:
378
  await db.execute("PRAGMA foreign_keys=ON;")
 
402
  self,
403
  *,
404
  template_id: str,
405
+ bottom_up_instructions: str,
406
+ bottom_up_attributes: List[str],
407
+ rubric_attributes: List[str],
408
+ top_down_attributes: List[str],
409
  categories: List[Dict[str, str]],
410
+ allow_default: bool = False,
411
  ) -> str:
412
  if not isinstance(template_id, str) or not template_id.strip():
413
  raise ValueError("template_id is required")
414
  cleaned_categories = _clean_category_list(categories)
415
  if not cleaned_categories:
416
  raise ValueError("categories are required")
417
+ bui = _clean_str(bottom_up_instructions)
418
+ bua = _clean_str_list(bottom_up_attributes)
419
+ ra = _clean_str_list(rubric_attributes)
420
+ tda = _clean_str_list(top_down_attributes)
421
 
422
  now = _now_iso()
423
  version_id = str(uuid4())
424
+ content_json = json.dumps(
425
+ {
426
+ "bottom_up_instructions": bui,
427
+ "bottom_up_attributes": bua,
428
+ "rubric_attributes": ra,
429
+ "top_down_attributes": tda,
430
+ "categories": cleaned_categories,
431
+ },
432
+ ensure_ascii=False,
433
+ )
434
 
435
  async with aiosqlite.connect(self.db_path) as db:
436
  await db.execute("PRAGMA foreign_keys=ON;")
 
443
  raise ValueError("template not found")
444
  if bool(row[1]):
445
  raise ValueError("template not found")
446
+ if bool(row[0]) and not allow_default:
447
  raise PermissionError("default template is immutable")
448
 
449
  await db.execute(
docs/capabilities.md CHANGED
@@ -27,7 +27,7 @@ This document describes what ConverTA does today, independent of any past roadma
27
  - **Configuration**
28
  - Review/edit system prompts and agent configuration.
29
  - Create/duplicate/edit/delete **user personas**.
30
- - Create/duplicate/edit/delete **analysis templates** (top-down codebook categories).
31
 
32
  ## Persistence (Server-Canonical)
33
 
@@ -41,8 +41,8 @@ Persisted artifacts:
41
 
42
  - **Sealed runs** (transcript + analysis + evidence catalog + config snapshots)
43
  - **Personas** (defaults + user-created) with versioning
44
- - **Shared settings** (system prompts and analysis attributes)
45
- - **Analysis templates** (top-down codebook templates) with versioning
46
 
47
  Notes:
48
 
@@ -100,15 +100,18 @@ For reliability and smaller-model compatibility, the analysis pipeline runs as t
100
 
101
  In the UI, each column populates as its pass completes, while later passes show as pending/running.
102
 
103
- ### Analysis templates (Top-down codebook)
104
 
105
- - The set and order of top-down codebook categories come from an **analysis template** stored in the DB.
106
- - Templates are managed in **Configuration** (create/duplicate/delete + edit categories).
107
- - The **active template is selected per run** using the dropdown next to the **πŸ“Š Analysis** header in:
 
 
 
108
  - AI-to-AI
109
  - Human-to-AI
110
  - Upload Text
111
- - Runs snapshot the selected template so History replay and exports remain stable.
112
 
113
  ## Exports
114
 
 
27
  - **Configuration**
28
  - Review/edit system prompts and agent configuration.
29
  - Create/duplicate/edit/delete **user personas**.
30
+ - Create/duplicate/edit/delete **analysis frameworks** (bottom-up + rubric + top-down).
31
 
32
  ## Persistence (Server-Canonical)
33
 
 
41
 
42
  - **Sealed runs** (transcript + analysis + evidence catalog + config snapshots)
43
  - **Personas** (defaults + user-created) with versioning
44
+ - **Shared settings** (system prompts + active analysis framework id)
45
+ - **Analysis frameworks** (bottom-up + rubric + top-down) with versioning
46
 
47
  Notes:
48
 
 
100
 
101
  In the UI, each column populates as its pass completes, while later passes show as pending/running.
102
 
103
+ ### Analysis frameworks
104
 
105
+ - An **analysis framework** defines:
106
+ - bottom-up instructions + attributes
107
+ - rubric attributes
108
+ - top-down attributes + codebook categories
109
+ - Frameworks are managed in **Configuration** (create/duplicate/delete + edit the framework).
110
+ - The **active framework is selected per run** using the dropdown next to the **πŸ“Š Analysis** header in:
111
  - AI-to-AI
112
  - Human-to-AI
113
  - Upload Text
114
+ - Runs snapshot the selected framework so History replay and exports remain stable.
115
 
116
  ## Exports
117
 
docs/overview.md CHANGED
@@ -33,7 +33,7 @@ The AI Survey Simulator orchestrates AI-to-AI healthcare survey conversations so
33
  4. Generated messages stream back to the browser over the bridged WebSocket connection.
34
  5. When the conversation completes, the backend runs a post-conversation analysis pass and returns:
35
  - Bottom-up findings (emergent themes) with evidence pointers
36
- - Top-down coding (care experience rubric + codebook categories) with evidence pointers (driven by a template selected per run)
37
 
38
  Note: the UI also supports **Upload Text**, which runs the same analysis pipeline on pasted/uploaded content (including best-effort PDF text extraction).
39
  Note: the UI also supports **Human Chat**, where a human types as the patient and the backend generates the surveyor turns over the same WebSocket bridge.
@@ -54,12 +54,12 @@ These are forwarded across the `/ws/frontend/...` bridge to the backend socket.
54
 
55
  The UI includes a **Configuration** view (same page, no reload) that lets you:
56
 
57
- - Edit app-wide surveyor/patient system prompts and analysis attributes (`GET/PUT /api/settings`)
58
  - Create/edit/delete user personas (`GET/POST/PUT/DELETE /api/personas`)
59
- - Create/edit/delete analysis templates (top-down codebook categories) (`GET/POST/PUT/DELETE /api/analysis-templates`)
60
 
61
  Persona selection for actually running conversations is done in the **AI-to-AI** and **Human-to-AI** panels.
62
- Analysis template selection for actually running analyses is done in the run panels via the dropdown next to **πŸ“Š Analysis**.
63
 
64
  ## Export & Persistence Notes
65
 
 
33
  4. Generated messages stream back to the browser over the bridged WebSocket connection.
34
  5. When the conversation completes, the backend runs a post-conversation analysis pass and returns:
35
  - Bottom-up findings (emergent themes) with evidence pointers
36
+ - Top-down coding (care experience rubric + codebook categories) with evidence pointers (driven by an analysis framework selected per run)
37
 
38
  Note: the UI also supports **Upload Text**, which runs the same analysis pipeline on pasted/uploaded content (including best-effort PDF text extraction).
39
  Note: the UI also supports **Human Chat**, where a human types as the patient and the backend generates the surveyor turns over the same WebSocket bridge.
 
54
 
55
  The UI includes a **Configuration** view (same page, no reload) that lets you:
56
 
57
+ - Edit app-wide surveyor/patient/analysis system prompts and shared settings (`GET/PUT /api/settings`)
58
  - Create/edit/delete user personas (`GET/POST/PUT/DELETE /api/personas`)
59
+ - Create/edit/delete analysis frameworks (bottom-up + rubric + top-down) (`GET/POST/PUT/DELETE /api/analysis-templates`)
60
 
61
  Persona selection for actually running conversations is done in the **AI-to-AI** and **Human-to-AI** panels.
62
+ Analysis framework selection for actually running analyses is done in the run panels via the dropdown next to **πŸ“Š Analysis**.
63
 
64
  ## Export & Persistence Notes
65
 
frontend/pages/config_view.py CHANGED
@@ -11,17 +11,36 @@ def get_config_view_js() -> str:
11
  window.dispatchEvent(new Event('converai:analysis-templates-updated'));
12
  } catch {}
13
  };
 
14
 
15
- const ANALYSIS_DEFAULT_ATTRIBUTES = [
16
  "Use ONLY the provided transcript.",
17
  "Output MUST be valid JSON only (no markdown, no backticks).",
18
  "Evidence must be selected from the provided evidence catalog by evidence_id.",
19
  "Do NOT invent quotes. Do NOT paraphrase evidence. Cite by evidence_id only.",
20
- "For care experience: do not duplicate the same evidence_id across positive/negative/mixed/neutral. If a sentence supports both positive and negative interpretations, put it in care_experience.mixed.",
21
  "confidence must be a number between 0 and 1.",
 
22
  "For health_situations: include a short code label (1-3 words) in addition to the longer summary.",
23
- "For top_down_codebook categories: include a short code label (1-3 words) and cite evidence.",
 
 
 
 
 
 
 
24
  "Prefer fewer, higher-confidence items.",
 
 
 
 
 
 
 
 
 
 
 
25
  ];
26
 
27
  const DEFAULT_SURVEYOR_SYSTEM_PROMPT = `You are a survey interviewer conducting an empathetic healthcare experience survey.
@@ -85,9 +104,10 @@ Requirements:
85
  });
86
  const [surveyorPersonaDetail, setSurveyorPersonaDetail] = React.useState(null);
87
  const [patientPersonaDetail, setPatientPersonaDetail] = React.useState(null);
88
- const [analysisAttributeItems, setAnalysisAttributeItems] = React.useState(() => {
89
- return [];
90
- });
 
91
  const [analysisSystemPrompt, setAnalysisSystemPrompt] = React.useState(() => {
92
  return '';
93
  });
@@ -223,29 +243,25 @@ Requirements:
223
  })
224
  .catch(() => null);
225
  };
 
 
 
226
 
227
- const addAnalysisAttribute = () => {
228
- setAnalysisAttributeItems((prev) => ([...(prev || []), '' ]));
229
- };
230
 
231
- const updateAnalysisAttribute = (idx, text) => {
232
- setAnalysisAttributeItems((prev) => (prev || []).map((v, i) => (i === idx ? text : v)));
 
233
  };
234
 
235
- const removeAnalysisAttribute = (idx) => {
236
- setAnalysisAttributeItems((prev) => (prev || []).filter((_, i) => i !== idx));
237
  };
238
 
239
- const applyAnalysisDefaults = () => {
240
- authedFetch('/api/settings/defaults')
241
- .then(r => r.json())
242
- .then(data => {
243
- const attrs = Array.isArray(data?.analysis_attributes) ? data.analysis_attributes : [];
244
- const cleaned = attrs.map((x) => (typeof x === 'string' ? x.trim() : '')).filter((x) => x.length > 0);
245
- if (cleaned.length > 0) setAnalysisAttributeItems(cleaned);
246
- else setAnalysisAttributeItems([...(ANALYSIS_DEFAULT_ATTRIBUTES || [])]);
247
- })
248
- .catch(() => setAnalysisAttributeItems([...(ANALYSIS_DEFAULT_ATTRIBUTES || [])]));
249
  };
250
 
251
  const addSurveyorAttribute = () => setSurveyorAttributeItems((prev) => ([...(prev || []), '' ]));
@@ -284,8 +300,6 @@ Requirements:
284
  if (typeof data?.surveyor_system_prompt === 'string') setSurveyorSystemPrompt(data.surveyor_system_prompt);
285
  if (typeof data?.patient_system_prompt === 'string') setPatientSystemPrompt(data.patient_system_prompt);
286
  if (typeof data?.analysis_system_prompt === 'string') setAnalysisSystemPrompt(data.analysis_system_prompt);
287
- const attrs = Array.isArray(data?.analysis_attributes) ? data.analysis_attributes : [];
288
- setAnalysisAttributeItems(attrs.map((x) => (typeof x === 'string' ? x.trim() : '')).filter((x) => x.length > 0));
289
  })
290
  .catch(() => {});
291
  }, []);
@@ -301,6 +315,23 @@ Requirements:
301
  .then(r => r.json())
302
  .then(t => {
303
  setSelectedTemplateDetail(t || null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  const cats = Array.isArray(t?.categories) ? t.categories : [];
305
  setSelectedTemplateCategories(cats.map((c) => ({
306
  category_id: (c && typeof c.category_id === 'string') ? c.category_id : '',
@@ -309,6 +340,10 @@ Requirements:
309
  })
310
  .catch(() => {
311
  setSelectedTemplateDetail(null);
 
 
 
 
312
  setSelectedTemplateCategories([]);
313
  });
314
  }, [selectedTemplateId]);
@@ -347,7 +382,7 @@ Requirements:
347
  });
348
  }, [selectedPatientId]);
349
 
350
- const onSave = () => {
351
  const prev = loadConfig() || {};
352
  const cfg = Object.assign({}, prev, {
353
  editor: {
@@ -359,12 +394,11 @@ Requirements:
359
  });
360
  saveConfig(cfg);
361
 
362
- const body = {
363
- surveyor_system_prompt: (surveyorSystemPrompt || '').trim(),
364
- patient_system_prompt: (patientSystemPrompt || '').trim(),
365
- analysis_system_prompt: (analysisSystemPrompt || '').trim(),
366
- analysis_attributes: (analysisAttributeItems || []).map((x) => (typeof x === 'string' ? x.trim() : '')).filter((x) => x.length > 0),
367
- };
368
 
369
  Promise.all([
370
  authedFetch('/api/settings', {
@@ -392,18 +426,22 @@ Requirements:
392
  })
393
  }).catch(() => null)
394
  : Promise.resolve(null),
395
- (activePane === 'analysis' && selectedTemplateDetail && selectedTemplateDetail.is_default === false)
396
- ? authedFetch(`/api/analysis-templates/${selectedTemplateId}`, {
397
- method: 'PUT',
398
- headers: { 'Content-Type': 'application/json' },
399
- body: JSON.stringify({
400
- categories: (selectedTemplateCategories || []).map((c) => ({
401
- category_id: (c && typeof c.category_id === 'string') ? c.category_id : '',
402
- label: (c && typeof c.label === 'string') ? c.label.trim() : '',
403
- })).filter((c) => c.label && c.label.length > 0),
404
- })
405
- }).catch(() => null)
406
- : Promise.resolve(null),
 
 
 
 
407
  ]).then(() => {
408
  setSavedAt(cfg.saved_at);
409
  setSaveFlash(true);
@@ -871,197 +909,183 @@ Requirements:
871
  </div>
872
  </div>
873
 
874
- <div className="grid grid-cols-2 gap-6">
875
- <div>
876
- <div className="flex items-center justify-between gap-3 mb-2">
877
- <label className="block text-sm font-semibold text-slate-700">Edit top-down codebook template</label>
878
- </div>
879
- <div className="flex items-center gap-2 flex-wrap">
880
- <select
881
- className="w-full md:w-1/2 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
882
- value={selectedTemplateId || ''}
883
- onChange={(e) => setSelectedTemplateId(e.target.value)}
884
- >
885
- {(analysisTemplates.length ? analysisTemplates : [{template_id: 'top_down_v1', name: 'Default top-down codebook (v1)', is_default: true}]).map(t => (
886
- <option key={t.template_id} value={t.template_id}>{t.name}</option>
887
- ))}
888
- </select>
889
- <button
890
- type="button"
891
- onClick={async () => {
892
- const id = await createAnalysisTemplate(null);
893
- if (id) setSelectedTemplateId(id);
894
- }}
895
- className="bg-slate-100 hover:bg-slate-200 text-slate-800 px-3 py-2 rounded-lg text-sm font-semibold transition-all border border-slate-300"
896
- >
897
- + New
898
- </button>
899
- <button
900
- type="button"
901
- onClick={async () => {
902
- const id = await createAnalysisTemplate(selectedTemplateId);
903
- if (id) setSelectedTemplateId(id);
904
- }}
905
- className="bg-slate-100 hover:bg-slate-200 text-slate-800 px-3 py-2 rounded-lg text-sm font-semibold transition-all border border-slate-300"
906
- >
907
- Duplicate
908
- </button>
909
- <button
910
- type="button"
911
- disabled={!(selectedTemplateDetail && selectedTemplateDetail.is_default === false)}
912
- onClick={async () => {
913
- const ok = await deleteAnalysisTemplate(selectedTemplateId);
914
- if (ok) {
915
- const list = await refreshAnalysisTemplates();
916
- const fallback = (list && list[0] && list[0].template_id) ? list[0].template_id : 'top_down_v1';
917
- setSelectedTemplateId(fallback);
918
- }
919
- }}
920
- className="bg-white hover:bg-red-50 text-red-700 px-3 py-2 rounded-lg text-sm font-semibold transition-all border border-red-200 disabled:opacity-50 disabled:hover:bg-white"
921
- >
922
- Delete
923
- </button>
924
- </div>
925
- {selectedTemplateDetail?.is_default !== false && (
926
- <div className="mt-2 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 text-sm text-slate-800 w-fit">
927
- Note: This is a default template that can't be edited. Create a new template to edit.
928
- </div>
929
- )}
930
  </div>
931
- <div />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
932
  </div>
933
 
934
- <div className="grid grid-cols-2 gap-6">
935
  <div>
936
- <div className="flex items-center justify-between gap-3 mb-2">
937
- <label className="block text-sm font-semibold text-slate-700">
938
- Codebook categories {selectedTemplateDetail?.is_default === false ? '' : '(read-only)'}
939
- </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
940
  </div>
941
  <div className="space-y-2">
942
- {(selectedTemplateCategories || []).length === 0 ? (
943
- <div className="text-sm text-slate-500">No categories.</div>
944
  ) : (
945
- (selectedTemplateCategories || []).map((cat, idx) => (
946
  <div key={idx} className="flex items-start gap-2">
947
  <div className="mt-2 text-xs font-mono text-slate-500 w-10 shrink-0">{String(idx + 1).padStart(2, '0')}</div>
948
- <input
949
- className={`flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm ${selectedTemplateDetail?.is_default === false ? 'bg-white' : 'bg-slate-50 text-slate-600'}`}
950
- value={(cat && typeof cat.label === 'string') ? cat.label : ''}
951
- onChange={(e) => updateTemplateCategoryLabel(idx, e.target.value)}
952
- disabled={!(selectedTemplateDetail?.is_default === false)}
953
- placeholder="Category label..."
954
- />
955
- {selectedTemplateDetail?.is_default === false && (
956
- <>
957
- <button
958
- type="button"
959
- onClick={() => moveTemplateCategory(idx, -1)}
960
- className="text-slate-600 hover:text-slate-900 px-2 py-2 text-sm"
961
- title="Move up"
962
- >
963
- ↑
964
- </button>
965
- <button
966
- type="button"
967
- onClick={() => moveTemplateCategory(idx, 1)}
968
- className="text-slate-600 hover:text-slate-900 px-2 py-2 text-sm"
969
- title="Move down"
970
- >
971
- ↓
972
- </button>
973
- <button
974
- type="button"
975
- onClick={() => removeTemplateCategory(idx)}
976
- className="text-slate-600 hover:text-red-600 px-2 py-2 text-sm"
977
- title="Remove category"
978
- >
979
- βœ•
980
- </button>
981
- </>
982
- )}
983
- </div>
984
- ))
985
- )}
986
- {selectedTemplateDetail?.is_default === false && (
987
- <button
988
- type="button"
989
- onClick={addTemplateCategory}
990
- className="bg-slate-100 hover:bg-slate-200 text-slate-800 px-3 py-2 rounded-lg text-sm font-semibold transition-all border border-slate-300"
991
- >
992
- + Add category
993
- </button>
994
- )}
995
  </div>
996
  </div>
997
- <div />
998
- </div>
999
 
1000
- <div className="grid grid-cols-2 gap-6">
1001
  <div>
1002
- <div className="flex items-center justify-between gap-3 mb-2">
1003
- <label className="block text-sm font-semibold text-slate-700">Attributes</label>
1004
- </div>
1005
- <div className="text-xs text-slate-500 mb-2">
1006
- These rules are appended to the analysis agent system prompt for future analyses.
1007
- </div>
1008
- <div className="space-y-2">
1009
- {(analysisAttributeItems || []).length === 0 ? (
1010
- <div className="text-sm text-slate-500">No attributes yet.</div>
1011
- ) : (
1012
- (analysisAttributeItems || []).map((attr, idx) => (
1013
- <div key={idx} className="flex items-start gap-2">
1014
- <div className="mt-2 text-xs font-mono text-slate-500 w-10 shrink-0">{String(idx + 1).padStart(2, '0')}</div>
1015
- <input
1016
- className="flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
1017
- value={attr || ''}
1018
- onChange={(e) => updateAnalysisAttribute(idx, e.target.value)}
1019
- placeholder="Type an attribute..."
1020
- />
1021
- <button
1022
- type="button"
1023
- onClick={() => removeAnalysisAttribute(idx)}
1024
- className="text-slate-600 hover:text-red-600 px-2 py-2 text-sm"
1025
- title="Remove attribute"
1026
- >
1027
- βœ•
1028
- </button>
1029
- </div>
1030
- ))
1031
- )}
1032
- <button
1033
- type="button"
1034
- onClick={addAnalysisAttribute}
1035
- className="bg-slate-100 hover:bg-slate-200 text-slate-800 px-3 py-2 rounded-lg text-sm font-semibold transition-all border border-slate-300"
1036
- >
1037
- + Add attribute
1038
- </button>
1039
- <button
1040
- type="button"
1041
- onClick={applyAnalysisDefaults}
1042
- className="bg-slate-100 hover:bg-slate-200 text-slate-800 px-3 py-2 rounded-lg text-sm font-semibold transition-all border border-slate-300 ml-2"
1043
- >
1044
- Apply defaults
1045
- </button>
1046
  </div>
1047
- </div>
1048
- <div />
1049
- </div>
1050
- </div>
1051
- ) : null}
1052
 
1053
- <div className="mt-6 flex items-center justify-between gap-4">
1054
- <div className="text-xs text-slate-500">
1055
- {savedAt ? `Saved: ${new Date(savedAt).toLocaleString()}` : 'Not saved yet.'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1056
  </div>
1057
- <button
1058
- onClick={onSave}
 
 
 
 
 
 
1059
  disabled={saveFlash}
1060
- className={`${saveFlash ? 'bg-emerald-600 text-white' : 'bg-blue-600 hover:bg-blue-700 text-white'} disabled:opacity-90 px-5 py-2.5 rounded-lg font-semibold transition-all shadow`}
1061
- >
1062
- {saveFlash ? 'Saved successfully' : 'Save Configuration'}
1063
- </button>
1064
- </div>
1065
  </div>
1066
  );
1067
  }
 
11
  window.dispatchEvent(new Event('converai:analysis-templates-updated'));
12
  } catch {}
13
  };
14
+ const DEFAULT_BOTTOM_UP_INSTRUCTIONS = `You are performing bottom-up thematic analysis. Identify the most prominent themes in the conversation, with no a priori concepts; the themes are emergent from the text.`;
15
 
16
+ const BOTTOM_UP_DEFAULT_ATTRIBUTES = [
17
  "Use ONLY the provided transcript.",
18
  "Output MUST be valid JSON only (no markdown, no backticks).",
19
  "Evidence must be selected from the provided evidence catalog by evidence_id.",
20
  "Do NOT invent quotes. Do NOT paraphrase evidence. Cite by evidence_id only.",
 
21
  "confidence must be a number between 0 and 1.",
22
+ "Prefer fewer, higher-confidence items.",
23
  "For health_situations: include a short code label (1-3 words) in addition to the longer summary.",
24
+ ];
25
+
26
+ const RUBRIC_DEFAULT_ATTRIBUTES = [
27
+ "Use ONLY the provided transcript.",
28
+ "Output MUST be valid JSON only (no markdown, no backticks).",
29
+ "Evidence must be selected from the provided evidence catalog by evidence_id.",
30
+ "Do NOT invent quotes. Do NOT paraphrase evidence. Cite by evidence_id only.",
31
+ "confidence must be a number between 0 and 1.",
32
  "Prefer fewer, higher-confidence items.",
33
+ "For care experience: do not duplicate the same evidence_id across positive/negative/mixed/neutral. If a sentence supports both positive and negative interpretations, put it in care_experience.mixed.",
34
+ ];
35
+
36
+ const TOP_DOWN_DEFAULT_ATTRIBUTES = [
37
+ "Use ONLY the provided transcript.",
38
+ "Output MUST be valid JSON only (no markdown, no backticks).",
39
+ "Evidence must be selected from the provided evidence catalog by evidence_id.",
40
+ "Do NOT invent quotes. Do NOT paraphrase evidence. Cite by evidence_id only.",
41
+ "confidence must be a number between 0 and 1.",
42
+ "Prefer fewer, higher-confidence items.",
43
+ "For top_down_codebook categories: include a short code label (1-3 words) and cite evidence.",
44
  ];
45
 
46
  const DEFAULT_SURVEYOR_SYSTEM_PROMPT = `You are a survey interviewer conducting an empathetic healthcare experience survey.
 
104
  });
105
  const [surveyorPersonaDetail, setSurveyorPersonaDetail] = React.useState(null);
106
  const [patientPersonaDetail, setPatientPersonaDetail] = React.useState(null);
107
+ const [bottomUpInstructions, setBottomUpInstructions] = React.useState(() => '');
108
+ const [bottomUpAttributeItems, setBottomUpAttributeItems] = React.useState(() => []);
109
+ const [rubricAttributeItems, setRubricAttributeItems] = React.useState(() => []);
110
+ const [topDownAttributeItems, setTopDownAttributeItems] = React.useState(() => []);
111
  const [analysisSystemPrompt, setAnalysisSystemPrompt] = React.useState(() => {
112
  return '';
113
  });
 
243
  })
244
  .catch(() => null);
245
  };
246
+ const addBottomUpAttribute = () => setBottomUpAttributeItems((prev) => ([...(prev || []), '' ]));
247
+ const updateBottomUpAttribute = (idx, text) => setBottomUpAttributeItems((prev) => (prev || []).map((v, i) => (i === idx ? text : v)));
248
+ const removeBottomUpAttribute = (idx) => setBottomUpAttributeItems((prev) => (prev || []).filter((_, i) => i !== idx));
249
 
250
+ const addTopDownAttribute = () => setTopDownAttributeItems((prev) => ([...(prev || []), '' ]));
251
+ const updateTopDownAttribute = (idx, text) => setTopDownAttributeItems((prev) => (prev || []).map((v, i) => (i === idx ? text : v)));
252
+ const removeTopDownAttribute = (idx) => setTopDownAttributeItems((prev) => (prev || []).filter((_, i) => i !== idx));
253
 
254
+ const applyBottomUpDefaults = () => {
255
+ setBottomUpInstructions(DEFAULT_BOTTOM_UP_INSTRUCTIONS);
256
+ setBottomUpAttributeItems([...(BOTTOM_UP_DEFAULT_ATTRIBUTES || [])]);
257
  };
258
 
259
+ const applyRubricDefaults = () => {
260
+ setRubricAttributeItems([...(RUBRIC_DEFAULT_ATTRIBUTES || [])]);
261
  };
262
 
263
+ const applyTopDownDefaults = () => {
264
+ setTopDownAttributeItems([...(TOP_DOWN_DEFAULT_ATTRIBUTES || [])]);
 
 
 
 
 
 
 
 
265
  };
266
 
267
  const addSurveyorAttribute = () => setSurveyorAttributeItems((prev) => ([...(prev || []), '' ]));
 
300
  if (typeof data?.surveyor_system_prompt === 'string') setSurveyorSystemPrompt(data.surveyor_system_prompt);
301
  if (typeof data?.patient_system_prompt === 'string') setPatientSystemPrompt(data.patient_system_prompt);
302
  if (typeof data?.analysis_system_prompt === 'string') setAnalysisSystemPrompt(data.analysis_system_prompt);
 
 
303
  })
304
  .catch(() => {});
305
  }, []);
 
315
  .then(r => r.json())
316
  .then(t => {
317
  setSelectedTemplateDetail(t || null);
318
+ if (typeof t?.bottom_up_instructions === 'string' && t.bottom_up_instructions.trim()) {
319
+ setBottomUpInstructions(t.bottom_up_instructions);
320
+ } else {
321
+ setBottomUpInstructions(DEFAULT_BOTTOM_UP_INSTRUCTIONS);
322
+ }
323
+ const bua = Array.isArray(t?.bottom_up_attributes) ? t.bottom_up_attributes : [];
324
+ const buaClean = bua.map((x) => (typeof x === 'string' ? x.trim() : '')).filter((x) => x.length > 0);
325
+ setBottomUpAttributeItems(buaClean.length ? buaClean : [ ...(BOTTOM_UP_DEFAULT_ATTRIBUTES || []) ]);
326
+
327
+ const ra = Array.isArray(t?.rubric_attributes) ? t.rubric_attributes : [];
328
+ const raClean = ra.map((x) => (typeof x === 'string' ? x.trim() : '')).filter((x) => x.length > 0);
329
+ setRubricAttributeItems(raClean.length ? raClean : [ ...(RUBRIC_DEFAULT_ATTRIBUTES || []) ]);
330
+
331
+ const tda = Array.isArray(t?.top_down_attributes) ? t.top_down_attributes : [];
332
+ const tdaClean = tda.map((x) => (typeof x === 'string' ? x.trim() : '')).filter((x) => x.length > 0);
333
+ setTopDownAttributeItems(tdaClean.length ? tdaClean : [ ...(TOP_DOWN_DEFAULT_ATTRIBUTES || []) ]);
334
+
335
  const cats = Array.isArray(t?.categories) ? t.categories : [];
336
  setSelectedTemplateCategories(cats.map((c) => ({
337
  category_id: (c && typeof c.category_id === 'string') ? c.category_id : '',
 
340
  })
341
  .catch(() => {
342
  setSelectedTemplateDetail(null);
343
+ setBottomUpInstructions(DEFAULT_BOTTOM_UP_INSTRUCTIONS);
344
+ setBottomUpAttributeItems([ ...(BOTTOM_UP_DEFAULT_ATTRIBUTES || []) ]);
345
+ setRubricAttributeItems([ ...(RUBRIC_DEFAULT_ATTRIBUTES || []) ]);
346
+ setTopDownAttributeItems([ ...(TOP_DOWN_DEFAULT_ATTRIBUTES || []) ]);
347
  setSelectedTemplateCategories([]);
348
  });
349
  }, [selectedTemplateId]);
 
382
  });
383
  }, [selectedPatientId]);
384
 
385
+ const onSave = () => {
386
  const prev = loadConfig() || {};
387
  const cfg = Object.assign({}, prev, {
388
  editor: {
 
394
  });
395
  saveConfig(cfg);
396
 
397
+ const body = {
398
+ surveyor_system_prompt: (surveyorSystemPrompt || '').trim(),
399
+ patient_system_prompt: (patientSystemPrompt || '').trim(),
400
+ analysis_system_prompt: (analysisSystemPrompt || '').trim(),
401
+ };
 
402
 
403
  Promise.all([
404
  authedFetch('/api/settings', {
 
426
  })
427
  }).catch(() => null)
428
  : Promise.resolve(null),
429
+ (activePane === 'analysis' && selectedTemplateDetail && selectedTemplateDetail.is_default === false)
430
+ ? authedFetch(`/api/analysis-templates/${selectedTemplateId}`, {
431
+ method: 'PUT',
432
+ headers: { 'Content-Type': 'application/json' },
433
+ body: JSON.stringify({
434
+ bottom_up_instructions: (bottomUpInstructions || '').trim(),
435
+ bottom_up_attributes: (bottomUpAttributeItems || []).map((x) => (typeof x === 'string' ? x.trim() : '')).filter((x) => x.length > 0),
436
+ rubric_attributes: (rubricAttributeItems || []).map((x) => (typeof x === 'string' ? x.trim() : '')).filter((x) => x.length > 0),
437
+ top_down_attributes: (topDownAttributeItems || []).map((x) => (typeof x === 'string' ? x.trim() : '')).filter((x) => x.length > 0),
438
+ categories: (selectedTemplateCategories || []).map((c) => ({
439
+ category_id: (c && typeof c.category_id === 'string') ? c.category_id : '',
440
+ label: (c && typeof c.label === 'string') ? c.label.trim() : '',
441
+ })).filter((c) => c.label && c.label.length > 0),
442
+ })
443
+ }).catch(() => null)
444
+ : Promise.resolve(null),
445
  ]).then(() => {
446
  setSavedAt(cfg.saved_at);
447
  setSaveFlash(true);
 
909
  </div>
910
  </div>
911
 
912
+ <div>
913
+ <div className="flex items-center justify-between gap-3 mb-2">
914
+ <label className="block text-sm font-semibold text-slate-700">Edit analysis framework</label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
915
  </div>
916
+ <div className="flex items-center gap-2 flex-wrap">
917
+ <select
918
+ className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
919
+ value={selectedTemplateId || ''}
920
+ onChange={(e) => setSelectedTemplateId(e.target.value)}
921
+ >
922
+ {(analysisTemplates.length ? analysisTemplates : [{template_id: 'top_down_v1', name: 'Default analysis framework (v1)', is_default: true}]).map(t => (
923
+ <option key={t.template_id} value={t.template_id}>{t.name}</option>
924
+ ))}
925
+ </select>
926
+ <button type="button" onClick={async () => { const id = await createAnalysisTemplate(null); if (id) setSelectedTemplateId(id); }} className="bg-slate-100 hover:bg-slate-200 text-slate-800 px-3 py-2 rounded-lg text-sm font-semibold transition-all border border-slate-300">+ New</button>
927
+ <button type="button" onClick={async () => { const id = await createAnalysisTemplate(selectedTemplateId); if (id) setSelectedTemplateId(id); }} className="bg-slate-100 hover:bg-slate-200 text-slate-800 px-3 py-2 rounded-lg text-sm font-semibold transition-all border border-slate-300">Duplicate</button>
928
+ <button type="button" disabled={!(selectedTemplateDetail && selectedTemplateDetail.is_default === false)} onClick={async () => { const ok = await deleteAnalysisTemplate(selectedTemplateId); if (ok) { const list = await refreshAnalysisTemplates(); const fallback = (list && list[0] && list[0].template_id) ? list[0].template_id : 'top_down_v1'; setSelectedTemplateId(fallback); } }} className="bg-white hover:bg-red-50 text-red-700 px-3 py-2 rounded-lg text-sm font-semibold transition-all border border-red-200 disabled:opacity-50 disabled:hover:bg-white">Delete</button>
929
+ </div>
930
+ {selectedTemplateDetail?.is_default !== false && (
931
+ <div className="mt-2 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 text-sm text-slate-800 w-fit">
932
+ Note: This is a default framework that can't be edited. Create a new framework to edit.
933
+ </div>
934
+ )}
935
  </div>
936
 
937
+ <div className="grid grid-cols-3 gap-6">
938
  <div>
939
+ <div className="text-sm font-semibold text-slate-700 mb-2">Bottom-Up analysis</div>
940
+
941
+ <div className="flex items-center justify-between gap-3 mb-2">
942
+ <label className="block text-sm font-semibold text-slate-700">Bottom-Up instructions</label>
943
+ <button
944
+ type="button"
945
+ onClick={applyBottomUpDefaults}
946
+ disabled={!(selectedTemplateDetail?.is_default === false)}
947
+ className="bg-slate-100 hover:bg-slate-200 disabled:hover:bg-slate-100 disabled:opacity-50 text-slate-800 px-3 py-2 rounded-lg text-sm font-semibold transition-all border border-slate-300"
948
+ >
949
+ Apply defaults
950
+ </button>
951
+ </div>
952
+ <textarea
953
+ disabled={!(selectedTemplateDetail?.is_default === false)}
954
+ className={`w-full border border-slate-300 rounded-lg px-3 py-2 text-sm h-32 ${selectedTemplateDetail?.is_default === false ? 'bg-white' : 'bg-slate-50 text-slate-700'}`}
955
+ value={bottomUpInstructions || ''}
956
+ onChange={(e) => setBottomUpInstructions(e.target.value)}
957
+ placeholder="Bottom-up instructions..."
958
+ />
959
+
960
+ <div className="flex items-center justify-between gap-3 mt-4 mb-2">
961
+ <label className="block text-sm font-semibold text-slate-700">Bottom-Up attributes</label>
962
  </div>
963
  <div className="space-y-2">
964
+ {(bottomUpAttributeItems || []).length === 0 ? (
965
+ <div className="text-sm text-slate-500">No attributes yet.</div>
966
  ) : (
967
+ (bottomUpAttributeItems || []).map((attr, idx) => (
968
  <div key={idx} className="flex items-start gap-2">
969
  <div className="mt-2 text-xs font-mono text-slate-500 w-10 shrink-0">{String(idx + 1).padStart(2, '0')}</div>
970
+ <input
971
+ disabled={!(selectedTemplateDetail?.is_default === false)}
972
+ className={`flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm ${selectedTemplateDetail?.is_default === false ? 'bg-white' : 'bg-slate-50 text-slate-600'}`}
973
+ value={attr || ''}
974
+ onChange={(e) => updateBottomUpAttribute(idx, e.target.value)}
975
+ placeholder="Type an attribute..."
976
+ />
977
+ {selectedTemplateDetail?.is_default === false && (
978
+ <button
979
+ type="button"
980
+ onClick={() => removeBottomUpAttribute(idx)}
981
+ className="text-slate-600 hover:text-red-600 px-2 py-2 text-sm"
982
+ title="Remove attribute"
983
+ >
984
+ βœ•
985
+ </button>
986
+ )}
987
+ </div>
988
+ ))
989
+ )}
990
+ {selectedTemplateDetail?.is_default === false && (
991
+ <button
992
+ type="button"
993
+ onClick={addBottomUpAttribute}
994
+ className="bg-slate-100 hover:bg-slate-200 text-slate-800 px-3 py-2 rounded-lg text-sm font-semibold transition-all border border-slate-300"
995
+ >
996
+ + Add attribute
997
+ </button>
998
+ )}
999
+ </div>
1000
+ </div>
1001
+
1002
+ <div>
1003
+ <div className="text-sm font-semibold text-slate-700 mb-2">Care experience rubric</div>
1004
+ <div className="bg-slate-50 border border-slate-200 rounded-lg p-4 text-sm text-slate-600">
1005
+ Placeholder: rubric-specific settings will go here.
 
 
 
 
 
 
 
 
 
 
 
1006
  </div>
1007
  </div>
 
 
1008
 
 
1009
  <div>
1010
+ <div className="text-sm font-semibold text-slate-700 mb-2">Top-Down codebook</div>
1011
+
1012
+ <div className="mt-4">
1013
+ <div className="flex items-center justify-between gap-3 mb-2">
1014
+ <label className="block text-sm font-semibold text-slate-700">Codebook categories {selectedTemplateDetail?.is_default === false ? '' : '(read-only)'}</label>
1015
+ </div>
1016
+ <div className="space-y-2">
1017
+ {(selectedTemplateCategories || []).length === 0 ? (
1018
+ <div className="text-sm text-slate-500">No categories.</div>
1019
+ ) : (
1020
+ (selectedTemplateCategories || []).map((cat, idx) => (
1021
+ <div key={idx} className="flex items-start gap-2">
1022
+ <div className="mt-2 text-xs font-mono text-slate-500 w-10 shrink-0">{String(idx + 1).padStart(2, '0')}</div>
1023
+ <input
1024
+ className={`flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm ${selectedTemplateDetail?.is_default === false ? 'bg-white' : 'bg-slate-50 text-slate-600'}`}
1025
+ value={(cat && typeof cat.label === 'string') ? cat.label : ''}
1026
+ onChange={(e) => updateTemplateCategoryLabel(idx, e.target.value)}
1027
+ disabled={!(selectedTemplateDetail?.is_default === false)}
1028
+ placeholder="Category label..."
1029
+ />
1030
+ {selectedTemplateDetail?.is_default === false && (
1031
+ <>
1032
+ <button type="button" onClick={() => moveTemplateCategory(idx, -1)} className="text-slate-600 hover:text-slate-900 px-2 py-2 text-sm" title="Move up">↑</button>
1033
+ <button type="button" onClick={() => moveTemplateCategory(idx, 1)} className="text-slate-600 hover:text-slate-900 px-2 py-2 text-sm" title="Move down">↓</button>
1034
+ <button type="button" onClick={() => removeTemplateCategory(idx)} className="text-slate-600 hover:text-red-600 px-2 py-2 text-sm" title="Remove category">βœ•</button>
1035
+ </>
1036
+ )}
1037
+ </div>
1038
+ ))
1039
+ )}
1040
+ {selectedTemplateDetail?.is_default === false && (
1041
+ <button type="button" onClick={addTemplateCategory} className="bg-slate-100 hover:bg-slate-200 text-slate-800 px-3 py-2 rounded-lg text-sm font-semibold transition-all border border-slate-300">+ Add category</button>
1042
+ )}
1043
+ </div>
 
 
 
 
 
 
 
 
 
 
1044
  </div>
 
 
 
 
 
1045
 
1046
+ <div className="mt-4">
1047
+ <div className="flex items-center justify-between gap-3 mb-2">
1048
+ <label className="block text-sm font-semibold text-slate-700">Top-Down attributes</label>
1049
+ <button type="button" disabled={!(selectedTemplateDetail?.is_default === false)} onClick={applyTopDownDefaults} className="bg-slate-100 hover:bg-slate-200 disabled:hover:bg-slate-100 disabled:opacity-50 text-slate-800 px-3 py-2 rounded-lg text-sm font-semibold transition-all border border-slate-300">Apply defaults</button>
1050
+ </div>
1051
+ <div className="space-y-2">
1052
+ {(topDownAttributeItems || []).length === 0 ? (
1053
+ <div className="text-sm text-slate-500">No attributes yet.</div>
1054
+ ) : (
1055
+ (topDownAttributeItems || []).map((attr, idx) => (
1056
+ <div key={idx} className="flex items-start gap-2">
1057
+ <div className="mt-2 text-xs font-mono text-slate-500 w-10 shrink-0">{String(idx + 1).padStart(2, '0')}</div>
1058
+ <input disabled={!(selectedTemplateDetail?.is_default === false)} className={`flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm ${selectedTemplateDetail?.is_default === false ? 'bg-white' : 'bg-slate-50 text-slate-600'}`} value={attr || ''} onChange={(e) => updateTopDownAttribute(idx, e.target.value)} placeholder="Type an attribute..." />
1059
+ {selectedTemplateDetail?.is_default === false && (
1060
+ <button type="button" onClick={() => removeTopDownAttribute(idx)} className="text-slate-600 hover:text-red-600 px-2 py-2 text-sm" title="Remove attribute">βœ•</button>
1061
+ )}
1062
+ </div>
1063
+ ))
1064
+ )}
1065
+ <div className="flex items-center gap-2 flex-wrap">
1066
+ {selectedTemplateDetail?.is_default === false && (
1067
+ <button type="button" onClick={addTopDownAttribute} className="bg-slate-100 hover:bg-slate-200 text-slate-800 px-3 py-2 rounded-lg text-sm font-semibold transition-all border border-slate-300">+ Add attribute</button>
1068
+ )}
1069
+ </div>
1070
+ </div>
1071
+ </div>
1072
+ </div>
1073
+ </div>
1074
  </div>
1075
+ ) : null}
1076
+
1077
+ <div className="mt-6 flex items-center justify-between gap-4">
1078
+ <div className="text-xs text-slate-500">
1079
+ {savedAt ? `Saved: ${new Date(savedAt).toLocaleString()}` : 'Not saved yet.'}
1080
+ </div>
1081
+ <button
1082
+ onClick={onSave}
1083
  disabled={saveFlash}
1084
+ className={`${saveFlash ? 'bg-emerald-600 text-white' : 'bg-blue-600 hover:bg-blue-700 text-white'} disabled:opacity-90 px-5 py-2.5 rounded-lg font-semibold transition-all shadow`}
1085
+ >
1086
+ {saveFlash ? 'Saved successfully' : 'Save Configuration'}
1087
+ </button>
1088
+ </div>
1089
  </div>
1090
  );
1091
  }