personal memory agent
0
fork

Configure Feed

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

talent: retire legacy skills observer (Lode B)

Deletes talent/skills.{md,py} and tests/test_skills_hook.py. The
new apps/skills/talent pair (skill_observer + skill_editor) plus
apps/skills/call.py now owns the full skills pipeline end to end.

tests/test_journal_skill.py is unrelated (tests the journal SKILL.md
symlinks) and is preserved.

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

-1082
-32
talent/skills.md
··· 1 - { 2 - "type": "generate", 3 - "title": "Skill Observer", 4 - "description": "Detects recurring activity patterns and generates structured skill documents describing what the owner does, how, and why.", 5 - "hook": {"pre": "skills", "post": "skills"}, 6 - "schedule": "activity", 7 - "activities": ["*"], 8 - "priority": 90, 9 - "output": "json", 10 - "load": {"transcripts": false, "percepts": false, "talents": false} 11 - } 12 - 13 - You are analyzing recurring activity patterns to identify and document the owner's skills. 14 - 15 - $skill_instruction 16 - 17 - $pattern_context 18 - 19 - $previous_outputs 20 - 21 - Return JSON with these fields: 22 - - `skill_name` (string) 23 - - `slug` (string, must match the canonical slug provided in context) 24 - - `category` (string) 25 - - `description` (string, 1-2 sentences) 26 - - `how` (string, one paragraph) 27 - - `why` (string, one paragraph) 28 - - `tools` (list of strings) 29 - - `collaborators` (list of strings) 30 - - `confidence` (float from 0.0 to 1.0) 31 - 32 - Stay grounded in the supplied evidence. Do not fabricate tools, collaborators, or behaviors that are not supported by the observations and prior outputs.
-547
talent/skills.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - from __future__ import annotations 5 - 6 - import json 7 - import logging 8 - import re 9 - from datetime import datetime, timezone 10 - from pathlib import Path 11 - 12 - from think.activities import get_activity_output_path 13 - from think.entities.core import atomic_write 14 - from think.identity import update_identity_section 15 - from think.utils import get_journal 16 - 17 - logger = logging.getLogger(__name__) 18 - 19 - STOPWORDS = frozenset( 20 - { 21 - "a", 22 - "an", 23 - "the", 24 - "and", 25 - "or", 26 - "but", 27 - "in", 28 - "on", 29 - "at", 30 - "to", 31 - "for", 32 - "of", 33 - "with", 34 - "by", 35 - "from", 36 - "as", 37 - "is", 38 - "was", 39 - "are", 40 - "were", 41 - "be", 42 - "been", 43 - "being", 44 - "have", 45 - "has", 46 - "had", 47 - "do", 48 - "does", 49 - "did", 50 - "will", 51 - "would", 52 - "could", 53 - "should", 54 - "may", 55 - "might", 56 - "shall", 57 - "can", 58 - "need", 59 - "not", 60 - "no", 61 - "nor", 62 - "so", 63 - "if", 64 - "then", 65 - "than", 66 - "too", 67 - "very", 68 - "just", 69 - "about", 70 - "above", 71 - "after", 72 - "before", 73 - "between", 74 - "into", 75 - "through", 76 - "during", 77 - "out", 78 - "off", 79 - "over", 80 - "under", 81 - "again", 82 - "further", 83 - "once", 84 - "here", 85 - "there", 86 - "when", 87 - "where", 88 - "why", 89 - "how", 90 - "all", 91 - "each", 92 - "every", 93 - "both", 94 - "few", 95 - "more", 96 - "most", 97 - "other", 98 - "some", 99 - "such", 100 - "only", 101 - "own", 102 - "same", 103 - "also", 104 - "that", 105 - "this", 106 - "these", 107 - "those", 108 - "what", 109 - "which", 110 - "who", 111 - "whom", 112 - "its", 113 - "his", 114 - "her", 115 - "their", 116 - "our", 117 - "your", 118 - "any", 119 - "it", 120 - "he", 121 - "she", 122 - "they", 123 - "we", 124 - "you", 125 - "me", 126 - "him", 127 - "them", 128 - "us", 129 - "my", 130 - "up", 131 - } 132 - ) 133 - AGENT_OUTPUT_KEYS = ["decisions", "followups", "meetings", "messaging"] 134 - MATCH_THRESHOLD = 0.3 135 - 136 - 137 - def _skills_dir(facet: str) -> Path: 138 - path = Path(get_journal()) / "facets" / facet / "skills" 139 - path.mkdir(parents=True, exist_ok=True) 140 - return path 141 - 142 - 143 - def _patterns_path(facet: str) -> Path: 144 - return _skills_dir(facet) / "patterns.jsonl" 145 - 146 - 147 - def _load_patterns(facet: str) -> list[dict]: 148 - path = _patterns_path(facet) 149 - if not path.exists(): 150 - return [] 151 - 152 - patterns = [] 153 - with open(path, "r", encoding="utf-8") as f: 154 - for lineno, line in enumerate(f, start=1): 155 - line = line.strip() 156 - if not line: 157 - continue 158 - try: 159 - patterns.append(json.loads(line)) 160 - except json.JSONDecodeError: 161 - logger.warning("skills: malformed patterns.jsonl line %s", lineno) 162 - return patterns 163 - 164 - 165 - def _write_patterns(facet: str, patterns: list[dict]) -> None: 166 - content = "" 167 - if patterns: 168 - content = "\n".join( 169 - json.dumps(pattern, ensure_ascii=False) for pattern in patterns 170 - ) 171 - content += "\n" 172 - atomic_write(_patterns_path(facet), content) 173 - 174 - 175 - def _extract_keywords(text: str) -> list[str]: 176 - keywords = set() 177 - for token in re.split(r"[^a-z0-9]+", text.lower()): 178 - if len(token) < 3 or token in STOPWORDS or token.isdigit(): 179 - continue 180 - keywords.add(token) 181 - return sorted(keywords) 182 - 183 - 184 - def _normalize_entities(entities: list) -> list[str]: 185 - normalized = set() 186 - for entity in entities: 187 - value = str(entity).strip().lower() 188 - if value: 189 - normalized.add(value) 190 - return sorted(normalized) 191 - 192 - 193 - def _slugify(text: str) -> str: 194 - text = re.sub(r"[^a-z0-9]+", "-", text.lower()) 195 - text = re.sub(r"-+", "-", text) 196 - return text.strip("-") 197 - 198 - 199 - def _make_slug(activity_type: str, entities: list[str], keywords: list[str]) -> str: 200 - import hashlib 201 - 202 - tokens = [_slugify(activity_type)] 203 - tokens.extend(_slugify(entity) for entity in entities[:2]) 204 - tokens.extend(_slugify(keyword) for keyword in keywords[:3]) 205 - slug = "-".join(token for token in tokens if token) 206 - slug = _slugify(slug) 207 - if not entities and not keywords: 208 - base = _slugify(activity_type) or "activity" 209 - slug = f"{base}-pattern" 210 - elif not slug: 211 - base = _slugify(activity_type) or "activity" 212 - slug = f"{base}-pattern" 213 - if len(slug) > 80: 214 - digest = hashlib.md5(slug.encode("utf-8")).hexdigest()[:8] 215 - slug = f"{slug[:70].rstrip('-')}-{digest}" 216 - return slug 217 - 218 - 219 - def _jaccard(a: set, b: set) -> float: 220 - union = a | b 221 - if not union: 222 - return 0.0 223 - return len(a & b) / len(union) 224 - 225 - 226 - def _pattern_score( 227 - incoming_entities: set, incoming_keywords: set, pattern: dict 228 - ) -> float | None: 229 - scores = [] 230 - pattern_entities = set(pattern.get("entities", [])) 231 - pattern_keywords = set(pattern.get("keywords", [])) 232 - 233 - if incoming_entities and pattern_entities: 234 - scores.append(_jaccard(incoming_entities, pattern_entities)) 235 - if incoming_keywords and pattern_keywords: 236 - scores.append(_jaccard(incoming_keywords, pattern_keywords)) 237 - if not scores: 238 - return None 239 - 240 - combined = sum(scores) / len(scores) 241 - if combined >= MATCH_THRESHOLD: 242 - return combined 243 - return None 244 - 245 - 246 - def _find_best_match( 247 - activity_type: str, entities: set, keywords: set, patterns: list[dict] 248 - ) -> dict | None: 249 - best_match = None 250 - best_score = -1.0 251 - 252 - for pattern in patterns: 253 - if pattern.get("activity_type") != activity_type: 254 - continue 255 - score = _pattern_score(entities, keywords, pattern) 256 - if score is None: 257 - continue 258 - if score > best_score: 259 - best_score = score 260 - best_match = pattern 261 - 262 - return best_match 263 - 264 - 265 - def _make_observation( 266 - day: str, 267 - activity_id: str, 268 - description: str, 269 - entities: list[str], 270 - keywords: list[str], 271 - ) -> dict: 272 - return { 273 - "day": day, 274 - "activity_id": activity_id, 275 - "description": description, 276 - "entities": entities, 277 - "keywords": keywords, 278 - } 279 - 280 - 281 - def _format_pattern_context(pattern: dict) -> str: 282 - observations = pattern.get("observations", []) 283 - first_seen = observations[0]["day"] if observations else "" 284 - last_seen = observations[-1]["day"] if observations else "" 285 - lines = [ 286 - "## Pattern Context", 287 - f"Canonical slug: {pattern.get('id', '')}", 288 - f"Activity type: {pattern.get('activity_type', '')}", 289 - f"Observation count: {len(observations)}", 290 - f"First seen: {first_seen}", 291 - f"Last seen: {last_seen}", 292 - f"Merged entities: {', '.join(pattern.get('entities', [])) or '[none]'}", 293 - f"Merged keywords: {', '.join(pattern.get('keywords', [])) or '[none]'}", 294 - "", 295 - "Observations:", 296 - ] 297 - 298 - for obs in observations: 299 - description = obs.get("description", "") or "[no description]" 300 - entities = ", ".join(obs.get("entities", [])) or "[none]" 301 - lines.append(f"- {obs.get('day', '')}: {description}") 302 - lines.append(f" entities: {entities}") 303 - 304 - return "\n".join(lines) 305 - 306 - 307 - def _load_previous_outputs(facet: str, observations: list[dict]) -> str: 308 - if not observations: 309 - return "No prior agent outputs available." 310 - 311 - lines = ["## Prior Agent Outputs"] 312 - found_any = False 313 - 314 - for obs in observations[-3:]: 315 - obs_lines = [] 316 - for key in AGENT_OUTPUT_KEYS: 317 - path = get_activity_output_path(facet, obs["day"], obs["activity_id"], key) 318 - try: 319 - content = path.read_text(encoding="utf-8").strip() 320 - except (FileNotFoundError, OSError): 321 - continue 322 - if not content: 323 - continue 324 - found_any = True 325 - obs_lines.append(f"### {key} ({path.name})") 326 - obs_lines.append(content[:2000]) 327 - obs_lines.append("") 328 - 329 - if obs_lines: 330 - lines.append(f"Observation {obs['day']} / {obs['activity_id']}") 331 - lines.append(obs.get("description", "") or "[no description]") 332 - lines.append("") 333 - lines.extend(obs_lines) 334 - 335 - if not found_any: 336 - return "No prior agent outputs available." 337 - 338 - return "\n".join(lines).rstrip() 339 - 340 - 341 - def _read_agency_observations() -> str: 342 - """Read the current ## observations section from agency.md.""" 343 - try: 344 - path = Path(get_journal()) / "identity" / "agency.md" 345 - text = path.read_text(encoding="utf-8") 346 - except (FileNotFoundError, OSError): 347 - return "" 348 - lines = text.split("\n") 349 - start = None 350 - for i, line in enumerate(lines): 351 - if line == "## observations": 352 - start = i + 1 353 - elif start is not None and line.startswith("## "): 354 - return "\n".join(lines[start:i]).strip() 355 - if start is not None: 356 - return "\n".join(lines[start:]).strip() 357 - return "" 358 - 359 - 360 - def _skill_instruction(mode: str) -> str: 361 - if mode == "comparison": 362 - return ( 363 - "Compare these two activity observations. Identify whether they reflect " 364 - "the same recurring skill or capability. Draft a provisional skill " 365 - "profile based on the evidence so far. Be conservative — only claim " 366 - "what both observations support." 367 - ) 368 - if mode == "refresh": 369 - return ( 370 - "Update this skill profile with new evidence from the latest " 371 - "observation. Preserve the core skill identity. Incorporate new " 372 - "details about tools, collaborators, or techniques observed. The " 373 - "slug must remain unchanged." 374 - ) 375 - return ( 376 - "Synthesize a complete skill profile from these recurring activity " 377 - "observations. You have 3+ observations of this pattern. Produce a " 378 - "thorough, grounded skill document that captures what the owner does, " 379 - "how they do it, and why." 380 - ) 381 - 382 - 383 - def pre_process(context: dict) -> dict | None: 384 - facet = context.get("facet") 385 - day = context.get("day") 386 - activity = context.get("activity") 387 - if not facet or not day or not activity: 388 - return None 389 - 390 - activity_type = activity.get("activity") 391 - activity_id = activity.get("id") 392 - if not activity_type or not activity_id: 393 - return None 394 - 395 - description = activity.get("description", "") 396 - entities = _normalize_entities(activity.get("active_entities", [])) 397 - keywords = _extract_keywords(description) 398 - patterns = _load_patterns(facet) 399 - match = _find_best_match(activity_type, set(entities), set(keywords), patterns) 400 - now_iso = datetime.now(timezone.utc).isoformat() 401 - obs = _make_observation(day, activity_id, description, entities, keywords) 402 - 403 - if match is None: 404 - if not entities and not keywords: 405 - return {"skip_reason": "no signal to seed pattern"} 406 - pattern = { 407 - "id": _make_slug(activity_type, entities, keywords), 408 - "activity_type": activity_type, 409 - "keywords": keywords, 410 - "entities": entities, 411 - "observations": [obs], 412 - "created_at": now_iso, 413 - "updated_at": now_iso, 414 - "skill_generated": False, 415 - } 416 - patterns.append(pattern) 417 - _write_patterns(facet, patterns) 418 - return {"skip_reason": "first observation, seeded pattern"} 419 - 420 - match.setdefault("observations", []).append(obs) 421 - match["entities"] = sorted(set(match.get("entities", [])) | set(entities)) 422 - match["keywords"] = sorted(set(match.get("keywords", [])) | set(keywords)) 423 - match["updated_at"] = now_iso 424 - _write_patterns(facet, patterns) 425 - 426 - observation_count = len(match["observations"]) 427 - if observation_count == 2: 428 - mode = "comparison" 429 - elif not match.get("skill_generated", False): 430 - mode = "generate" 431 - else: 432 - mode = "refresh" 433 - 434 - return { 435 - "template_vars": { 436 - "skill_instruction": _skill_instruction(mode), 437 - "pattern_context": _format_pattern_context(match), 438 - "previous_outputs": _load_previous_outputs( 439 - facet, match["observations"][:-1] 440 - ), 441 - }, 442 - "meta": { 443 - "pattern_id": match["id"], 444 - "facet": facet, 445 - "mode": mode, 446 - }, 447 - } 448 - 449 - 450 - def post_process(result: str, context: dict) -> str | None: 451 - try: 452 - data = json.loads(result) 453 - except json.JSONDecodeError: 454 - logger.warning("skills: could not parse result as JSON") 455 - return None 456 - 457 - if not isinstance(data, dict): 458 - logger.warning("skills: expected JSON object result") 459 - return None 460 - 461 - meta = context.get("meta") or {} 462 - pattern_id = meta.get("pattern_id") 463 - facet = meta.get("facet") 464 - mode = meta.get("mode") 465 - if not pattern_id or not facet or not mode: 466 - logger.warning("skills: missing required post-process metadata") 467 - return None 468 - 469 - if mode == "comparison": 470 - return None 471 - 472 - patterns = _load_patterns(facet) 473 - pattern = next((item for item in patterns if item.get("id") == pattern_id), None) 474 - if pattern is None: 475 - logger.warning("skills: pattern %s not found for facet %s", pattern_id, facet) 476 - return None 477 - 478 - was_new = not pattern.get("skill_generated", False) 479 - pattern["skill_generated"] = True 480 - pattern["updated_at"] = datetime.now(timezone.utc).isoformat() 481 - _write_patterns(facet, patterns) 482 - 483 - observations = pattern.get("observations", []) 484 - first_seen = observations[0]["day"] if observations else "" 485 - last_seen = observations[-1]["day"] if observations else "" 486 - collaborators = sorted( 487 - {str(item) for item in data.get("collaborators", []) if item} 488 - ) 489 - tools = sorted({str(item) for item in data.get("tools", []) if item}) 490 - 491 - frontmatter = [ 492 - "---", 493 - f'name: "{str(data.get("skill_name", pattern_id)).replace(chr(34), chr(39))}"', 494 - f'slug: "{pattern_id}"', 495 - f'category: "{str(data.get("category", "")).replace(chr(34), chr(39))}"', 496 - f"confidence: {data.get('confidence', 0.0)}", 497 - f"observations: {len(observations)}", 498 - f'first_seen: "{first_seen}"', 499 - f'last_seen: "{last_seen}"', 500 - "collaborators:", 501 - ] 502 - if collaborators: 503 - frontmatter.extend( 504 - f' - "{item.replace(chr(34), chr(39))}"' for item in collaborators 505 - ) 506 - else: 507 - frontmatter.append(" []") 508 - frontmatter.append("tools:") 509 - if tools: 510 - frontmatter.extend(f' - "{item.replace(chr(34), chr(39))}"' for item in tools) 511 - else: 512 - frontmatter.append(" []") 513 - frontmatter.append("---") 514 - frontmatter.append("") 515 - frontmatter.append("## Description") 516 - frontmatter.append("") 517 - frontmatter.append(str(data.get("description", "")).strip()) 518 - frontmatter.append("") 519 - frontmatter.append("## How") 520 - frontmatter.append("") 521 - frontmatter.append(str(data.get("how", "")).strip()) 522 - frontmatter.append("") 523 - frontmatter.append("## Why") 524 - frontmatter.append("") 525 - frontmatter.append(str(data.get("why", "")).strip()) 526 - frontmatter.append("") 527 - 528 - skill_path = _skills_dir(facet) / f"{pattern_id}.md" 529 - atomic_write(skill_path, "\n".join(frontmatter)) 530 - 531 - if was_new: 532 - skill_name = data.get("skill_name", pattern_id) 533 - new_line = f"- Noticed recurring skill: {skill_name} — observed {len(observations)} times in {facet}" 534 - existing = _read_agency_observations() 535 - if existing and existing.strip() != "[watching and learning]": 536 - content = existing.rstrip("\n") + "\n" + new_line 537 - else: 538 - content = new_line 539 - update_identity_section( 540 - "agency.md", 541 - "observations", 542 - content, 543 - actor="agency-observations-tender", 544 - reason="agency observations refresh", 545 - ) 546 - 547 - return None
-503
tests/test_skills_hook.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - import json 5 - import os 6 - from pathlib import Path 7 - from unittest.mock import patch 8 - 9 - from talent.skills import ( 10 - MATCH_THRESHOLD, 11 - _extract_keywords, 12 - _find_best_match, 13 - _jaccard, 14 - _load_patterns, 15 - _make_slug, 16 - _normalize_entities, 17 - _pattern_score, 18 - _write_patterns, 19 - post_process, 20 - pre_process, 21 - ) 22 - 23 - os.environ.setdefault("_SOLSTONE_JOURNAL_OVERRIDE", "tests/fixtures/journal") 24 - 25 - 26 - _DEFAULT_ENTITIES = ["Alice", "Bob"] 27 - 28 - 29 - def _make_context( 30 - facet="test-facet", 31 - day="20260410", 32 - activity_type="meeting", 33 - activity_id="act-001", 34 - description="Weekly standup with engineering team", 35 - entities=None, 36 - ): 37 - return { 38 - "facet": facet, 39 - "day": day, 40 - "activity": { 41 - "activity": activity_type, 42 - "id": activity_id, 43 - "description": description, 44 - "active_entities": _DEFAULT_ENTITIES if entities is None else entities, 45 - }, 46 - } 47 - 48 - 49 - SAMPLE_LLM_RESULT = json.dumps( 50 - { 51 - "skill_name": "Engineering Standup Facilitation", 52 - "slug": "meeting-alice-bob-standup-engineering", 53 - "category": "communication", 54 - "description": "Facilitates regular engineering standup meetings.", 55 - "how": "Runs structured standups covering blockers and progress.", 56 - "why": "Keeps the engineering team aligned and unblocked.", 57 - "tools": ["Zoom", "Jira"], 58 - "collaborators": ["Alice", "Bob"], 59 - "confidence": 0.85, 60 - } 61 - ) 62 - 63 - 64 - class TestKeywordExtraction: 65 - def test_basic_extraction(self): 66 - result = _extract_keywords("Weekly standup with engineering team") 67 - assert "weekly" in result 68 - assert "standup" in result 69 - assert "engineering" in result 70 - assert "team" in result 71 - 72 - def test_stopword_removal(self): 73 - result = _extract_keywords("the quick brown fox") 74 - assert "the" not in result 75 - 76 - def test_short_words_removed(self): 77 - assert _extract_keywords("a an is") == [] 78 - 79 - def test_numeric_removed(self): 80 - result = _extract_keywords("meeting 123 items") 81 - assert "123" not in result 82 - 83 - def test_empty_string(self): 84 - assert _extract_keywords("") == [] 85 - 86 - 87 - class TestNormalizeEntities: 88 - def test_basic(self): 89 - assert _normalize_entities(["Alice", "Bob"]) == ["alice", "bob"] 90 - 91 - def test_dedup(self): 92 - assert _normalize_entities(["Alice", "alice"]) == ["alice"] 93 - 94 - def test_non_string(self): 95 - assert _normalize_entities([123]) == ["123"] 96 - 97 - 98 - class TestJaccard: 99 - def test_identical(self): 100 - assert _jaccard({1, 2, 3}, {1, 2, 3}) == 1.0 101 - 102 - def test_disjoint(self): 103 - assert _jaccard({1, 2}, {3, 4}) == 0.0 104 - 105 - def test_partial(self): 106 - assert _jaccard({1, 2, 3}, {2, 3, 4}) == 0.5 107 - 108 - def test_empty(self): 109 - assert _jaccard(set(), set()) == 0.0 110 - 111 - 112 - class TestMakeSlug: 113 - def test_basic(self): 114 - assert _make_slug("meeting", ["alice"], ["standup"]) == "meeting-alice-standup" 115 - 116 - def test_special_chars(self): 117 - slug = _make_slug("Team Meeting!", ["Alice Smith"], ["Q2/Planning"]) 118 - assert slug == "team-meeting-alice-smith-q2-planning" 119 - 120 - def test_truncation(self): 121 - slug = _make_slug( 122 - "meeting", 123 - ["a" * 40, "b" * 40], 124 - ["c" * 40, "d" * 40, "e" * 40], 125 - ) 126 - assert len(slug) <= 80 127 - 128 - def test_empty_fallback(self): 129 - assert _make_slug("meeting", [], []) == "meeting-pattern" 130 - 131 - 132 - class TestPatternScore: 133 - def test_matching_entities(self): 134 - pattern = { 135 - "activity_type": "meeting", 136 - "entities": ["alice", "bob"], 137 - "keywords": ["engineering", "standup"], 138 - } 139 - score = _pattern_score( 140 - {"alice", "carol"}, 141 - {"engineering", "weekly"}, 142 - pattern, 143 - ) 144 - assert score is not None 145 - assert score >= MATCH_THRESHOLD 146 - 147 - def test_no_overlap(self): 148 - pattern = { 149 - "activity_type": "meeting", 150 - "entities": ["alice"], 151 - "keywords": ["standup"], 152 - } 153 - assert _pattern_score({"carol"}, {"planning"}, pattern) is None 154 - 155 - def test_exact_type_required(self): 156 - patterns = [ 157 - { 158 - "id": "meeting-alice", 159 - "activity_type": "meeting", 160 - "entities": ["alice"], 161 - "keywords": ["standup"], 162 - } 163 - ] 164 - match = _find_best_match("email", {"alice"}, {"standup"}, patterns) 165 - assert match is None 166 - 167 - def test_entity_only_signal(self): 168 - pattern = {"activity_type": "meeting", "entities": ["alice"], "keywords": []} 169 - score = _pattern_score({"alice"}, set(), pattern) 170 - assert score == 1.0 171 - 172 - 173 - class TestPreProcess: 174 - def test_novel_activity_seeds_pattern_and_skips(self, tmp_path): 175 - with patch("talent.skills.get_journal", return_value=str(tmp_path)): 176 - result = pre_process(_make_context()) 177 - patterns = _load_patterns("test-facet") 178 - 179 - assert result == {"skip_reason": "first observation, seeded pattern"} 180 - assert len(patterns) == 1 181 - assert patterns[0]["activity_type"] == "meeting" 182 - assert len(patterns[0]["observations"]) == 1 183 - 184 - def test_second_observation_returns_template_vars(self, tmp_path): 185 - with patch("talent.skills.get_journal", return_value=str(tmp_path)): 186 - pre_process(_make_context()) 187 - result = pre_process( 188 - _make_context( 189 - day="20260411", 190 - activity_id="act-002", 191 - description="Weekly engineering standup with Alice and Bob", 192 - ) 193 - ) 194 - 195 - assert "template_vars" in result 196 - assert "meta" in result 197 - assert result["meta"]["mode"] == "comparison" 198 - assert { 199 - "skill_instruction", 200 - "pattern_context", 201 - "previous_outputs", 202 - } <= set(result["template_vars"]) 203 - 204 - def test_third_observation_triggers_generation(self, tmp_path): 205 - with patch("talent.skills.get_journal", return_value=str(tmp_path)): 206 - pre_process(_make_context()) 207 - pre_process( 208 - _make_context( 209 - day="20260411", 210 - activity_id="act-002", 211 - description="Weekly engineering standup with Alice and Bob", 212 - ) 213 - ) 214 - result = pre_process( 215 - _make_context( 216 - day="20260412", 217 - activity_id="act-003", 218 - description="Weekly engineering standup and blocker review", 219 - ) 220 - ) 221 - 222 - assert result["meta"]["mode"] == "generate" 223 - 224 - def test_no_signal_activity_skips(self, tmp_path): 225 - with patch("talent.skills.get_journal", return_value=str(tmp_path)): 226 - result = pre_process(_make_context(entities=[], description="")) 227 - patterns = _load_patterns("test-facet") 228 - 229 - assert result == {"skip_reason": "no signal to seed pattern"} 230 - assert len(patterns) == 0 231 - 232 - def test_entities_and_keywords_merged(self, tmp_path): 233 - with patch("talent.skills.get_journal", return_value=str(tmp_path)): 234 - pre_process( 235 - _make_context( 236 - entities=["Alice", "Bob"], 237 - description="Weekly standup with engineering team", 238 - ) 239 - ) 240 - pre_process( 241 - _make_context( 242 - day="20260411", 243 - activity_id="act-002", 244 - entities=["Alice", "Carol"], 245 - description="Weekly standup with product team", 246 - ) 247 - ) 248 - patterns = _load_patterns("test-facet") 249 - 250 - assert patterns[0]["entities"] == ["alice", "bob", "carol"] 251 - assert "engineering" in patterns[0]["keywords"] 252 - assert "product" in patterns[0]["keywords"] 253 - 254 - 255 - class TestPostProcess: 256 - def _seed_pattern(self, tmp_path, skill_generated=False): 257 - pattern = { 258 - "id": "meeting-alice-bob-standup-engineering", 259 - "activity_type": "meeting", 260 - "keywords": ["engineering", "standup", "weekly"], 261 - "entities": ["alice", "bob"], 262 - "observations": [ 263 - { 264 - "day": "20260410", 265 - "activity_id": "act-001", 266 - "description": "Weekly standup with engineering team", 267 - "entities": ["alice", "bob"], 268 - "keywords": ["engineering", "standup", "weekly"], 269 - }, 270 - { 271 - "day": "20260411", 272 - "activity_id": "act-002", 273 - "description": "Weekly engineering standup with Alice and Bob", 274 - "entities": ["alice", "bob"], 275 - "keywords": ["engineering", "standup", "weekly"], 276 - }, 277 - { 278 - "day": "20260412", 279 - "activity_id": "act-003", 280 - "description": "Weekly engineering standup and blocker review", 281 - "entities": ["alice", "bob"], 282 - "keywords": [ 283 - "blocker", 284 - "engineering", 285 - "review", 286 - "standup", 287 - "weekly", 288 - ], 289 - }, 290 - ], 291 - "created_at": "2026-04-10T00:00:00+00:00", 292 - "updated_at": "2026-04-12T00:00:00+00:00", 293 - "skill_generated": skill_generated, 294 - } 295 - with patch("talent.skills.get_journal", return_value=str(tmp_path)): 296 - _write_patterns("test-facet", [pattern]) 297 - 298 - def test_comparison_mode_is_noop(self, tmp_path): 299 - self._seed_pattern(tmp_path) 300 - with patch("talent.skills.get_journal", return_value=str(tmp_path)): 301 - result = post_process( 302 - SAMPLE_LLM_RESULT, 303 - { 304 - "meta": { 305 - "pattern_id": "meeting-alice-bob-standup-engineering", 306 - "facet": "test-facet", 307 - "mode": "comparison", 308 - } 309 - }, 310 - ) 311 - 312 - assert result is None 313 - skill_path = ( 314 - Path(tmp_path) 315 - / "facets" 316 - / "test-facet" 317 - / "skills" 318 - / "meeting-alice-bob-standup-engineering.md" 319 - ) 320 - assert not skill_path.exists() 321 - 322 - def test_generates_skill_document(self, tmp_path): 323 - self._seed_pattern(tmp_path) 324 - with ( 325 - patch("talent.skills.get_journal", return_value=str(tmp_path)), 326 - patch("talent.skills.update_identity_section"), 327 - ): 328 - post_process( 329 - SAMPLE_LLM_RESULT, 330 - { 331 - "meta": { 332 - "pattern_id": "meeting-alice-bob-standup-engineering", 333 - "facet": "test-facet", 334 - "mode": "generate", 335 - } 336 - }, 337 - ) 338 - 339 - skill_path = ( 340 - Path(tmp_path) 341 - / "facets" 342 - / "test-facet" 343 - / "skills" 344 - / "meeting-alice-bob-standup-engineering.md" 345 - ) 346 - content = skill_path.read_text(encoding="utf-8") 347 - assert content.startswith("---\n") 348 - assert 'slug: "meeting-alice-bob-standup-engineering"' in content 349 - assert "## Description" in content 350 - assert "## How" in content 351 - assert "## Why" in content 352 - 353 - def test_writes_patterns_atomically(self, tmp_path): 354 - self._seed_pattern(tmp_path) 355 - with ( 356 - patch("talent.skills.get_journal", return_value=str(tmp_path)), 357 - patch("talent.skills.update_identity_section"), 358 - ): 359 - post_process( 360 - SAMPLE_LLM_RESULT, 361 - { 362 - "meta": { 363 - "pattern_id": "meeting-alice-bob-standup-engineering", 364 - "facet": "test-facet", 365 - "mode": "generate", 366 - } 367 - }, 368 - ) 369 - patterns = _load_patterns("test-facet") 370 - 371 - assert patterns[0]["skill_generated"] is True 372 - assert patterns[0]["updated_at"] != "2026-04-12T00:00:00+00:00" 373 - 374 - def test_agency_notification(self, tmp_path): 375 - self._seed_pattern(tmp_path) 376 - with ( 377 - patch("talent.skills.get_journal", return_value=str(tmp_path)), 378 - patch("talent.skills.update_identity_section") as mock_update, 379 - patch("talent.skills._read_agency_observations", return_value=""), 380 - ): 381 - post_process( 382 - SAMPLE_LLM_RESULT, 383 - { 384 - "meta": { 385 - "pattern_id": "meeting-alice-bob-standup-engineering", 386 - "facet": "test-facet", 387 - "mode": "generate", 388 - } 389 - }, 390 - ) 391 - 392 - mock_update.assert_called_once() 393 - args = mock_update.call_args.args 394 - assert args[0] == "agency.md" 395 - assert args[1] == "observations" 396 - assert "Engineering Standup Facilitation" in args[2] 397 - 398 - def test_agency_notification_appends_to_existing(self, tmp_path): 399 - self._seed_pattern(tmp_path) 400 - with ( 401 - patch("talent.skills.get_journal", return_value=str(tmp_path)), 402 - patch("talent.skills.update_identity_section") as mock_update, 403 - patch( 404 - "talent.skills._read_agency_observations", 405 - return_value="- Existing observation about something", 406 - ), 407 - ): 408 - post_process( 409 - SAMPLE_LLM_RESULT, 410 - { 411 - "meta": { 412 - "pattern_id": "meeting-alice-bob-standup-engineering", 413 - "facet": "test-facet", 414 - "mode": "generate", 415 - } 416 - }, 417 - ) 418 - 419 - args = mock_update.call_args.args 420 - content = args[2] 421 - assert "Existing observation" in content 422 - assert "Engineering Standup Facilitation" in content 423 - 424 - def test_refresh_does_not_notify_agency(self, tmp_path): 425 - self._seed_pattern(tmp_path, skill_generated=True) 426 - with ( 427 - patch("talent.skills.get_journal", return_value=str(tmp_path)), 428 - patch("talent.skills.update_identity_section") as mock_update, 429 - ): 430 - post_process( 431 - SAMPLE_LLM_RESULT, 432 - { 433 - "meta": { 434 - "pattern_id": "meeting-alice-bob-standup-engineering", 435 - "facet": "test-facet", 436 - "mode": "refresh", 437 - } 438 - }, 439 - ) 440 - 441 - mock_update.assert_not_called() 442 - skill_path = ( 443 - Path(tmp_path) 444 - / "facets" 445 - / "test-facet" 446 - / "skills" 447 - / "meeting-alice-bob-standup-engineering.md" 448 - ) 449 - assert skill_path.exists() 450 - 451 - def test_malformed_json_result(self, tmp_path): 452 - self._seed_pattern(tmp_path) 453 - with patch("talent.skills.get_journal", return_value=str(tmp_path)): 454 - result = post_process( 455 - "not valid json", 456 - { 457 - "meta": { 458 - "pattern_id": "meeting-alice-bob-standup-engineering", 459 - "facet": "test-facet", 460 - "mode": "generate", 461 - } 462 - }, 463 - ) 464 - assert result is None 465 - 466 - def test_missing_pattern_id(self, tmp_path): 467 - self._seed_pattern(tmp_path) 468 - with patch("talent.skills.get_journal", return_value=str(tmp_path)): 469 - result = post_process( 470 - SAMPLE_LLM_RESULT, 471 - { 472 - "meta": { 473 - "pattern_id": "missing-pattern", 474 - "facet": "test-facet", 475 - "mode": "generate", 476 - } 477 - }, 478 - ) 479 - assert result is None 480 - 481 - 482 - class TestLoadPatterns: 483 - def test_empty_file(self, tmp_path): 484 - path = tmp_path / "facets" / "test-facet" / "skills" 485 - path.mkdir(parents=True) 486 - (path / "patterns.jsonl").write_text("", encoding="utf-8") 487 - with patch("talent.skills.get_journal", return_value=str(tmp_path)): 488 - assert _load_patterns("test-facet") == [] 489 - 490 - def test_missing_file(self, tmp_path): 491 - with patch("talent.skills.get_journal", return_value=str(tmp_path)): 492 - assert _load_patterns("test-facet") == [] 493 - 494 - def test_malformed_lines_skipped(self, tmp_path): 495 - path = tmp_path / "facets" / "test-facet" / "skills" 496 - path.mkdir(parents=True) 497 - (path / "patterns.jsonl").write_text( 498 - '{"id": "one", "activity_type": "meeting"}\nnot-json\n{"id": "two", "activity_type": "meeting"}\n', 499 - encoding="utf-8", 500 - ) 501 - with patch("talent.skills.get_journal", return_value=str(tmp_path)): 502 - patterns = _load_patterns("test-facet") 503 - assert [pattern["id"] for pattern in patterns] == ["one", "two"]