personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

home: cut Pulse skills to owner-wide storage (Lode B)

Rewrites _collect_skills() to read journal/skills/ directly; drops
the per-facet walk and the legacy skill_generated flag. Returns the
new shape (slug, description, category, confidence, status, facets,
first_seen, last_seen), excludes retired patterns and patterns
without a profile markdown, sorts by confidence desc then last_seen.

Threads status through both the Jinja card block and the
refreshSkills() JS builder, appending " (dormant)" to dormant
cards. test_home_skills.py migrates to owner-wide fixtures with
dormant-visible and retired-hidden coverage.

Part of Lode B of the skills-observer-editor refactor.

+339 -182
+87 -72
apps/home/routes.py
··· 19 19 20 20 from convey.apps import _resolve_attention 21 21 from convey.bridge import get_cached_state 22 + from think import skills as think_skills 22 23 from think.awareness import get_current 23 24 from think.capture_health import get_capture_health 24 25 from think.facets import get_enabled_facets, get_facets ··· 1112 1113 1113 1114 1114 1115 def _collect_skills() -> list[dict[str, Any]]: 1115 - """Collect mature skills from all enabled facets.""" 1116 + """Collect owner-wide skill profiles for the Pulse view.""" 1116 1117 try: 1117 - journal = Path(get_journal()) 1118 1118 state = _load_skills_state() 1119 1119 last_seen = state.get("skills_last_seen") 1120 1120 last_seen_dt = datetime.fromisoformat(last_seen) if last_seen else None 1121 1121 1122 - skills = [] 1123 - for facet_name in get_enabled_facets(): 1124 - skills_dir = journal / "facets" / facet_name / "skills" 1125 - if not skills_dir.exists(): 1126 - continue 1127 - 1128 - # Read patterns.jsonl for mature skills (skill_generated: true) 1129 - patterns_path = skills_dir / "patterns.jsonl" 1130 - if not patterns_path.exists(): 1131 - continue 1132 - 1133 - mature_ids = set() 1122 + skills: list[dict[str, Any]] = [] 1123 + for pattern in think_skills.load_patterns(): 1134 1124 try: 1135 - with open(patterns_path, encoding="utf-8") as f: 1136 - for line in f: 1137 - line = line.strip() 1138 - if not line: 1139 - continue 1140 - try: 1141 - pattern = json.loads(line) 1142 - if pattern.get("skill_generated"): 1143 - mature_ids.add(pattern.get("id", "")) 1144 - except json.JSONDecodeError: 1145 - continue 1146 - except OSError: 1147 - continue 1125 + slug = str(pattern.get("slug") or "").strip() 1126 + status = str(pattern.get("status") or "").strip() or "emerging" 1127 + if not slug or status == "retired": 1128 + continue 1148 1129 1149 - for skill_id in sorted(mature_ids): 1150 - skill_path = skills_dir / f"{skill_id}.md" 1151 - if not skill_path.exists(): 1130 + profile_markdown = think_skills.load_profile(slug) 1131 + if profile_markdown is None: 1152 1132 continue 1153 - try: 1154 - post = frontmatter.load(str(skill_path)) 1155 - meta = post.metadata 1156 1133 1157 - summary = meta.get("activity_type", "") 1158 - typical_time = meta.get("typical_time", "") 1159 - if typical_time: 1160 - summary = ( 1161 - f"{summary} · {typical_time}" if summary else typical_time 1162 - ) 1163 - 1164 - observations = meta.get("observations", 0) 1165 - last_seen_str = meta.get("last_seen", "") 1166 - 1167 - try: 1168 - mtime_dt = datetime.fromtimestamp(skill_path.stat().st_mtime) 1169 - except OSError: 1170 - mtime_dt = None 1134 + post = frontmatter.loads(profile_markdown) 1135 + meta = post.metadata 1136 + observations = [ 1137 + observation 1138 + for observation in pattern.get("observations", []) 1139 + if isinstance(observation, dict) 1140 + ] 1141 + observation_times = sorted( 1142 + str(observation.get("recorded_at") or observation.get("day") or "") 1143 + for observation in observations 1144 + if observation.get("recorded_at") or observation.get("day") 1145 + ) 1146 + facets = sorted( 1147 + { 1148 + str(item).strip() 1149 + for item in pattern.get("facets_touched", []) 1150 + if str(item).strip() 1151 + } 1152 + or { 1153 + str(observation.get("facet") or "").strip() 1154 + for observation in observations 1155 + if str(observation.get("facet") or "").strip() 1156 + } 1157 + ) 1158 + confidence = meta.get("confidence", 0.0) 1159 + if isinstance(confidence, (int, float)) and not isinstance( 1160 + confidence, bool 1161 + ): 1162 + confidence_value = float(confidence) 1163 + else: 1164 + confidence_value = 0.0 1171 1165 1172 - seen = ( 1173 - last_seen_dt is not None 1174 - and mtime_dt is not None 1175 - and mtime_dt <= last_seen_dt 1166 + try: 1167 + profile_mtime = datetime.fromtimestamp( 1168 + think_skills.profile_path(slug).stat().st_mtime 1176 1169 ) 1170 + except OSError: 1171 + profile_mtime = None 1177 1172 1178 - skills.append( 1179 - { 1180 - "id": skill_id, 1181 - "name": meta.get("name", skill_id), 1182 - "facet": facet_name, 1183 - "summary": summary, 1184 - "observations": observations, 1185 - "last_seen": last_seen_str, 1186 - "content": post.content, 1187 - "seen": seen, 1188 - } 1189 - ) 1190 - except Exception: 1191 - logger.warning( 1192 - "home: failed to load skill %s/%s", 1193 - facet_name, 1194 - skill_id, 1195 - exc_info=True, 1196 - ) 1197 - continue 1173 + seen = ( 1174 + last_seen_dt is not None 1175 + and profile_mtime is not None 1176 + and profile_mtime <= last_seen_dt 1177 + ) 1178 + 1179 + skills.append( 1180 + { 1181 + "id": slug, 1182 + "slug": slug, 1183 + "name": ( 1184 + str(meta.get("display_name") or "").strip() 1185 + or str(pattern.get("name") or "").strip() 1186 + or slug 1187 + ), 1188 + "description": str(meta.get("description") or "").strip(), 1189 + "category": str(meta.get("category") or "").strip(), 1190 + "confidence": confidence_value, 1191 + "status": status, 1192 + "facets": facets, 1193 + "observations": len(observations), 1194 + "first_seen": observation_times[0] if observation_times else "", 1195 + "last_seen": observation_times[-1] if observation_times else "", 1196 + "content": post.content, 1197 + "seen": seen, 1198 + } 1199 + ) 1200 + except Exception: 1201 + logger.warning( 1202 + "home: failed to load skill %s", 1203 + pattern.get("slug"), 1204 + exc_info=True, 1205 + ) 1206 + continue 1198 1207 1199 - skills.sort(key=lambda s: s.get("last_seen", ""), reverse=True) 1208 + skills.sort( 1209 + key=lambda skill: ( 1210 + float(skill.get("confidence") or 0.0), 1211 + str(skill.get("last_seen") or ""), 1212 + ), 1213 + reverse=True, 1214 + ) 1200 1215 return skills 1201 1216 except Exception: 1202 1217 logger.warning("home: failed to collect skills", exc_info=True)
+8 -7
apps/home/workspace.html
··· 1172 1172 {% for skill in skills %} 1173 1173 <div class="pulse-skill-item{{ ' is-new' if not skill.seen else '' }}" role="button" tabindex="0" data-skill-click="true" data-skill-id="{{ skill.id }}"> 1174 1174 <span> 1175 - <span class="pulse-skill-name">{{ skill.name }}</span> 1175 + <span class="pulse-skill-name">{{ skill.name }}{{ ' (dormant)' if skill.status == 'dormant' else '' }}</span> 1176 1176 <span class="pulse-skill-badge">{{ skill.observations }} obs</span> 1177 - <span class="pulse-skill-facet">{{ skill.facet }}</span> 1178 - {% if skill.summary %} 1179 - <span class="pulse-skill-summary">— {{ skill.summary }}</span> 1177 + <span class="pulse-skill-facet">{{ skill.facets[0] if skill.facets else '' }}</span> 1178 + {% if skill.description %} 1179 + <span class="pulse-skill-summary">— {{ skill.description }}</span> 1180 1180 {% endif %} 1181 1181 </span> 1182 1182 </div> ··· 1692 1692 html += '<div class="pulse-section-body">'; 1693 1693 html += '<div class="pulse-skills-list">'; 1694 1694 skills.forEach(function(s) { 1695 - var name = esc(s.name); 1695 + var name = esc(s.name + (s.status === 'dormant' ? ' (dormant)' : '')); 1696 + var facet = esc((s.facets && s.facets.length > 0) ? s.facets[0] : ''); 1696 1697 var newClass = s.seen ? '' : ' is-new'; 1697 1698 html += '<div class="pulse-skill-item' + newClass + '" role="button" tabindex="0" data-skill-click="true" data-skill-id="' + esc(s.id) + '"><span>'; 1698 1699 html += '<span class="pulse-skill-name">' + name + '</span> '; 1699 1700 html += '<span class="pulse-skill-badge">' + (s.observations || 0) + ' obs</span> '; 1700 - html += '<span class="pulse-skill-facet">' + esc(s.facet) + '</span>'; 1701 - if (s.summary) html += ' <span class="pulse-skill-summary">— ' + esc(s.summary) + '</span>'; 1701 + html += '<span class="pulse-skill-facet">' + facet + '</span>'; 1702 + if (s.description) html += ' <span class="pulse-skill-summary">— ' + esc(s.description) + '</span>'; 1702 1703 html += '</span></div>'; 1703 1704 html += '<div class="pulse-skill-detail" id="skill-detail-' + esc(s.id) + '" style="display: none;"></div>'; 1704 1705 });
+244 -103
tests/test_home_skills.py
··· 3 3 4 4 """Tests for home pulse skill surfacing.""" 5 5 6 + from __future__ import annotations 7 + 6 8 import json 7 9 from datetime import datetime, timedelta 8 10 ··· 26 28 return app.test_client() 27 29 28 30 29 - def _write_skill_fixtures(tmp_path, facet_name, patterns, skill_files): 30 - """Write patterns.jsonl and skill .md files for a facet. 31 - 32 - Parameters 33 - ---------- 34 - patterns : list[dict] 35 - Each dict is one line in patterns.jsonl. 36 - skill_files : dict[str, str] 37 - Mapping of {slug: markdown_content} for skill files. 38 - """ 39 - skills_dir = tmp_path / "facets" / facet_name / "skills" 31 + def _write_skill_fixtures(tmp_path, patterns, skill_files): 32 + """Write owner-wide skill patterns and markdown profiles.""" 33 + skills_dir = tmp_path / "skills" 40 34 skills_dir.mkdir(parents=True, exist_ok=True) 35 + lines = [json.dumps(pattern) for pattern in patterns] 36 + (skills_dir / "patterns.jsonl").write_text( 37 + "\n".join(lines) + ("\n" if lines else ""), 38 + encoding="utf-8", 39 + ) 40 + for slug, content in skill_files.items(): 41 + (skills_dir / f"{slug}.md").write_text(content, encoding="utf-8") 41 42 42 - # Write facet.json so get_enabled_facets() finds this facet 43 - facet_dir = tmp_path / "facets" / facet_name 44 - (facet_dir / "facet.json").write_text( 45 - json.dumps( 43 + 44 + def _pattern( 45 + *, 46 + slug: str, 47 + name: str, 48 + status: str = "mature", 49 + observations: list[dict] | None = None, 50 + ) -> dict: 51 + rows = observations or [ 52 + { 53 + "day": "2026-04-10", 54 + "facet": "work", 55 + "activity_ids": ["act_1"], 56 + "notes": "", 57 + "recorded_at": "2026-04-10T09:15:00Z", 58 + } 59 + ] 60 + return { 61 + "slug": slug, 62 + "name": name, 63 + "status": status, 64 + "observations": rows, 65 + "facets_touched": sorted( 46 66 { 47 - "title": facet_name.title(), 48 - "description": "", 49 - "color": "#000", 50 - "emoji": "📁", 67 + str(observation.get("facet") or "") 68 + for observation in rows 69 + if observation.get("facet") 51 70 } 52 71 ), 53 - encoding="utf-8", 54 - ) 72 + "first_seen": rows[0]["day"], 73 + "last_seen": rows[-1]["day"], 74 + "needs_profile": False, 75 + "needs_refresh": False, 76 + "profile_generated_at": "2026-04-11T10:00:00Z", 77 + "created_at": "2026-04-10T09:20:00Z", 78 + "updated_at": "2026-04-11T10:00:00Z", 79 + } 55 80 56 - # Write patterns.jsonl 57 - lines = [json.dumps(p) for p in patterns] 58 - (skills_dir / "patterns.jsonl").write_text( 59 - "\n".join(lines) + "\n", encoding="utf-8" 60 - ) 61 81 62 - # Write skill markdown files 63 - for slug, content in skill_files.items(): 64 - (skills_dir / f"{slug}.md").write_text(content, encoding="utf-8") 82 + def _profile_markdown( 83 + *, 84 + name: str, 85 + display_name: str, 86 + description: str, 87 + category: str = "coordination", 88 + confidence: float = 0.7, 89 + body: str = "## Overview\n\nProfile body.", 90 + ) -> str: 91 + return f"""--- 92 + name: "{name}" 93 + display_name: "{display_name}" 94 + description: "{description}" 95 + category: "{category}" 96 + confidence: {confidence} 97 + --- 98 + 99 + {body} 100 + """ 65 101 66 102 67 103 def test_collect_skills_no_facets(monkeypatch, tmp_path): 68 - """No facets directory yields empty skills list.""" 104 + """No owner-wide skills directory yields an empty list.""" 69 105 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 70 106 71 107 assert _collect_skills() == [] 72 108 73 109 74 - def test_collect_skills_no_skills_dir(monkeypatch, tmp_path): 75 - """Facet exists but has no skills directory.""" 110 + def test_collect_skills_no_patterns(monkeypatch, tmp_path): 111 + """An empty owner-wide skills directory yields an empty list.""" 76 112 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 77 113 78 - facet_dir = tmp_path / "facets" / "work" 79 - facet_dir.mkdir(parents=True) 80 - (facet_dir / "facet.json").write_text( 81 - json.dumps( 82 - {"title": "Work", "description": "", "color": "#000", "emoji": "💼"} 83 - ), 84 - encoding="utf-8", 85 - ) 114 + (tmp_path / "skills").mkdir(parents=True) 86 115 87 116 assert _collect_skills() == [] 88 117 89 118 90 - def test_collect_skills_with_mature_skill(monkeypatch, tmp_path): 91 - """Mature skill (skill_generated: true) is collected with correct fields.""" 119 + def test_collect_skills_with_owner_wide_profile(monkeypatch, tmp_path): 120 + """Pulse collects owner-wide profiles with the new payload shape.""" 92 121 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 93 122 94 - skill_md = """--- 95 - name: Morning Standup 96 - activity_type: meeting 97 - facet: work 98 - observations: 5 99 - first_seen: "2026-03-01T09:00:00" 100 - last_seen: "2026-04-10T09:15:00" 101 - typical_duration: 15m 102 - typical_time: "9:00 AM" 103 - key_entities: 104 - - Engineering Team 105 - --- 106 - 107 - ## when this happens 108 - 109 - Daily morning standup with the engineering team. 110 - 111 - ## what the owner does 112 - 113 - Gives status updates and listens to blockers. 114 - """ 115 - 116 123 _write_skill_fixtures( 117 124 tmp_path, 118 - "work", 119 - [{"id": "morning-standup", "skill_generated": True, "observation_count": 5}], 120 - {"morning-standup": skill_md}, 125 + [ 126 + _pattern( 127 + slug="morning-standup", 128 + name="Standup Pattern", 129 + observations=[ 130 + { 131 + "day": "2026-03-01", 132 + "facet": "work", 133 + "activity_ids": ["act_1"], 134 + "notes": "", 135 + "recorded_at": "2026-03-01T09:00:00Z", 136 + }, 137 + { 138 + "day": "2026-04-10", 139 + "facet": "work", 140 + "activity_ids": ["act_2"], 141 + "notes": "", 142 + "recorded_at": "2026-04-10T09:15:00Z", 143 + }, 144 + ], 145 + ) 146 + ], 147 + { 148 + "morning-standup": _profile_markdown( 149 + name="morning-standup", 150 + display_name="Morning Standup", 151 + description="Daily engineering sync for blockers and updates.", 152 + category="coordination", 153 + confidence=0.9, 154 + body="## when this happens\n\nDaily morning standup with the engineering team.", 155 + ) 156 + }, 121 157 ) 122 158 123 159 skills = _collect_skills() 124 160 125 161 assert len(skills) == 1 126 162 assert skills[0]["id"] == "morning-standup" 163 + assert skills[0]["slug"] == "morning-standup" 127 164 assert skills[0]["name"] == "Morning Standup" 128 - assert skills[0]["facet"] == "work" 129 - assert skills[0]["observations"] == 5 130 - assert "meeting" in skills[0]["summary"] 131 - assert "9:00 AM" in skills[0]["summary"] 165 + assert ( 166 + skills[0]["description"] == "Daily engineering sync for blockers and updates." 167 + ) 168 + assert skills[0]["category"] == "coordination" 169 + assert skills[0]["confidence"] == 0.9 170 + assert skills[0]["status"] == "mature" 171 + assert skills[0]["facets"] == ["work"] 172 + assert skills[0]["observations"] == 2 173 + assert skills[0]["first_seen"] == "2026-03-01T09:00:00Z" 174 + assert skills[0]["last_seen"] == "2026-04-10T09:15:00Z" 132 175 assert "when this happens" in skills[0]["content"] 133 176 assert skills[0]["seen"] is False 134 177 135 178 136 - def test_collect_skills_immature_excluded(monkeypatch, tmp_path): 137 - """Patterns with skill_generated: false are excluded.""" 179 + def test_collect_skills_hides_pattern_without_profile(monkeypatch, tmp_path): 180 + """Observer-only patterns stay hidden until a profile exists.""" 138 181 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 139 182 140 - skill_md = """--- 141 - name: Random Chat 142 - activity_type: conversation 143 - observations: 1 144 - --- 145 - 146 - Some content. 147 - """ 148 - 149 183 _write_skill_fixtures( 150 184 tmp_path, 151 - "work", 152 - [{"id": "random-chat", "skill_generated": False, "observation_count": 1}], 153 - {"random-chat": skill_md}, 185 + [_pattern(slug="random-chat", name="Random Chat")], 186 + {}, 154 187 ) 155 188 156 189 assert _collect_skills() == [] 157 190 158 191 159 192 def test_collect_skills_seen_flag(monkeypatch, tmp_path): 160 - """Skills modified before skills_last_seen are marked seen.""" 193 + """Profiles older than the last seen marker are marked seen.""" 161 194 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 162 195 163 - skill_md = """--- 164 - name: Daily Review 165 - activity_type: review 166 - observations: 3 167 - last_seen: "2026-04-09T17:00:00" 168 - --- 169 - 170 - Review content. 171 - """ 172 - 173 196 _write_skill_fixtures( 174 197 tmp_path, 175 - "work", 176 - [{"id": "daily-review", "skill_generated": True, "observation_count": 3}], 177 - {"daily-review": skill_md}, 198 + [_pattern(slug="daily-review", name="Daily Review")], 199 + { 200 + "daily-review": _profile_markdown( 201 + name="daily-review", 202 + display_name="Daily Review", 203 + description="End-of-day review habit.", 204 + body="Review content.", 205 + ) 206 + }, 178 207 ) 179 208 180 - # Mark as seen AFTER the file was written (file mtime < skills_last_seen) 181 209 _save_skills_state( 182 210 {"skills_last_seen": (datetime.now() + timedelta(minutes=5)).isoformat()} 183 211 ) ··· 188 216 assert skills[0]["seen"] is True 189 217 190 218 219 + def test_collect_skills_shows_dormant(monkeypatch, tmp_path): 220 + """Dormant skills stay visible in Pulse.""" 221 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 222 + 223 + _write_skill_fixtures( 224 + tmp_path, 225 + [_pattern(slug="deep-work", name="Deep Work", status="dormant")], 226 + { 227 + "deep-work": _profile_markdown( 228 + name="deep-work", 229 + display_name="Deep Work", 230 + description="Focused solo execution work.", 231 + ) 232 + }, 233 + ) 234 + 235 + skills = _collect_skills() 236 + 237 + assert len(skills) == 1 238 + assert skills[0]["status"] == "dormant" 239 + 240 + 241 + def test_collect_skills_hides_retired(monkeypatch, tmp_path): 242 + """Retired skills are excluded from Pulse.""" 243 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 244 + 245 + _write_skill_fixtures( 246 + tmp_path, 247 + [_pattern(slug="legacy", name="Legacy Skill", status="retired")], 248 + { 249 + "legacy": _profile_markdown( 250 + name="legacy", 251 + display_name="Legacy Skill", 252 + description="Old profile.", 253 + ) 254 + }, 255 + ) 256 + 257 + assert _collect_skills() == [] 258 + 259 + 260 + def test_collect_skills_sorts_by_confidence_then_last_seen(monkeypatch, tmp_path): 261 + """Skills sort by confidence first, then recency.""" 262 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 263 + 264 + _write_skill_fixtures( 265 + tmp_path, 266 + [ 267 + _pattern(slug="high-confidence", name="High Confidence"), 268 + _pattern( 269 + slug="recent-mid", 270 + name="Recent Mid", 271 + observations=[ 272 + { 273 + "day": "2026-04-12", 274 + "facet": "work", 275 + "activity_ids": ["act_2"], 276 + "notes": "", 277 + "recorded_at": "2026-04-12T08:00:00Z", 278 + } 279 + ], 280 + ), 281 + _pattern( 282 + slug="older-mid", 283 + name="Older Mid", 284 + observations=[ 285 + { 286 + "day": "2026-04-05", 287 + "facet": "work", 288 + "activity_ids": ["act_3"], 289 + "notes": "", 290 + "recorded_at": "2026-04-05T08:00:00Z", 291 + } 292 + ], 293 + ), 294 + ], 295 + { 296 + "high-confidence": _profile_markdown( 297 + name="high-confidence", 298 + display_name="High Confidence", 299 + description="High confidence skill.", 300 + confidence=0.95, 301 + ), 302 + "recent-mid": _profile_markdown( 303 + name="recent-mid", 304 + display_name="Recent Mid", 305 + description="Recent mid confidence skill.", 306 + confidence=0.6, 307 + ), 308 + "older-mid": _profile_markdown( 309 + name="older-mid", 310 + display_name="Older Mid", 311 + description="Older mid confidence skill.", 312 + confidence=0.6, 313 + ), 314 + }, 315 + ) 316 + 317 + skills = _collect_skills() 318 + 319 + assert [skill["id"] for skill in skills] == [ 320 + "high-confidence", 321 + "recent-mid", 322 + "older-mid", 323 + ] 324 + 325 + 191 326 def test_api_skills_seen(monkeypatch, tmp_path, home_client): 192 327 """Seen endpoint persists the skills seen timestamp.""" 193 328 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) ··· 223 358 lambda: [ 224 359 { 225 360 "id": "morning-standup", 361 + "slug": "morning-standup", 226 362 "name": "Morning Standup", 227 - "facet": "work", 228 - "summary": "meeting · 9:00 AM", 363 + "description": "Daily engineering sync for blockers and updates.", 364 + "category": "coordination", 365 + "confidence": 0.9, 366 + "status": "mature", 367 + "facets": ["work"], 229 368 "observations": 5, 230 - "last_seen": "2026-04-10T09:15:00", 369 + "first_seen": "2026-03-01T09:00:00Z", 370 + "last_seen": "2026-04-10T09:15:00Z", 231 371 "content": "# Standup\n\nDaily standup.", 232 372 "seen": False, 233 373 } ··· 240 380 data = resp.get_json() 241 381 assert "skills" in data 242 382 assert data["skills"][0]["name"] == "Morning Standup" 383 + assert data["skills"][0]["facets"] == ["work"] 243 384 assert "skills_summary" in data 244 385 assert "skills_content" in data 245 386 assert "morning-standup" in data["skills_content"]