Add care experience breakdown pie chart
Browse files- README.md +1 -0
- backend/api/conversation_service.py +28 -1
- docs/capabilities.md +1 -0
- frontend/pages/shared_views.py +69 -0
README.md
CHANGED
|
@@ -98,6 +98,7 @@ Backend listens on `http://localhost:8000`, Gradio on `http://localhost:7860`.
|
|
| 98 |
After the conversation completes, the app runs post-conversation analysis and populates:
|
| 99 |
- Bottom-up findings (emergent themes) with evidence
|
| 100 |
- Top-down coding (care experience rubric + codebook categories) with evidence
|
|
|
|
| 101 |
|
| 102 |
### Analysis frameworks
|
| 103 |
|
|
|
|
| 98 |
After the conversation completes, the app runs post-conversation analysis and populates:
|
| 99 |
- Bottom-up findings (emergent themes) with evidence
|
| 100 |
- Top-down coding (care experience rubric + codebook categories) with evidence
|
| 101 |
+
- Care experience includes a model-rated positive/mixed/negative split (for pie chart summary)
|
| 102 |
|
| 103 |
### Analysis frameworks
|
| 104 |
|
backend/api/conversation_service.py
CHANGED
|
@@ -144,7 +144,7 @@ async def run_resource_agent_analysis(
|
|
| 144 |
)
|
| 145 |
|
| 146 |
schema_version = "9"
|
| 147 |
-
analysis_prompt_version = "
|
| 148 |
|
| 149 |
evidence_catalog: Dict[str, Dict[str, Any]] = {}
|
| 150 |
for message in transcript:
|
|
@@ -243,6 +243,11 @@ async def run_resource_agent_analysis(
|
|
| 243 |
"{\n"
|
| 244 |
f" \"schema_version\": \"{schema_version}\",\n"
|
| 245 |
f" \"analysis_prompt_version\": \"{analysis_prompt_version}\",\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
" \"care_experience\": {\n"
|
| 247 |
" \"positive\": {\n"
|
| 248 |
" \"summary\": string,\n"
|
|
@@ -348,6 +353,26 @@ async def run_resource_agent_analysis(
|
|
| 348 |
await _phase("rubric", "running")
|
| 349 |
rubric_raw = await client.generate(prompt=_rubric_prompt(), system_prompt=rubric_system_prompt, temperature=0.2)
|
| 350 |
rubric = json.loads(rubric_raw) if isinstance(rubric_raw, str) else {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
care_experience = rubric.get("care_experience") or {}
|
| 352 |
for key in ("positive", "mixed", "negative", "neutral"):
|
| 353 |
box = care_experience.get(key)
|
|
@@ -356,6 +381,7 @@ async def run_resource_agent_analysis(
|
|
| 356 |
if normalized is not None:
|
| 357 |
box["confidence"] = normalized
|
| 358 |
await _partial({
|
|
|
|
| 359 |
"care_experience": care_experience,
|
| 360 |
})
|
| 361 |
await _phase("rubric", "complete")
|
|
@@ -397,6 +423,7 @@ async def run_resource_agent_analysis(
|
|
| 397 |
"analysis_prompt_version": analysis_prompt_version,
|
| 398 |
"evidence_catalog": evidence_catalog,
|
| 399 |
"themes": themes,
|
|
|
|
| 400 |
"care_experience": care_experience,
|
| 401 |
"top_down_codebook": td_book or {
|
| 402 |
"template_id": top_down_template_id or "",
|
|
|
|
| 144 |
)
|
| 145 |
|
| 146 |
schema_version = "9"
|
| 147 |
+
analysis_prompt_version = "v5-3pass"
|
| 148 |
|
| 149 |
evidence_catalog: Dict[str, Dict[str, Any]] = {}
|
| 150 |
for message in transcript:
|
|
|
|
| 243 |
"{\n"
|
| 244 |
f" \"schema_version\": \"{schema_version}\",\n"
|
| 245 |
f" \"analysis_prompt_version\": \"{analysis_prompt_version}\",\n"
|
| 246 |
+
" \"care_experience_breakdown\": {\n"
|
| 247 |
+
" \"positive\": number, // 0-100, sums to 100 with mixed + negative\n"
|
| 248 |
+
" \"mixed\": number,\n"
|
| 249 |
+
" \"negative\": number\n"
|
| 250 |
+
" },\n"
|
| 251 |
" \"care_experience\": {\n"
|
| 252 |
" \"positive\": {\n"
|
| 253 |
" \"summary\": string,\n"
|
|
|
|
| 353 |
await _phase("rubric", "running")
|
| 354 |
rubric_raw = await client.generate(prompt=_rubric_prompt(), system_prompt=rubric_system_prompt, temperature=0.2)
|
| 355 |
rubric = json.loads(rubric_raw) if isinstance(rubric_raw, str) else {}
|
| 356 |
+
breakdown = rubric.get("care_experience_breakdown") or {}
|
| 357 |
+
breakdown_normalized = {}
|
| 358 |
+
if isinstance(breakdown, dict):
|
| 359 |
+
for key in ("positive", "mixed", "negative"):
|
| 360 |
+
value = breakdown.get(key)
|
| 361 |
+
try:
|
| 362 |
+
num = float(value)
|
| 363 |
+
except (TypeError, ValueError):
|
| 364 |
+
num = None
|
| 365 |
+
if num is not None:
|
| 366 |
+
if num <= 1.0:
|
| 367 |
+
num = num * 100.0
|
| 368 |
+
breakdown_normalized[key] = max(0.0, min(100.0, num))
|
| 369 |
+
if breakdown_normalized:
|
| 370 |
+
total = sum(breakdown_normalized.values())
|
| 371 |
+
if total > 0:
|
| 372 |
+
for key in breakdown_normalized:
|
| 373 |
+
breakdown_normalized[key] = round((breakdown_normalized[key] / total) * 100.0, 1)
|
| 374 |
+
else:
|
| 375 |
+
breakdown_normalized = None
|
| 376 |
care_experience = rubric.get("care_experience") or {}
|
| 377 |
for key in ("positive", "mixed", "negative", "neutral"):
|
| 378 |
box = care_experience.get(key)
|
|
|
|
| 381 |
if normalized is not None:
|
| 382 |
box["confidence"] = normalized
|
| 383 |
await _partial({
|
| 384 |
+
"care_experience_breakdown": breakdown_normalized,
|
| 385 |
"care_experience": care_experience,
|
| 386 |
})
|
| 387 |
await _phase("rubric", "complete")
|
|
|
|
| 423 |
"analysis_prompt_version": analysis_prompt_version,
|
| 424 |
"evidence_catalog": evidence_catalog,
|
| 425 |
"themes": themes,
|
| 426 |
+
"care_experience_breakdown": breakdown_normalized,
|
| 427 |
"care_experience": care_experience,
|
| 428 |
"top_down_codebook": td_book or {
|
| 429 |
"template_id": top_down_template_id or "",
|
docs/capabilities.md
CHANGED
|
@@ -88,6 +88,7 @@ Analysis runs post-conversation (or on uploaded text) and produces evidence-back
|
|
| 88 |
|
| 89 |
- **Bottom-up findings** (emergent themes)
|
| 90 |
- **Care experience rubric** (fixed buckets: positive / mixed / negative / neutral)
|
|
|
|
| 91 |
- **Top-down codebook categories** (template-driven)
|
| 92 |
|
| 93 |
### Analysis execution (3-pass)
|
|
|
|
| 88 |
|
| 89 |
- **Bottom-up findings** (emergent themes)
|
| 90 |
- **Care experience rubric** (fixed buckets: positive / mixed / negative / neutral)
|
| 91 |
+
- Includes a model-rated split across positive/mixed/negative (for a pie chart summary)
|
| 92 |
- **Top-down codebook categories** (template-driven)
|
| 93 |
|
| 94 |
### Analysis execution (3-pass)
|
frontend/pages/shared_views.py
CHANGED
|
@@ -443,6 +443,34 @@ def get_shared_views_js() -> str:
|
|
| 443 |
} = props;
|
| 444 |
|
| 445 |
const care = activeResources?.care_experience || null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
if (!care || typeof care !== 'object') {
|
| 447 |
return (
|
| 448 |
<p className="text-slate-400 text-center py-8 text-sm">
|
|
@@ -459,6 +487,47 @@ def get_shared_views_js() -> str:
|
|
| 459 |
const neutral = care.neutral || null;
|
| 460 |
return (
|
| 461 |
<>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
{renderCareBox('Positive', 'positive', positive, 'No positive insights detected.')}
|
| 463 |
{renderCareBox('Mixed / Tradeoffs', 'mixed', mixed, 'No mixed/tradeoff insights detected.')}
|
| 464 |
{renderCareBox('Negative', 'negative', negative, 'No negative insights detected.')}
|
|
|
|
| 443 |
} = props;
|
| 444 |
|
| 445 |
const care = activeResources?.care_experience || null;
|
| 446 |
+
const breakdownRaw = activeResources?.care_experience_breakdown || null;
|
| 447 |
+
const normalizeBreakdown = (raw) => {
|
| 448 |
+
if (!raw || typeof raw !== 'object') return null;
|
| 449 |
+
const toNum = (v) => {
|
| 450 |
+
if (typeof v === 'number') return v;
|
| 451 |
+
const parsed = parseFloat(String(v));
|
| 452 |
+
return Number.isFinite(parsed) ? parsed : null;
|
| 453 |
+
};
|
| 454 |
+
const out = {
|
| 455 |
+
positive: toNum(raw.positive),
|
| 456 |
+
mixed: toNum(raw.mixed),
|
| 457 |
+
negative: toNum(raw.negative)
|
| 458 |
+
};
|
| 459 |
+
if (out.positive == null && out.mixed == null && out.negative == null) return null;
|
| 460 |
+
Object.keys(out).forEach((k) => {
|
| 461 |
+
const v = out[k];
|
| 462 |
+
if (v == null) return;
|
| 463 |
+
out[k] = v <= 1 ? v * 100 : v;
|
| 464 |
+
});
|
| 465 |
+
const total = (out.positive || 0) + (out.mixed || 0) + (out.negative || 0);
|
| 466 |
+
if (!total) return null;
|
| 467 |
+
return {
|
| 468 |
+
positive: Math.round(((out.positive || 0) / total) * 1000) / 10,
|
| 469 |
+
mixed: Math.round(((out.mixed || 0) / total) * 1000) / 10,
|
| 470 |
+
negative: Math.round(((out.negative || 0) / total) * 1000) / 10
|
| 471 |
+
};
|
| 472 |
+
};
|
| 473 |
+
const breakdown = normalizeBreakdown(breakdownRaw);
|
| 474 |
if (!care || typeof care !== 'object') {
|
| 475 |
return (
|
| 476 |
<p className="text-slate-400 text-center py-8 text-sm">
|
|
|
|
| 487 |
const neutral = care.neutral || null;
|
| 488 |
return (
|
| 489 |
<>
|
| 490 |
+
{breakdown && (
|
| 491 |
+
<div className="bg-slate-50 border border-slate-200 rounded-lg p-3">
|
| 492 |
+
<div className="flex items-center justify-between gap-2 mb-2">
|
| 493 |
+
<div className="text-xs font-semibold text-slate-600">Care experience split</div>
|
| 494 |
+
<div className="relative group">
|
| 495 |
+
<div className="h-5 w-5 rounded-full border border-slate-300 text-slate-500 flex items-center justify-center text-[11px] font-semibold bg-white cursor-help">
|
| 496 |
+
i
|
| 497 |
+
</div>
|
| 498 |
+
<div className="pointer-events-none absolute right-0 top-6 z-10 w-64 rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs text-slate-700 shadow-lg opacity-0 transition-opacity duration-150 group-hover:opacity-100">
|
| 499 |
+
This pie chart reflects the model’s own judgment of how strongly the conversation supports positive, mixed, or negative experiences. It is a weighted perception (not a simple count), so one strong negative can outweigh many mild positives.
|
| 500 |
+
</div>
|
| 501 |
+
</div>
|
| 502 |
+
</div>
|
| 503 |
+
<div className="flex items-center gap-4">
|
| 504 |
+
<div
|
| 505 |
+
className="h-20 w-20 rounded-full border border-slate-200"
|
| 506 |
+
style={{
|
| 507 |
+
background: `conic-gradient(#16a34a 0% ${breakdown.positive}%, #f59e0b ${breakdown.positive}% ${breakdown.positive + breakdown.mixed}%, #ef4444 ${breakdown.positive + breakdown.mixed}% 100%)`
|
| 508 |
+
}}
|
| 509 |
+
title={`Positive ${breakdown.positive}%, Mixed ${breakdown.mixed}%, Negative ${breakdown.negative}%`}
|
| 510 |
+
/>
|
| 511 |
+
<div className="space-y-1 text-xs text-slate-700">
|
| 512 |
+
<div className="flex items-center gap-2">
|
| 513 |
+
<span className="inline-block h-2.5 w-2.5 rounded-full bg-green-600" />
|
| 514 |
+
<span>Positive</span>
|
| 515 |
+
<span className="ml-auto font-semibold">{breakdown.positive}%</span>
|
| 516 |
+
</div>
|
| 517 |
+
<div className="flex items-center gap-2">
|
| 518 |
+
<span className="inline-block h-2.5 w-2.5 rounded-full bg-amber-500" />
|
| 519 |
+
<span>Mixed</span>
|
| 520 |
+
<span className="ml-auto font-semibold">{breakdown.mixed}%</span>
|
| 521 |
+
</div>
|
| 522 |
+
<div className="flex items-center gap-2">
|
| 523 |
+
<span className="inline-block h-2.5 w-2.5 rounded-full bg-red-500" />
|
| 524 |
+
<span>Negative</span>
|
| 525 |
+
<span className="ml-auto font-semibold">{breakdown.negative}%</span>
|
| 526 |
+
</div>
|
| 527 |
+
</div>
|
| 528 |
+
</div>
|
| 529 |
+
</div>
|
| 530 |
+
)}
|
| 531 |
{renderCareBox('Positive', 'positive', positive, 'No positive insights detected.')}
|
| 532 |
{renderCareBox('Mixed / Tradeoffs', 'mixed', mixed, 'No mixed/tradeoff insights detected.')}
|
| 533 |
{renderCareBox('Negative', 'negative', negative, 'No negative insights detected.')}
|