MikelWL commited on
Commit
b5897db
·
1 Parent(s): 903beb7

Analysis templates: dynamic top-down codebook

Browse files
backend/api/analysis_routes.py CHANGED
@@ -148,6 +148,9 @@ async def _analyze_from_text(
148
  store = get_persona_store()
149
  effective_analysis_attributes = await store.get_setting("analysis_attributes")
150
  effective_analysis_system_prompt = await store.get_setting("analysis_system_prompt")
 
 
 
151
  resources = await run_resource_agent_analysis(
152
  transcript=transcript,
153
  llm_backend=settings.llm.backend,
@@ -156,6 +159,9 @@ async def _analyze_from_text(
156
  settings=settings,
157
  analysis_attributes=effective_analysis_attributes,
158
  analysis_system_prompt=effective_analysis_system_prompt if isinstance(effective_analysis_system_prompt, str) else None,
 
 
 
159
  )
160
 
161
  persisted = False
@@ -178,6 +184,9 @@ async def _analyze_from_text(
178
  "analysis": {
179
  "analysis_system_prompt": effective_analysis_system_prompt if isinstance(effective_analysis_system_prompt, str) else None,
180
  "analysis_attributes": effective_analysis_attributes,
 
 
 
181
  },
182
  }
183
  record = RunRecord(
 
148
  store = get_persona_store()
149
  effective_analysis_attributes = await store.get_setting("analysis_attributes")
150
  effective_analysis_system_prompt = await store.get_setting("analysis_system_prompt")
151
+ template_id = await store.get_setting("top_down_codebook_template_id")
152
+ template_id_str = template_id.strip() if isinstance(template_id, str) else ""
153
+ template_record = await store.get_analysis_template(template_id_str, include_deleted=False) if template_id_str else None
154
  resources = await run_resource_agent_analysis(
155
  transcript=transcript,
156
  llm_backend=settings.llm.backend,
 
159
  settings=settings,
160
  analysis_attributes=effective_analysis_attributes,
161
  analysis_system_prompt=effective_analysis_system_prompt if isinstance(effective_analysis_system_prompt, str) else None,
162
+ top_down_template_id=template_record.template_id if template_record else template_id_str,
163
+ top_down_template_version_id=template_record.current_version_id if template_record else "",
164
+ top_down_template_categories=template_record.categories if template_record else [],
165
  )
166
 
167
  persisted = False
 
184
  "analysis": {
185
  "analysis_system_prompt": effective_analysis_system_prompt if isinstance(effective_analysis_system_prompt, str) else None,
186
  "analysis_attributes": effective_analysis_attributes,
187
+ "top_down_codebook_template_id": template_record.template_id if template_record else template_id_str,
188
+ "top_down_codebook_template_version_id": template_record.current_version_id if template_record else "",
189
+ "top_down_codebook_template_snapshot": template_record.categories if template_record else [],
190
  },
191
  }
192
  record = RunRecord(
backend/api/analysis_template_routes.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Top-down codebook analysis templates (CRUD)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any, Dict, List, Optional
7
+ from uuid import uuid4
8
+
9
+ from fastapi import APIRouter, HTTPException
10
+ from pydantic import BaseModel, Field
11
+
12
+ from .storage_service import get_persona_store
13
+
14
+ router = APIRouter(prefix="", tags=["analysis-templates"])
15
+
16
+
17
+ def _slugify(value: str) -> str:
18
+ normalized = re.sub(r"[^a-zA-Z0-9]+", "_", (value or "").strip().lower()).strip("_")
19
+ normalized = re.sub(r"_+", "_", normalized)
20
+ return normalized
21
+
22
+
23
+ def _normalize_categories(payload: Any) -> List[Dict[str, str]]:
24
+ if not isinstance(payload, list):
25
+ return []
26
+ out: List[Dict[str, str]] = []
27
+ used: set = set()
28
+ for item in payload:
29
+ if not isinstance(item, dict):
30
+ continue
31
+ label = item.get("label")
32
+ if not isinstance(label, str) or not label.strip():
33
+ continue
34
+ cid = item.get("category_id")
35
+ cid_str = cid.strip() if isinstance(cid, str) else ""
36
+ if not cid_str:
37
+ cid_str = _slugify(label)
38
+ if not cid_str:
39
+ cid_str = f"cat_{uuid4().hex[:8]}"
40
+ # Ensure unique within this template version.
41
+ base = cid_str
42
+ n = 2
43
+ while cid_str in used:
44
+ cid_str = f"{base}_{n}"
45
+ n += 1
46
+ used.add(cid_str)
47
+ out.append({"category_id": cid_str, "label": label.strip()})
48
+ return out
49
+
50
+
51
+ class AnalysisTemplateSummaryResponse(BaseModel):
52
+ template_id: str
53
+ name: str
54
+ is_default: bool = False
55
+
56
+
57
+ class AnalysisTemplateDetailResponse(BaseModel):
58
+ template_id: str
59
+ name: str
60
+ is_default: bool = False
61
+ version_id: str
62
+ categories: List[Dict[str, str]] = Field(default_factory=list)
63
+
64
+
65
+ class CreateAnalysisTemplateRequest(BaseModel):
66
+ name: str = Field(..., description="Display name (set on create; not editable)")
67
+ clone_from_template_id: Optional[str] = Field(default=None)
68
+
69
+
70
+ class UpdateAnalysisTemplateRequest(BaseModel):
71
+ categories: List[Dict[str, str]] = Field(default_factory=list)
72
+
73
+
74
+ @router.get("/analysis-templates", response_model=List[AnalysisTemplateSummaryResponse])
75
+ async def list_templates() -> List[AnalysisTemplateSummaryResponse]:
76
+ store = get_persona_store()
77
+ rows = await store.list_analysis_template_summaries(include_deleted=False)
78
+ return [
79
+ AnalysisTemplateSummaryResponse(
80
+ template_id=row.template_id,
81
+ name=row.name,
82
+ is_default=row.is_default,
83
+ )
84
+ for row in rows
85
+ ]
86
+
87
+
88
+ @router.get("/analysis-templates/{template_id}", response_model=AnalysisTemplateDetailResponse)
89
+ async def get_template(template_id: str) -> AnalysisTemplateDetailResponse:
90
+ store = get_persona_store()
91
+ record = await store.get_analysis_template(template_id, include_deleted=False)
92
+ if not record:
93
+ raise HTTPException(status_code=404, detail="Template not found")
94
+ return AnalysisTemplateDetailResponse(
95
+ template_id=record.template_id,
96
+ name=record.name,
97
+ is_default=record.is_default,
98
+ version_id=record.current_version_id,
99
+ categories=record.categories,
100
+ )
101
+
102
+
103
+ @router.post("/analysis-templates", response_model=AnalysisTemplateDetailResponse)
104
+ async def create_template(payload: CreateAnalysisTemplateRequest) -> AnalysisTemplateDetailResponse:
105
+ name = (payload.name or "").strip()
106
+ if not name:
107
+ raise HTTPException(status_code=400, detail="name is required")
108
+
109
+ store = get_persona_store()
110
+ categories: List[Dict[str, str]] = []
111
+ if payload.clone_from_template_id:
112
+ src = await store.get_analysis_template(payload.clone_from_template_id, include_deleted=False)
113
+ if not src:
114
+ raise HTTPException(status_code=404, detail="clone_from_template_id not found")
115
+ categories = src.categories
116
+ if not categories:
117
+ categories = [{"category_id": "category_1", "label": "New category"}]
118
+
119
+ template_id, version_id = await store.create_analysis_template(
120
+ name=name,
121
+ categories=_normalize_categories(categories),
122
+ is_default=False,
123
+ )
124
+ record = await store.get_analysis_template(template_id, include_deleted=False)
125
+ if not record:
126
+ raise HTTPException(status_code=500, detail="Failed to create template")
127
+ return AnalysisTemplateDetailResponse(
128
+ template_id=record.template_id,
129
+ name=record.name,
130
+ is_default=record.is_default,
131
+ version_id=record.current_version_id,
132
+ categories=record.categories,
133
+ )
134
+
135
+
136
+ @router.put("/analysis-templates/{template_id}", response_model=AnalysisTemplateDetailResponse)
137
+ async def update_template(template_id: str, payload: UpdateAnalysisTemplateRequest) -> AnalysisTemplateDetailResponse:
138
+ store = get_persona_store()
139
+ record = await store.get_analysis_template(template_id, include_deleted=True)
140
+ if not record:
141
+ raise HTTPException(status_code=404, detail="Template not found")
142
+ if record.is_default:
143
+ raise HTTPException(status_code=403, detail="default template is immutable")
144
+
145
+ categories = _normalize_categories(payload.categories)
146
+ if not categories:
147
+ raise HTTPException(status_code=400, detail="categories are required")
148
+
149
+ try:
150
+ await store.update_analysis_template(template_id=template_id, categories=categories)
151
+ except PermissionError as e:
152
+ raise HTTPException(status_code=403, detail=str(e))
153
+ except ValueError as e:
154
+ raise HTTPException(status_code=404, detail=str(e))
155
+
156
+ updated = await store.get_analysis_template(template_id, include_deleted=False)
157
+ if not updated:
158
+ raise HTTPException(status_code=404, detail="Template not found")
159
+ return AnalysisTemplateDetailResponse(
160
+ template_id=updated.template_id,
161
+ name=updated.name,
162
+ is_default=updated.is_default,
163
+ version_id=updated.current_version_id,
164
+ categories=updated.categories,
165
+ )
166
+
167
+
168
+ @router.delete("/analysis-templates/{template_id}")
169
+ async def delete_template(template_id: str) -> Dict[str, Any]:
170
+ store = get_persona_store()
171
+ try:
172
+ await store.soft_delete_analysis_template(template_id)
173
+ except PermissionError as e:
174
+ raise HTTPException(status_code=403, detail=str(e))
175
+ except ValueError:
176
+ raise HTTPException(status_code=404, detail="Template not found")
177
+ return {"ok": True}
178
+
backend/api/conversation_service.py CHANGED
@@ -97,6 +97,9 @@ async def run_resource_agent_analysis(
97
  settings: AppSettings,
98
  analysis_attributes: Optional[List[str]] = None,
99
  analysis_system_prompt: Optional[str] = None,
 
 
 
100
  ) -> Dict[str, Any]:
101
  """Run the resource agent analysis on an in-memory transcript and return parsed JSON.
102
 
@@ -121,8 +124,8 @@ async def run_resource_agent_analysis(
121
  **llm_params,
122
  )
123
 
124
- schema_version = "7"
125
- analysis_prompt_version = "v2"
126
 
127
  evidence_catalog: Dict[str, Dict[str, Any]] = {}
128
  for message in transcript:
@@ -147,6 +150,17 @@ async def run_resource_agent_analysis(
147
  )
148
  system_prompt = (base + "\n\n" + compile_analysis_rules_block(analysis_attributes)).strip()
149
 
 
 
 
 
 
 
 
 
 
 
 
150
  evidence_catalog_json = json.dumps(evidence_catalog, ensure_ascii=False)
151
  user_prompt = (
152
  "Evidence catalog (JSON object mapping evidence_id -> sentence):\n"
@@ -189,15 +203,37 @@ async def run_resource_agent_analysis(
189
  " \"confidence\": number // 0..1\n"
190
  " }\n"
191
  " }\n"
192
- " \"top_down_codes\": {\n"
193
- " \"symptoms_concerns\": [ {\"code\": string, \"summary\": string, \"evidence\": [ {\"evidence_id\": string} ], \"confidence\": number // 0..1 } ],\n"
194
- " \"daily_management\": [ {\"code\": string, \"summary\": string, \"evidence\": [ {\"evidence_id\": string} ], \"confidence\": number // 0..1 } ],\n"
195
- " \"barriers_constraints\": [ {\"code\": string, \"summary\": string, \"evidence\": [ {\"evidence_id\": string} ], \"confidence\": number // 0..1 } ],\n"
196
- " \"support_resources\": [ {\"code\": string, \"summary\": string, \"evidence\": [ {\"evidence_id\": string} ], \"confidence\": number // 0..1 } ]\n"
 
 
 
 
 
197
  " }\n"
198
  "}\n"
199
  )
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  try:
202
  raw = await client.generate(prompt=user_prompt, system_prompt=system_prompt, temperature=0.2)
203
  parsed = json.loads(raw)
@@ -217,17 +253,49 @@ async def run_resource_agent_analysis(
217
  if normalized is not None:
218
  box["confidence"] = normalized
219
 
220
- top_down_codes = parsed.get("top_down_codes") or {}
221
- for key in ("symptoms_concerns", "daily_management", "barriers_constraints", "support_resources"):
222
- items = top_down_codes.get(key) or []
223
- if not isinstance(items, list):
224
- continue
225
- for item in items:
226
- if not isinstance(item, dict):
227
- continue
228
- normalized = _normalize_confidence(item.get("confidence"))
229
- if normalized is not None:
230
- item["confidence"] = normalized
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
 
232
  return parsed
233
  finally:
@@ -265,6 +333,9 @@ class ConversationInfo:
265
  patient_system_prompt: str = ""
266
  analysis_system_prompt: str = ""
267
  analysis_attributes: List[str] = field(default_factory=list)
 
 
 
268
  patient_attributes: List[str] = field(default_factory=list)
269
  surveyor_attributes: List[str] = field(default_factory=list)
270
  surveyor_question_bank: Optional[str] = None
@@ -287,6 +358,9 @@ class HumanChatInfo:
287
  patient_system_prompt: str = ""
288
  analysis_system_prompt: str = ""
289
  analysis_attributes: List[str] = field(default_factory=list)
 
 
 
290
  patient_attributes: List[str] = field(default_factory=list)
291
  surveyor_attributes: List[str] = field(default_factory=list)
292
  surveyor_question_bank: Optional[str] = None
@@ -343,6 +417,19 @@ class ConversationService:
343
  return []
344
  return [s.strip() for s in attrs if isinstance(s, str) and s.strip()]
345
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  async def start_human_chat(
347
  self,
348
  conversation_id: str,
@@ -385,6 +472,7 @@ class ConversationService:
385
  resolved_patient_prompt = pp if isinstance(pp, str) and pp.strip() else DEFAULT_PATIENT_SYSTEM_PROMPT
386
  resolved_analysis_prompt = asp if isinstance(asp, str) and asp.strip() else ""
387
  resolved_analysis_attrs = [s.strip() for s in ap if isinstance(ap, str) and s.strip()] if isinstance(ap, list) else []
 
388
 
389
  chat_info = HumanChatInfo(
390
  conversation_id=conversation_id,
@@ -397,6 +485,9 @@ class ConversationService:
397
  patient_system_prompt=resolved_patient_prompt,
398
  analysis_system_prompt=resolved_analysis_prompt,
399
  analysis_attributes=resolved_analysis_attrs,
 
 
 
400
  patient_attributes=self._persona_attributes(patient_persona),
401
  surveyor_attributes=self._persona_attributes(surveyor_persona),
402
  surveyor_question_bank=self._persona_question_bank(surveyor_persona),
@@ -747,6 +838,7 @@ class ConversationService:
747
  resolved_patient_prompt = pp if isinstance(pp, str) and pp.strip() else DEFAULT_PATIENT_SYSTEM_PROMPT
748
  resolved_analysis_prompt = asp if isinstance(asp, str) and asp.strip() else ""
749
  resolved_analysis_attrs = [s.strip() for s in ap if isinstance(ap, str) and s.strip()] if isinstance(ap, list) else []
 
750
 
751
  # Create conversation info
752
  conv_info = ConversationInfo(
@@ -760,6 +852,9 @@ class ConversationService:
760
  patient_system_prompt=resolved_patient_prompt,
761
  analysis_system_prompt=resolved_analysis_prompt,
762
  analysis_attributes=resolved_analysis_attrs,
 
 
 
763
  patient_attributes=self._persona_attributes(patient_persona),
764
  surveyor_attributes=self._persona_attributes(surveyor_persona),
765
  surveyor_question_bank=self._persona_question_bank(surveyor_persona),
@@ -1027,6 +1122,9 @@ class ConversationService:
1027
  settings=self.settings,
1028
  analysis_attributes=getattr(conv_info, "analysis_attributes", None),
1029
  analysis_system_prompt=getattr(conv_info, "analysis_system_prompt", None),
 
 
 
1030
  )
1031
 
1032
  persisted = False
@@ -1076,6 +1174,9 @@ class ConversationService:
1076
  "analysis": {
1077
  "analysis_system_prompt": getattr(conv_info, "analysis_system_prompt", None),
1078
  "analysis_attributes": getattr(conv_info, "analysis_attributes", None),
 
 
 
1079
  },
1080
  }
1081
 
 
97
  settings: AppSettings,
98
  analysis_attributes: Optional[List[str]] = None,
99
  analysis_system_prompt: Optional[str] = None,
100
+ top_down_template_id: Optional[str] = None,
101
+ top_down_template_version_id: Optional[str] = None,
102
+ top_down_template_categories: Optional[List[Dict[str, str]]] = None,
103
  ) -> Dict[str, Any]:
104
  """Run the resource agent analysis on an in-memory transcript and return parsed JSON.
105
 
 
124
  **llm_params,
125
  )
126
 
127
+ schema_version = "8"
128
+ analysis_prompt_version = "v3"
129
 
130
  evidence_catalog: Dict[str, Dict[str, Any]] = {}
131
  for message in transcript:
 
150
  )
151
  system_prompt = (base + "\n\n" + compile_analysis_rules_block(analysis_attributes)).strip()
152
 
153
+ template_categories = top_down_template_categories or []
154
+ template_categories = [
155
+ {"category_id": str(c.get("category_id") or "").strip(), "label": str(c.get("label") or "").strip()}
156
+ for c in template_categories
157
+ if isinstance(c, dict)
158
+ and isinstance(c.get("category_id"), str)
159
+ and str(c.get("category_id") or "").strip()
160
+ and isinstance(c.get("label"), str)
161
+ and str(c.get("label") or "").strip()
162
+ ]
163
+
164
  evidence_catalog_json = json.dumps(evidence_catalog, ensure_ascii=False)
165
  user_prompt = (
166
  "Evidence catalog (JSON object mapping evidence_id -> sentence):\n"
 
203
  " \"confidence\": number // 0..1\n"
204
  " }\n"
205
  " }\n"
206
+ " \"top_down_codebook\": {\n"
207
+ " \"template_id\": string,\n"
208
+ " \"template_version_id\": string,\n"
209
+ " \"categories\": [\n"
210
+ " {\n"
211
+ " \"category_id\": string,\n"
212
+ " \"label\": string,\n"
213
+ " \"items\": [ {\"code\": string, \"summary\": string, \"evidence\": [ {\"evidence_id\": string} ], \"confidence\": number // 0..1 } ]\n"
214
+ " }\n"
215
+ " ]\n"
216
  " }\n"
217
  "}\n"
218
  )
219
 
220
+ if template_categories:
221
+ template_json = json.dumps(
222
+ {
223
+ "template_id": top_down_template_id or "",
224
+ "template_version_id": top_down_template_version_id or "",
225
+ "categories": template_categories,
226
+ },
227
+ ensure_ascii=False,
228
+ )
229
+ user_prompt = (
230
+ user_prompt
231
+ + "\n\nTop-down codebook template (JSON):\n"
232
+ + template_json
233
+ + "\n\nFor top-down coding, you MUST populate top_down_codebook.categories in the same order as the template. "
234
+ "Use the exact category_id and label from the template. If no evidence supports a category, return an empty items array.\n"
235
+ )
236
+
237
  try:
238
  raw = await client.generate(prompt=user_prompt, system_prompt=system_prompt, temperature=0.2)
239
  parsed = json.loads(raw)
 
253
  if normalized is not None:
254
  box["confidence"] = normalized
255
 
256
+ td_book = parsed.get("top_down_codebook")
257
+ if not isinstance(td_book, dict):
258
+ td_book = None
259
+
260
+ # Compatibility shim: if the model returns the legacy dict-shaped top_down_codes,
261
+ # map it into the new array-based top_down_codebook using the template order when available.
262
+ if td_book is None:
263
+ legacy = parsed.get("top_down_codes")
264
+ if isinstance(legacy, dict):
265
+ cats: List[Dict[str, Any]] = []
266
+ if template_categories:
267
+ for cat in template_categories:
268
+ cid = cat.get("category_id")
269
+ label = cat.get("label")
270
+ arr = legacy.get(cid) or []
271
+ cats.append({"category_id": cid, "label": label, "items": arr if isinstance(arr, list) else []})
272
+ else:
273
+ for key, arr in legacy.items():
274
+ if not isinstance(key, str):
275
+ continue
276
+ cats.append({"category_id": key, "label": key, "items": arr if isinstance(arr, list) else []})
277
+ td_book = {
278
+ "template_id": top_down_template_id or "",
279
+ "template_version_id": top_down_template_version_id or "",
280
+ "categories": cats,
281
+ }
282
+ parsed["top_down_codebook"] = td_book
283
+
284
+ if isinstance(td_book, dict):
285
+ categories = td_book.get("categories") or []
286
+ if isinstance(categories, list):
287
+ for cat in categories:
288
+ if not isinstance(cat, dict):
289
+ continue
290
+ items = cat.get("items") or []
291
+ if not isinstance(items, list):
292
+ continue
293
+ for item in items:
294
+ if not isinstance(item, dict):
295
+ continue
296
+ normalized = _normalize_confidence(item.get("confidence"))
297
+ if normalized is not None:
298
+ item["confidence"] = normalized
299
 
300
  return parsed
301
  finally:
 
333
  patient_system_prompt: str = ""
334
  analysis_system_prompt: str = ""
335
  analysis_attributes: List[str] = field(default_factory=list)
336
+ top_down_template_id: str = ""
337
+ top_down_template_version_id: str = ""
338
+ top_down_template_categories: List[Dict[str, str]] = field(default_factory=list)
339
  patient_attributes: List[str] = field(default_factory=list)
340
  surveyor_attributes: List[str] = field(default_factory=list)
341
  surveyor_question_bank: Optional[str] = None
 
358
  patient_system_prompt: str = ""
359
  analysis_system_prompt: str = ""
360
  analysis_attributes: List[str] = field(default_factory=list)
361
+ top_down_template_id: str = ""
362
+ top_down_template_version_id: str = ""
363
+ top_down_template_categories: List[Dict[str, str]] = field(default_factory=list)
364
  patient_attributes: List[str] = field(default_factory=list)
365
  surveyor_attributes: List[str] = field(default_factory=list)
366
  surveyor_question_bank: Optional[str] = None
 
417
  return []
418
  return [s.strip() for s in attrs if isinstance(s, str) and s.strip()]
419
 
420
+ async def _resolve_top_down_template(self) -> Dict[str, Any]:
421
+ store = get_persona_store()
422
+ template_id = await store.get_setting("top_down_codebook_template_id")
423
+ template_id_str = template_id.strip() if isinstance(template_id, str) else ""
424
+ record = await store.get_analysis_template(template_id_str, include_deleted=False) if template_id_str else None
425
+ if record:
426
+ return {
427
+ "template_id": record.template_id,
428
+ "template_version_id": record.current_version_id,
429
+ "categories": record.categories,
430
+ }
431
+ return {"template_id": template_id_str, "template_version_id": "", "categories": []}
432
+
433
  async def start_human_chat(
434
  self,
435
  conversation_id: str,
 
472
  resolved_patient_prompt = pp if isinstance(pp, str) and pp.strip() else DEFAULT_PATIENT_SYSTEM_PROMPT
473
  resolved_analysis_prompt = asp if isinstance(asp, str) and asp.strip() else ""
474
  resolved_analysis_attrs = [s.strip() for s in ap if isinstance(ap, str) and s.strip()] if isinstance(ap, list) else []
475
+ template = await self._resolve_top_down_template()
476
 
477
  chat_info = HumanChatInfo(
478
  conversation_id=conversation_id,
 
485
  patient_system_prompt=resolved_patient_prompt,
486
  analysis_system_prompt=resolved_analysis_prompt,
487
  analysis_attributes=resolved_analysis_attrs,
488
+ top_down_template_id=str(template.get("template_id") or ""),
489
+ top_down_template_version_id=str(template.get("template_version_id") or ""),
490
+ top_down_template_categories=list(template.get("categories") or []),
491
  patient_attributes=self._persona_attributes(patient_persona),
492
  surveyor_attributes=self._persona_attributes(surveyor_persona),
493
  surveyor_question_bank=self._persona_question_bank(surveyor_persona),
 
838
  resolved_patient_prompt = pp if isinstance(pp, str) and pp.strip() else DEFAULT_PATIENT_SYSTEM_PROMPT
839
  resolved_analysis_prompt = asp if isinstance(asp, str) and asp.strip() else ""
840
  resolved_analysis_attrs = [s.strip() for s in ap if isinstance(ap, str) and s.strip()] if isinstance(ap, list) else []
841
+ template = await self._resolve_top_down_template()
842
 
843
  # Create conversation info
844
  conv_info = ConversationInfo(
 
852
  patient_system_prompt=resolved_patient_prompt,
853
  analysis_system_prompt=resolved_analysis_prompt,
854
  analysis_attributes=resolved_analysis_attrs,
855
+ top_down_template_id=str(template.get("template_id") or ""),
856
+ top_down_template_version_id=str(template.get("template_version_id") or ""),
857
+ top_down_template_categories=list(template.get("categories") or []),
858
  patient_attributes=self._persona_attributes(patient_persona),
859
  surveyor_attributes=self._persona_attributes(surveyor_persona),
860
  surveyor_question_bank=self._persona_question_bank(surveyor_persona),
 
1122
  settings=self.settings,
1123
  analysis_attributes=getattr(conv_info, "analysis_attributes", None),
1124
  analysis_system_prompt=getattr(conv_info, "analysis_system_prompt", None),
1125
+ top_down_template_id=getattr(conv_info, "top_down_template_id", None),
1126
+ top_down_template_version_id=getattr(conv_info, "top_down_template_version_id", None),
1127
+ top_down_template_categories=getattr(conv_info, "top_down_template_categories", None),
1128
  )
1129
 
1130
  persisted = False
 
1174
  "analysis": {
1175
  "analysis_system_prompt": getattr(conv_info, "analysis_system_prompt", None),
1176
  "analysis_attributes": getattr(conv_info, "analysis_attributes", None),
1177
+ "top_down_codebook_template_id": getattr(conv_info, "top_down_template_id", None),
1178
+ "top_down_codebook_template_version_id": getattr(conv_info, "top_down_template_version_id", None),
1179
+ "top_down_codebook_template_snapshot": getattr(conv_info, "top_down_template_categories", None),
1180
  },
1181
  }
1182
 
backend/api/export_routes.py CHANGED
@@ -162,9 +162,13 @@ def _export_xlsx_bytes(payload: ExportRequest) -> bytes:
162
  )
163
 
164
  ws_td = add_sheet("TopDownCodes", ["category", "index", "code", "summary", "confidence", "evidence_ids"])
165
- top_down = payload.resources.get("top_down_codes") or {}
166
- if isinstance(top_down, dict):
167
- for category, arr in top_down.items():
 
 
 
 
168
  if not isinstance(arr, list):
169
  continue
170
  for idx, item in enumerate(arr):
@@ -173,7 +177,7 @@ def _export_xlsx_bytes(payload: ExportRequest) -> bytes:
173
  evidence_ids = _extract_evidence_ids(item.get("evidence"))
174
  ws_td.append(
175
  [
176
- str(category),
177
  idx,
178
  item.get("code", "") or "",
179
  item.get("summary", "") or "",
@@ -181,6 +185,26 @@ def _export_xlsx_bytes(payload: ExportRequest) -> bytes:
181
  _safe_join(evidence_ids),
182
  ]
183
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
 
185
  for ws in wb.worksheets:
186
  for row in ws.iter_rows(min_row=2):
 
162
  )
163
 
164
  ws_td = add_sheet("TopDownCodes", ["category", "index", "code", "summary", "confidence", "evidence_ids"])
165
+ td_book = payload.resources.get("top_down_codebook")
166
+ if isinstance(td_book, dict) and isinstance(td_book.get("categories"), list):
167
+ for cat in td_book.get("categories") or []:
168
+ if not isinstance(cat, dict):
169
+ continue
170
+ label = cat.get("label") or cat.get("category_id") or "Category"
171
+ arr = cat.get("items") or []
172
  if not isinstance(arr, list):
173
  continue
174
  for idx, item in enumerate(arr):
 
177
  evidence_ids = _extract_evidence_ids(item.get("evidence"))
178
  ws_td.append(
179
  [
180
+ str(label),
181
  idx,
182
  item.get("code", "") or "",
183
  item.get("summary", "") or "",
 
185
  _safe_join(evidence_ids),
186
  ]
187
  )
188
+ else:
189
+ top_down = payload.resources.get("top_down_codes") or {}
190
+ if isinstance(top_down, dict):
191
+ for category, arr in top_down.items():
192
+ if not isinstance(arr, list):
193
+ continue
194
+ for idx, item in enumerate(arr):
195
+ if not isinstance(item, dict):
196
+ continue
197
+ evidence_ids = _extract_evidence_ids(item.get("evidence"))
198
+ ws_td.append(
199
+ [
200
+ str(category),
201
+ idx,
202
+ item.get("code", "") or "",
203
+ item.get("summary", "") or "",
204
+ item.get("confidence", ""),
205
+ _safe_join(evidence_ids),
206
+ ]
207
+ )
208
 
209
  for ws in wb.worksheets:
210
  for row in ws.iter_rows(min_row=2):
backend/api/main.py CHANGED
@@ -32,6 +32,7 @@ from .conversation_routes import router as conversation_router # noqa: E402
32
  from .persona_routes import router as persona_router # noqa: E402
33
  from .export_routes import router as export_router # noqa: E402
34
  from .analysis_routes import router as analysis_router # noqa: E402
 
35
  from .run_routes import router as run_router # noqa: E402
36
  from .run_export_routes import router as run_export_router # noqa: E402
37
  from .conversation_service import initialize_conversation_service # noqa: E402
@@ -70,6 +71,7 @@ app.include_router(conversation_router)
70
  app.include_router(persona_router)
71
  app.include_router(export_router)
72
  app.include_router(analysis_router)
 
73
  app.include_router(run_router)
74
  app.include_router(run_export_router)
75
 
 
32
  from .persona_routes import router as persona_router # noqa: E402
33
  from .export_routes import router as export_router # noqa: E402
34
  from .analysis_routes import router as analysis_router # noqa: E402
35
+ from .analysis_template_routes import router as analysis_template_router # noqa: E402
36
  from .run_routes import router as run_router # noqa: E402
37
  from .run_export_routes import router as run_export_router # noqa: E402
38
  from .conversation_service import initialize_conversation_service # noqa: E402
 
71
  app.include_router(persona_router)
72
  app.include_router(export_router)
73
  app.include_router(analysis_router)
74
+ app.include_router(analysis_template_router)
75
  app.include_router(run_router)
76
  app.include_router(run_export_router)
77
 
backend/api/persona_routes.py CHANGED
@@ -52,6 +52,7 @@ class SettingsResponse(BaseModel):
52
  patient_system_prompt: str
53
  analysis_system_prompt: str
54
  analysis_attributes: List[str]
 
55
 
56
 
57
  class UpdateSettingsRequest(BaseModel):
@@ -59,6 +60,7 @@ class UpdateSettingsRequest(BaseModel):
59
  patient_system_prompt: Optional[str] = None
60
  analysis_system_prompt: Optional[str] = None
61
  analysis_attributes: Optional[List[str]] = None
 
62
 
63
 
64
  @router.get("/settings/defaults")
@@ -68,6 +70,7 @@ async def get_settings_defaults() -> SettingsResponse:
68
  patient_system_prompt=DEFAULT_PATIENT_SYSTEM_PROMPT,
69
  analysis_system_prompt=DEFAULT_ANALYSIS_SYSTEM_PROMPT,
70
  analysis_attributes=list(DEFAULT_ANALYSIS_ATTRIBUTES),
 
71
  )
72
 
73
 
@@ -244,11 +247,13 @@ async def get_settings() -> SettingsResponse:
244
  pp = await store.get_setting("patient_system_prompt")
245
  asp = await store.get_setting("analysis_system_prompt")
246
  ap = await store.get_setting("analysis_attributes")
 
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
  analysis_attributes=[s for s in ap if isinstance(ap, str)] if isinstance(ap, list) else [],
 
252
  )
253
 
254
 
@@ -266,4 +271,6 @@ async def update_settings(payload: UpdateSettingsRequest) -> SettingsResponse:
266
  "analysis_attributes",
267
  [s.strip() for s in (payload.analysis_attributes or []) if isinstance(s, str) and s.strip()],
268
  )
 
 
269
  return await get_settings()
 
52
  patient_system_prompt: str
53
  analysis_system_prompt: str
54
  analysis_attributes: List[str]
55
+ top_down_codebook_template_id: str
56
 
57
 
58
  class UpdateSettingsRequest(BaseModel):
 
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
 
66
  @router.get("/settings/defaults")
 
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
 
 
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
 
259
 
 
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()
backend/core/analysis_knobs.py CHANGED
@@ -11,7 +11,7 @@ DEFAULT_ANALYSIS_ATTRIBUTES: List[str] = [
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_codes categories: include a short code label (1-3 words) and cite evidence.",
15
  "Prefer fewer, higher-confidence items.",
16
  ]
17
 
 
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
 
backend/core/default_analysis_templates.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 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"},
12
+ {"category_id": "barriers_constraints", "label": "Barriers/constraints"},
13
+ {"category_id": "support_resources", "label": "Support/resources"},
14
+ ],
15
+ }
16
+
17
+
18
+ def default_top_down_categories() -> List[Dict[str, str]]:
19
+ cats = DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE.get("categories") # type: ignore[assignment]
20
+ if isinstance(cats, list):
21
+ return [c for c in cats if isinstance(c, dict)] # type: ignore[return-value]
22
+ return []
23
+
backend/core/persona_seed.py CHANGED
@@ -5,6 +5,7 @@ from typing import Any, Dict, List
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.storage.sqlite_persona_store import SQLitePersonaStore
9
 
10
 
@@ -26,6 +27,23 @@ async def seed_defaults_overwrite(*, store: SQLitePersonaStore) -> None:
26
  if not (isinstance(existing_analysis_prompt, str) and existing_analysis_prompt.strip()):
27
  await store.upsert_setting("analysis_system_prompt", DEFAULT_ANALYSIS_SYSTEM_PROMPT)
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  # Personas (overwrite by writing a new version each startup)
30
  for p in (DEFAULT_SURVEYOR_PERSONAS + DEFAULT_PATIENT_PERSONAS):
31
  await store.upsert_default_persona(
 
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
 
11
 
 
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)
29
 
30
+ # Top-down codebook template: seed a default and set the active template if missing.
31
+ template_id = str(DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE.get("template_id") or "top_down_v1")
32
+ template_name = str(DEFAULT_TOP_DOWN_CODEBOOK_TEMPLATE.get("name") or "Default top-down codebook (v1)")
33
+ try:
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:
40
+ # Best-effort seeding; do not block startup.
41
+ pass
42
+
43
+ active_template = await store.get_setting("top_down_codebook_template_id")
44
+ if not (isinstance(active_template, str) and active_template.strip()):
45
+ await store.upsert_setting("top_down_codebook_template_id", template_id)
46
+
47
  # Personas (overwrite by writing a new version each startup)
48
  for p in (DEFAULT_SURVEYOR_PERSONAS + DEFAULT_PATIENT_PERSONAS):
49
  await store.upsert_default_persona(
backend/storage/__init__.py CHANGED
@@ -1,4 +1,11 @@
1
- from .models import RunRecord, RunSummary, PersonaRecord, PersonaSummary
 
 
 
 
 
 
 
2
  from .sqlite_persona_store import SQLitePersonaStore
3
  from .sqlite_run_store import SQLiteRunStore
4
 
@@ -7,6 +14,8 @@ __all__ = [
7
  "RunSummary",
8
  "PersonaRecord",
9
  "PersonaSummary",
 
 
10
  "SQLiteRunStore",
11
  "SQLitePersonaStore",
12
  ]
 
1
+ from .models import (
2
+ RunRecord,
3
+ RunSummary,
4
+ PersonaRecord,
5
+ PersonaSummary,
6
+ AnalysisTemplateRecord,
7
+ AnalysisTemplateSummary,
8
+ )
9
  from .sqlite_persona_store import SQLitePersonaStore
10
  from .sqlite_run_store import SQLiteRunStore
11
 
 
14
  "RunSummary",
15
  "PersonaRecord",
16
  "PersonaSummary",
17
+ "AnalysisTemplateRecord",
18
+ "AnalysisTemplateSummary",
19
  "SQLiteRunStore",
20
  "SQLitePersonaStore",
21
  ]
backend/storage/models.py CHANGED
@@ -52,3 +52,23 @@ class PersonaRecord:
52
  updated_at: str
53
  attributes: List[str]
54
  question_bank_items: List[str]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  updated_at: str
53
  attributes: List[str]
54
  question_bank_items: List[str]
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class AnalysisTemplateSummary:
59
+ template_id: str
60
+ name: str
61
+ is_default: bool = False
62
+ is_deleted: bool = False
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class AnalysisTemplateRecord:
67
+ template_id: str
68
+ name: str
69
+ is_default: bool
70
+ is_deleted: bool
71
+ current_version_id: str
72
+ created_at: str
73
+ updated_at: str
74
+ categories: List[Dict[str, str]]
backend/storage/sqlite_persona_store.py CHANGED
@@ -9,7 +9,7 @@ from uuid import uuid4
9
 
10
  import aiosqlite
11
 
12
- from .models import PersonaRecord, PersonaSummary
13
 
14
 
15
  def _now_iso() -> str:
@@ -26,6 +26,23 @@ def _clean_str_list(value: Any) -> List[str]:
26
  return out
27
 
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  class SQLitePersonaStore:
30
  def __init__(self, db_path: str):
31
  self.db_path = str(db_path)
@@ -76,6 +93,323 @@ class SQLitePersonaStore:
76
  );
77
  """
78
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  await db.commit()
80
 
81
  async def upsert_setting(self, key: str, value: Any) -> None:
 
9
 
10
  import aiosqlite
11
 
12
+ from .models import PersonaRecord, PersonaSummary, AnalysisTemplateRecord, AnalysisTemplateSummary
13
 
14
 
15
  def _now_iso() -> 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 []
32
+ out: List[Dict[str, str]] = []
33
+ for item in value:
34
+ if not isinstance(item, dict):
35
+ continue
36
+ cid = item.get("category_id")
37
+ label = item.get("label")
38
+ if not (isinstance(cid, str) and cid.strip()):
39
+ continue
40
+ if not (isinstance(label, str) and label.strip()):
41
+ continue
42
+ out.append({"category_id": cid.strip(), "label": label.strip()})
43
+ return out
44
+
45
+
46
  class SQLitePersonaStore:
47
  def __init__(self, db_path: str):
48
  self.db_path = str(db_path)
 
93
  );
94
  """
95
  )
96
+
97
+ await db.execute(
98
+ """
99
+ CREATE TABLE IF NOT EXISTS analysis_templates (
100
+ template_id TEXT PRIMARY KEY,
101
+ name TEXT NOT NULL,
102
+ is_default INTEGER NOT NULL DEFAULT 0,
103
+ is_deleted INTEGER NOT NULL DEFAULT 0,
104
+ current_version_id TEXT NOT NULL,
105
+ created_at TEXT NOT NULL,
106
+ updated_at TEXT NOT NULL
107
+ );
108
+ """
109
+ )
110
+ await db.execute("CREATE INDEX IF NOT EXISTS analysis_templates_is_default ON analysis_templates(is_default);")
111
+ await db.execute(
112
+ """
113
+ CREATE TABLE IF NOT EXISTS analysis_template_versions (
114
+ template_id TEXT NOT NULL,
115
+ version_id TEXT NOT NULL,
116
+ created_at TEXT NOT NULL,
117
+ content_json TEXT NOT NULL,
118
+ PRIMARY KEY (template_id, version_id),
119
+ FOREIGN KEY (template_id) REFERENCES analysis_templates(template_id) ON DELETE CASCADE
120
+ );
121
+ """
122
+ )
123
+ await db.commit()
124
+
125
+ async def list_analysis_template_summaries(
126
+ self,
127
+ *,
128
+ include_deleted: bool = False,
129
+ ) -> List[AnalysisTemplateSummary]:
130
+ async with aiosqlite.connect(self.db_path) as db:
131
+ await db.execute("PRAGMA foreign_keys=ON;")
132
+ where: List[str] = []
133
+ args: List[Any] = []
134
+ if not include_deleted:
135
+ where.append("is_deleted = 0")
136
+ clause = ("WHERE " + " AND ".join(where)) if where else ""
137
+ cur = await db.execute(
138
+ f"""
139
+ SELECT template_id, name, is_default, is_deleted
140
+ FROM analysis_templates
141
+ {clause}
142
+ ORDER BY is_default DESC, name ASC;
143
+ """,
144
+ tuple(args),
145
+ )
146
+ rows = await cur.fetchall()
147
+ return [
148
+ AnalysisTemplateSummary(
149
+ template_id=row[0],
150
+ name=row[1],
151
+ is_default=bool(row[2]),
152
+ is_deleted=bool(row[3]),
153
+ )
154
+ for row in rows
155
+ ]
156
+
157
+ async def list_analysis_template_records(
158
+ self,
159
+ *,
160
+ include_deleted: bool = False,
161
+ ) -> List[AnalysisTemplateRecord]:
162
+ async with aiosqlite.connect(self.db_path) as db:
163
+ await db.execute("PRAGMA foreign_keys=ON;")
164
+ where: List[str] = []
165
+ args: List[Any] = []
166
+ if not include_deleted:
167
+ where.append("t.is_deleted = 0")
168
+ clause = ("WHERE " + " AND ".join(where)) if where else ""
169
+ cur = await db.execute(
170
+ f"""
171
+ SELECT
172
+ t.template_id, t.name, t.is_default, t.is_deleted,
173
+ t.current_version_id, t.created_at, t.updated_at,
174
+ v.content_json
175
+ FROM analysis_templates t
176
+ JOIN analysis_template_versions v
177
+ ON v.template_id = t.template_id AND v.version_id = t.current_version_id
178
+ {clause}
179
+ ORDER BY t.is_default DESC, t.name ASC;
180
+ """,
181
+ tuple(args),
182
+ )
183
+ rows = await cur.fetchall()
184
+
185
+ out: List[AnalysisTemplateRecord] = []
186
+ for row in rows:
187
+ content: Dict[str, Any] = {}
188
+ if isinstance(row[7], str):
189
+ try:
190
+ content = json.loads(row[7]) or {}
191
+ except Exception:
192
+ content = {}
193
+ out.append(
194
+ AnalysisTemplateRecord(
195
+ template_id=row[0],
196
+ name=row[1],
197
+ is_default=bool(row[2]),
198
+ is_deleted=bool(row[3]),
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
+ )
205
+ return out
206
+
207
+ async def get_analysis_template(
208
+ self,
209
+ template_id: str,
210
+ *,
211
+ include_deleted: bool = False,
212
+ ) -> Optional[AnalysisTemplateRecord]:
213
+ if not isinstance(template_id, str) or not template_id.strip():
214
+ return None
215
+ async with aiosqlite.connect(self.db_path) as db:
216
+ await db.execute("PRAGMA foreign_keys=ON;")
217
+ cur = await db.execute(
218
+ """
219
+ SELECT template_id, name, is_default, is_deleted, current_version_id, created_at, updated_at
220
+ FROM analysis_templates
221
+ WHERE template_id = ?;
222
+ """,
223
+ (template_id,),
224
+ )
225
+ row = await cur.fetchone()
226
+ if not row:
227
+ return None
228
+ if bool(row[3]) and not include_deleted:
229
+ return None
230
+
231
+ cur2 = await db.execute(
232
+ """
233
+ SELECT content_json
234
+ FROM analysis_template_versions
235
+ WHERE template_id = ? AND version_id = ?;
236
+ """,
237
+ (row[0], row[4]),
238
+ )
239
+ vrow = await cur2.fetchone()
240
+ content: Dict[str, Any] = {}
241
+ if vrow and isinstance(vrow[0], str):
242
+ try:
243
+ content = json.loads(vrow[0]) or {}
244
+ except Exception:
245
+ content = {}
246
+
247
+ return AnalysisTemplateRecord(
248
+ template_id=row[0],
249
+ name=row[1],
250
+ is_default=bool(row[2]),
251
+ is_deleted=bool(row[3]),
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
+
258
+ async def upsert_default_analysis_template(
259
+ self,
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():
266
+ raise ValueError("template_id is required")
267
+ if not isinstance(name, str) or not name.strip():
268
+ raise ValueError("name is required")
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;")
279
+ cur = await db.execute(
280
+ "SELECT current_version_id FROM analysis_templates WHERE template_id = ?;",
281
+ (template_id,),
282
+ )
283
+ existing = await cur.fetchone()
284
+
285
+ if existing:
286
+ # Do not overwrite existing defaults; keep the DB canonical once created.
287
+ return template_id, str(existing[0])
288
+
289
+ await db.execute(
290
+ """
291
+ INSERT INTO analysis_template_versions (template_id, version_id, created_at, content_json)
292
+ VALUES (?, ?, ?, ?);
293
+ """,
294
+ (template_id, version_id, now, content_json),
295
+ )
296
+ await db.execute(
297
+ """
298
+ INSERT INTO analysis_templates (template_id, name, is_default, is_deleted, current_version_id, created_at, updated_at)
299
+ VALUES (?, ?, 1, 0, ?, ?, ?);
300
+ """,
301
+ (template_id, name.strip(), version_id, now, now),
302
+ )
303
+ await db.commit()
304
+ return template_id, version_id
305
+
306
+ async def create_analysis_template(
307
+ self,
308
+ *,
309
+ name: str,
310
+ categories: List[Dict[str, str]],
311
+ is_default: bool = False,
312
+ ) -> Tuple[str, str]:
313
+ if not isinstance(name, str) or not name.strip():
314
+ raise ValueError("name is required")
315
+ cleaned_categories = _clean_category_list(categories)
316
+ if not cleaned_categories:
317
+ raise ValueError("categories are required")
318
+
319
+ now = _now_iso()
320
+ template_id = str(uuid4())
321
+ version_id = str(uuid4())
322
+ content_json = json.dumps({"categories": cleaned_categories}, ensure_ascii=False)
323
+
324
+ async with aiosqlite.connect(self.db_path) as db:
325
+ await db.execute("PRAGMA foreign_keys=ON;")
326
+ await db.execute(
327
+ """
328
+ INSERT INTO analysis_template_versions (template_id, version_id, created_at, content_json)
329
+ VALUES (?, ?, ?, ?);
330
+ """,
331
+ (template_id, version_id, now, content_json),
332
+ )
333
+ await db.execute(
334
+ """
335
+ INSERT INTO analysis_templates (template_id, name, is_default, is_deleted, current_version_id, created_at, updated_at)
336
+ VALUES (?, ?, ?, 0, ?, ?, ?);
337
+ """,
338
+ (template_id, name.strip(), 1 if is_default else 0, version_id, now, now),
339
+ )
340
+ await db.commit()
341
+ return template_id, version_id
342
+
343
+ async def update_analysis_template(
344
+ self,
345
+ *,
346
+ template_id: str,
347
+ categories: List[Dict[str, str]],
348
+ ) -> str:
349
+ if not isinstance(template_id, str) or not template_id.strip():
350
+ raise ValueError("template_id is required")
351
+ cleaned_categories = _clean_category_list(categories)
352
+ if not cleaned_categories:
353
+ raise ValueError("categories are required")
354
+
355
+ now = _now_iso()
356
+ version_id = str(uuid4())
357
+ content_json = json.dumps({"categories": cleaned_categories}, ensure_ascii=False)
358
+
359
+ async with aiosqlite.connect(self.db_path) as db:
360
+ await db.execute("PRAGMA foreign_keys=ON;")
361
+ cur = await db.execute(
362
+ "SELECT is_default, is_deleted FROM analysis_templates WHERE template_id = ?;",
363
+ (template_id,),
364
+ )
365
+ row = await cur.fetchone()
366
+ if not row:
367
+ raise ValueError("template not found")
368
+ if bool(row[1]):
369
+ raise ValueError("template not found")
370
+ if bool(row[0]):
371
+ raise PermissionError("default template is immutable")
372
+
373
+ await db.execute(
374
+ """
375
+ INSERT INTO analysis_template_versions (template_id, version_id, created_at, content_json)
376
+ VALUES (?, ?, ?, ?);
377
+ """,
378
+ (template_id, version_id, now, content_json),
379
+ )
380
+ await db.execute(
381
+ """
382
+ UPDATE analysis_templates
383
+ SET current_version_id = ?, updated_at = ?
384
+ WHERE template_id = ?;
385
+ """,
386
+ (version_id, now, template_id),
387
+ )
388
+ await db.commit()
389
+ return version_id
390
+
391
+ async def soft_delete_analysis_template(self, template_id: str) -> None:
392
+ if not isinstance(template_id, str) or not template_id.strip():
393
+ raise ValueError("template_id is required")
394
+ now = _now_iso()
395
+ async with aiosqlite.connect(self.db_path) as db:
396
+ await db.execute("PRAGMA foreign_keys=ON;")
397
+ cur = await db.execute(
398
+ "SELECT is_default, is_deleted FROM analysis_templates WHERE template_id = ?;",
399
+ (template_id,),
400
+ )
401
+ row = await cur.fetchone()
402
+ if not row:
403
+ raise ValueError("template not found")
404
+ if bool(row[1]):
405
+ raise ValueError("template not found")
406
+ if bool(row[0]):
407
+ raise PermissionError("default template is immutable")
408
+
409
+ await db.execute(
410
+ "UPDATE analysis_templates SET is_deleted = 1, updated_at = ? WHERE template_id = ?;",
411
+ (now, template_id),
412
+ )
413
  await db.commit()
414
 
415
  async def upsert_setting(self, key: str, value: Any) -> None:
frontend/pages/config_view.py CHANGED
@@ -15,7 +15,7 @@ def get_config_view_js() -> str:
15
  "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.",
16
  "confidence must be a number between 0 and 1.",
17
  "For health_situations: include a short code label (1-3 words) in addition to the longer summary.",
18
- "For top_down_codes categories: include a short code label (1-3 words) and cite evidence.",
19
  "Prefer fewer, higher-confidence items.",
20
  ];
21
 
@@ -86,6 +86,10 @@ Requirements:
86
  const [analysisSystemPrompt, setAnalysisSystemPrompt] = React.useState(() => {
87
  return '';
88
  });
 
 
 
 
89
  const [savedAt, setSavedAt] = React.useState(existing?.saved_at || null);
90
  const [saveFlash, setSaveFlash] = React.useState(false);
91
  const applySurveyorDefaults = () => {
@@ -116,6 +120,61 @@ Requirements:
116
  .catch(() => setAnalysisSystemPrompt(DEFAULT_ANALYSIS_SYSTEM_PROMPT));
117
  };
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  React.useEffect(() => {
120
  if (activePane === 'surveyors') {
121
  setSurveyorPromptUnlocked(false);
@@ -128,6 +187,10 @@ Requirements:
128
  }
129
  }, [activePane]);
130
 
 
 
 
 
131
  const refreshPersonas = () => {
132
  return authedFetch('/api/personas')
133
  .then(r => r.json())
@@ -201,12 +264,36 @@ Requirements:
201
  if (typeof data?.surveyor_system_prompt === 'string') setSurveyorSystemPrompt(data.surveyor_system_prompt);
202
  if (typeof data?.patient_system_prompt === 'string') setPatientSystemPrompt(data.patient_system_prompt);
203
  if (typeof data?.analysis_system_prompt === 'string') setAnalysisSystemPrompt(data.analysis_system_prompt);
 
204
  const attrs = Array.isArray(data?.analysis_attributes) ? data.analysis_attributes : [];
205
  setAnalysisAttributeItems(attrs.map((x) => (typeof x === 'string' ? x.trim() : '')).filter((x) => x.length > 0));
206
  })
207
  .catch(() => {});
208
  }, []);
209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  React.useEffect(() => {
211
  authedFetch(`/api/personas/${selectedSurveyorId}`)
212
  .then(r => r.json())
@@ -256,6 +343,7 @@ Requirements:
256
  surveyor_system_prompt: (surveyorSystemPrompt || '').trim(),
257
  patient_system_prompt: (patientSystemPrompt || '').trim(),
258
  analysis_system_prompt: (analysisSystemPrompt || '').trim(),
 
259
  analysis_attributes: (analysisAttributeItems || []).map((x) => (typeof x === 'string' ? x.trim() : '')).filter((x) => x.length > 0),
260
  };
261
 
@@ -285,6 +373,18 @@ Requirements:
285
  })
286
  }).catch(() => null)
287
  : Promise.resolve(null),
 
 
 
 
 
 
 
 
 
 
 
 
288
  ]).then(() => {
289
  setSavedAt(cfg.saved_at);
290
  setSaveFlash(true);
@@ -738,6 +838,132 @@ Requirements:
738
  </div>
739
  </div>
740
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
741
  <div className="grid grid-cols-2 gap-6">
742
  <div>
743
  <div className="flex items-center justify-between gap-3 mb-2">
 
15
  "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.",
16
  "confidence must be a number between 0 and 1.",
17
  "For health_situations: include a short code label (1-3 words) in addition to the longer summary.",
18
+ "For top_down_codebook categories: include a short code label (1-3 words) and cite evidence.",
19
  "Prefer fewer, higher-confidence items.",
20
  ];
21
 
 
86
  const [analysisSystemPrompt, setAnalysisSystemPrompt] = React.useState(() => {
87
  return '';
88
  });
89
+ const [analysisTemplates, setAnalysisTemplates] = React.useState([]);
90
+ const [selectedTemplateId, setSelectedTemplateId] = React.useState('');
91
+ const [selectedTemplateDetail, setSelectedTemplateDetail] = React.useState(null);
92
+ const [selectedTemplateCategories, setSelectedTemplateCategories] = React.useState([]);
93
  const [savedAt, setSavedAt] = React.useState(existing?.saved_at || null);
94
  const [saveFlash, setSaveFlash] = React.useState(false);
95
  const applySurveyorDefaults = () => {
 
120
  .catch(() => setAnalysisSystemPrompt(DEFAULT_ANALYSIS_SYSTEM_PROMPT));
121
  };
122
 
123
+ const refreshAnalysisTemplates = () => {
124
+ return authedFetch('/api/analysis-templates')
125
+ .then(r => r.json())
126
+ .then(rows => {
127
+ const list = Array.isArray(rows) ? rows : [];
128
+ setAnalysisTemplates(list);
129
+ return list;
130
+ })
131
+ .catch(() => null);
132
+ };
133
+
134
+ const addTemplateCategory = () => setSelectedTemplateCategories((prev) => ([...(prev || []), { category_id: '', label: '' }]));
135
+ const updateTemplateCategoryLabel = (idx, label) => setSelectedTemplateCategories((prev) => (prev || []).map((c, i) => (i === idx ? Object.assign({}, c, { label }) : c)));
136
+ const removeTemplateCategory = (idx) => setSelectedTemplateCategories((prev) => (prev || []).filter((_, i) => i !== idx));
137
+ const moveTemplateCategory = (idx, dir) => {
138
+ setSelectedTemplateCategories((prev) => {
139
+ const arr = [...(prev || [])];
140
+ const next = idx + dir;
141
+ if (idx < 0 || idx >= arr.length) return arr;
142
+ if (next < 0 || next >= arr.length) return arr;
143
+ const tmp = arr[idx];
144
+ arr[idx] = arr[next];
145
+ arr[next] = tmp;
146
+ return arr;
147
+ });
148
+ };
149
+
150
+ const createAnalysisTemplate = async (cloneFromId) => {
151
+ const suggested = cloneFromId ? 'Copy of codebook' : '';
152
+ const name = window.prompt('New codebook template name', suggested) || '';
153
+ if (!name.trim()) return null;
154
+ const res = await authedFetch('/api/analysis-templates', {
155
+ method: 'POST',
156
+ headers: { 'Content-Type': 'application/json' },
157
+ body: JSON.stringify({
158
+ name: name.trim(),
159
+ clone_from_template_id: cloneFromId || undefined,
160
+ })
161
+ });
162
+ if (!res.ok) return null;
163
+ const data = await res.json();
164
+ await refreshAnalysisTemplates();
165
+ return data?.template_id || null;
166
+ };
167
+
168
+ const deleteAnalysisTemplate = async (templateId) => {
169
+ if (!templateId) return false;
170
+ const ok = window.confirm('Delete this codebook template? This cannot be undone.');
171
+ if (!ok) return false;
172
+ const res = await authedFetch(`/api/analysis-templates/${templateId}`, { method: 'DELETE' });
173
+ if (!res.ok) return false;
174
+ await refreshAnalysisTemplates();
175
+ return true;
176
+ };
177
+
178
  React.useEffect(() => {
179
  if (activePane === 'surveyors') {
180
  setSurveyorPromptUnlocked(false);
 
187
  }
188
  }, [activePane]);
189
 
190
+ React.useEffect(() => {
191
+ refreshAnalysisTemplates();
192
+ }, []);
193
+
194
  const refreshPersonas = () => {
195
  return authedFetch('/api/personas')
196
  .then(r => r.json())
 
264
  if (typeof data?.surveyor_system_prompt === 'string') setSurveyorSystemPrompt(data.surveyor_system_prompt);
265
  if (typeof data?.patient_system_prompt === 'string') setPatientSystemPrompt(data.patient_system_prompt);
266
  if (typeof data?.analysis_system_prompt === 'string') setAnalysisSystemPrompt(data.analysis_system_prompt);
267
+ if (typeof data?.top_down_codebook_template_id === 'string') setSelectedTemplateId(data.top_down_codebook_template_id);
268
  const attrs = Array.isArray(data?.analysis_attributes) ? data.analysis_attributes : [];
269
  setAnalysisAttributeItems(attrs.map((x) => (typeof x === 'string' ? x.trim() : '')).filter((x) => x.length > 0));
270
  })
271
  .catch(() => {});
272
  }, []);
273
 
274
+ React.useEffect(() => {
275
+ const tid = (selectedTemplateId || '').trim();
276
+ if (!tid) {
277
+ setSelectedTemplateDetail(null);
278
+ setSelectedTemplateCategories([]);
279
+ return;
280
+ }
281
+ authedFetch(`/api/analysis-templates/${tid}`)
282
+ .then(r => r.json())
283
+ .then(t => {
284
+ setSelectedTemplateDetail(t || null);
285
+ const cats = Array.isArray(t?.categories) ? t.categories : [];
286
+ setSelectedTemplateCategories(cats.map((c) => ({
287
+ category_id: (c && typeof c.category_id === 'string') ? c.category_id : '',
288
+ label: (c && typeof c.label === 'string') ? c.label : '',
289
+ })));
290
+ })
291
+ .catch(() => {
292
+ setSelectedTemplateDetail(null);
293
+ setSelectedTemplateCategories([]);
294
+ });
295
+ }, [selectedTemplateId]);
296
+
297
  React.useEffect(() => {
298
  authedFetch(`/api/personas/${selectedSurveyorId}`)
299
  .then(r => r.json())
 
343
  surveyor_system_prompt: (surveyorSystemPrompt || '').trim(),
344
  patient_system_prompt: (patientSystemPrompt || '').trim(),
345
  analysis_system_prompt: (analysisSystemPrompt || '').trim(),
346
+ top_down_codebook_template_id: (selectedTemplateId || '').trim(),
347
  analysis_attributes: (analysisAttributeItems || []).map((x) => (typeof x === 'string' ? x.trim() : '')).filter((x) => x.length > 0),
348
  };
349
 
 
373
  })
374
  }).catch(() => null)
375
  : Promise.resolve(null),
376
+ (activePane === 'analysis' && selectedTemplateDetail && selectedTemplateDetail.is_default === false)
377
+ ? authedFetch(`/api/analysis-templates/${selectedTemplateId}`, {
378
+ method: 'PUT',
379
+ headers: { 'Content-Type': 'application/json' },
380
+ body: JSON.stringify({
381
+ categories: (selectedTemplateCategories || []).map((c) => ({
382
+ category_id: (c && typeof c.category_id === 'string') ? c.category_id : '',
383
+ label: (c && typeof c.label === 'string') ? c.label.trim() : '',
384
+ })).filter((c) => c.label && c.label.length > 0),
385
+ })
386
+ }).catch(() => null)
387
+ : Promise.resolve(null),
388
  ]).then(() => {
389
  setSavedAt(cfg.saved_at);
390
  setSaveFlash(true);
 
838
  </div>
839
  </div>
840
 
841
+ <div className="grid grid-cols-2 gap-6">
842
+ <div>
843
+ <div className="flex items-center justify-between gap-3 mb-2">
844
+ <label className="block text-sm font-semibold text-slate-700">Top-down codebook template (applies to analyses)</label>
845
+ </div>
846
+ <div className="flex items-center gap-2 flex-wrap">
847
+ <select
848
+ className="w-full md:w-1/2 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
849
+ value={selectedTemplateId || ''}
850
+ onChange={(e) => setSelectedTemplateId(e.target.value)}
851
+ >
852
+ {(analysisTemplates.length ? analysisTemplates : [{template_id: 'top_down_v1', name: 'Default top-down codebook (v1)', is_default: true}]).map(t => (
853
+ <option key={t.template_id} value={t.template_id}>{t.name}</option>
854
+ ))}
855
+ </select>
856
+ <button
857
+ type="button"
858
+ onClick={async () => {
859
+ const id = await createAnalysisTemplate(null);
860
+ if (id) setSelectedTemplateId(id);
861
+ }}
862
+ 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"
863
+ >
864
+ + New
865
+ </button>
866
+ <button
867
+ type="button"
868
+ onClick={async () => {
869
+ const id = await createAnalysisTemplate(selectedTemplateId);
870
+ if (id) setSelectedTemplateId(id);
871
+ }}
872
+ 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"
873
+ >
874
+ Duplicate
875
+ </button>
876
+ <button
877
+ type="button"
878
+ disabled={!(selectedTemplateDetail && selectedTemplateDetail.is_default === false)}
879
+ onClick={async () => {
880
+ const ok = await deleteAnalysisTemplate(selectedTemplateId);
881
+ if (ok) {
882
+ const list = await refreshAnalysisTemplates();
883
+ const fallback = (list && list[0] && list[0].template_id) ? list[0].template_id : 'top_down_v1';
884
+ setSelectedTemplateId(fallback);
885
+ }
886
+ }}
887
+ 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"
888
+ >
889
+ Delete
890
+ </button>
891
+ </div>
892
+ {selectedTemplateDetail?.is_default !== false && (
893
+ <div className="mt-2 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 text-sm text-slate-800 w-fit">
894
+ Note: This is a default template that can't be edited. Create a new template to edit.
895
+ </div>
896
+ )}
897
+ </div>
898
+ <div />
899
+ </div>
900
+
901
+ <div className="grid grid-cols-2 gap-6">
902
+ <div>
903
+ <div className="flex items-center justify-between gap-3 mb-2">
904
+ <label className="block text-sm font-semibold text-slate-700">
905
+ Codebook categories {selectedTemplateDetail?.is_default === false ? '' : '(read-only)'}
906
+ </label>
907
+ </div>
908
+ <div className="space-y-2">
909
+ {(selectedTemplateCategories || []).length === 0 ? (
910
+ <div className="text-sm text-slate-500">No categories.</div>
911
+ ) : (
912
+ (selectedTemplateCategories || []).map((cat, idx) => (
913
+ <div key={idx} className="flex items-start gap-2">
914
+ <div className="mt-2 text-xs font-mono text-slate-500 w-10 shrink-0">{String(idx + 1).padStart(2, '0')}</div>
915
+ <input
916
+ 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'}`}
917
+ value={(cat && typeof cat.label === 'string') ? cat.label : ''}
918
+ onChange={(e) => updateTemplateCategoryLabel(idx, e.target.value)}
919
+ disabled={!(selectedTemplateDetail?.is_default === false)}
920
+ placeholder="Category label..."
921
+ />
922
+ {selectedTemplateDetail?.is_default === false && (
923
+ <>
924
+ <button
925
+ type="button"
926
+ onClick={() => moveTemplateCategory(idx, -1)}
927
+ className="text-slate-600 hover:text-slate-900 px-2 py-2 text-sm"
928
+ title="Move up"
929
+ >
930
+
931
+ </button>
932
+ <button
933
+ type="button"
934
+ onClick={() => moveTemplateCategory(idx, 1)}
935
+ className="text-slate-600 hover:text-slate-900 px-2 py-2 text-sm"
936
+ title="Move down"
937
+ >
938
+
939
+ </button>
940
+ <button
941
+ type="button"
942
+ onClick={() => removeTemplateCategory(idx)}
943
+ className="text-slate-600 hover:text-red-600 px-2 py-2 text-sm"
944
+ title="Remove category"
945
+ >
946
+
947
+ </button>
948
+ </>
949
+ )}
950
+ </div>
951
+ ))
952
+ )}
953
+ {selectedTemplateDetail?.is_default === false && (
954
+ <button
955
+ type="button"
956
+ onClick={addTemplateCategory}
957
+ 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"
958
+ >
959
+ + Add category
960
+ </button>
961
+ )}
962
+ </div>
963
+ </div>
964
+ <div />
965
+ </div>
966
+
967
  <div className="grid grid-cols-2 gap-6">
968
  <div>
969
  <div className="flex items-center justify-between gap-3 mb-2">
frontend/pages/shared_views.py CHANGED
@@ -463,20 +463,53 @@ def get_shared_views_js() -> str:
463
  <div className="space-y-3">
464
  <div className="text-lg font-extrabold text-slate-900 mb-2">Top-down codebook categories</div>
465
  {(() => {
466
- const td = activeResources?.top_down_codes || {};
467
- const order = [
468
- { key: 'symptoms_concerns', label: 'Symptoms/concerns', empty: 'No symptoms/concerns excerpts detected.' },
469
- { key: 'daily_management', label: 'Daily management', empty: 'No daily management excerpts detected.' },
470
- { key: 'barriers_constraints', label: 'Barriers/constraints', empty: 'No barriers/constraints excerpts detected.' },
471
- { key: 'support_resources', label: 'Support/resources', empty: 'No support/resources excerpts detected.' }
472
- ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
473
 
474
  return (
475
  <div className="space-y-3">
476
- {order.map(({ key, label, empty }) => {
477
- const arr = td[key] || [];
 
 
478
  return (
479
- <div key={key} className="bg-slate-50 border border-slate-200 rounded-lg p-3">
480
  <div className="font-extrabold text-slate-900">Code: {label}</div>
481
  {arr.length === 0 ? (
482
  <div className="text-sm text-slate-600 mt-2">{empty}</div>
 
463
  <div className="space-y-3">
464
  <div className="text-lg font-extrabold text-slate-900 mb-2">Top-down codebook categories</div>
465
  {(() => {
466
+ const tdBook = activeResources?.top_down_codebook || null;
467
+ const legacy = activeResources?.top_down_codes || {};
468
+
469
+ const legacyLabels = {
470
+ symptoms_concerns: 'Symptoms/concerns',
471
+ daily_management: 'Daily management',
472
+ barriers_constraints: 'Barriers/constraints',
473
+ support_resources: 'Support/resources'
474
+ };
475
+ const legacyOrder = ['symptoms_concerns', 'daily_management', 'barriers_constraints', 'support_resources'];
476
+
477
+ const categories = (() => {
478
+ if (tdBook && Array.isArray(tdBook.categories)) {
479
+ return tdBook.categories
480
+ .filter((c) => c && typeof c === 'object')
481
+ .map((c) => ({
482
+ category_id: String(c.category_id || ''),
483
+ label: String(c.label || c.category_id || 'Category'),
484
+ items: Array.isArray(c.items) ? c.items : []
485
+ }));
486
+ }
487
+
488
+ const out = [];
489
+ if (legacy && typeof legacy === 'object') {
490
+ const seen = new Set();
491
+ legacyOrder.forEach((key) => {
492
+ if (Array.isArray(legacy[key])) {
493
+ seen.add(key);
494
+ out.push({ category_id: key, label: legacyLabels[key] || key, items: legacy[key] });
495
+ }
496
+ });
497
+ Object.keys(legacy).forEach((key) => {
498
+ if (seen.has(key)) return;
499
+ if (Array.isArray(legacy[key])) out.push({ category_id: key, label: key, items: legacy[key] });
500
+ });
501
+ }
502
+ return out;
503
+ })();
504
 
505
  return (
506
  <div className="space-y-3">
507
+ {categories.map((cat) => {
508
+ const arr = Array.isArray(cat.items) ? cat.items : [];
509
+ const label = cat.label || 'Category';
510
+ const empty = `No ${String(label).toLowerCase()} excerpts detected.`;
511
  return (
512
+ <div key={cat.category_id || label} className="bg-slate-50 border border-slate-200 rounded-lg p-3">
513
  <div className="font-extrabold text-slate-900">Code: {label}</div>
514
  {arr.length === 0 ? (
515
  <div className="text-sm text-slate-600 mt-2">{empty}</div>