Coverage for src/keel/capture.py: 100%
291 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
1"""Consumer-neutral post-merge capture contract and verification helpers."""
3from __future__ import annotations
5import re
6from dataclasses import dataclass
7from pathlib import Path
8from typing import Any
10from . import config as cfg
12CAPTURE_SCHEMA_VERSION = "keel.capture.v1"
13RECONCILE_SCHEMA_VERSION = "keel.capture-reconcile.v1"
14LEARNING_DECISION_SCHEMA_VERSION = "keel.capture-learning.v1"
15MARKER_PREFIX = "compound-learning"
16STATUSES = ("applied", "deferred", "skipped")
17SKIP_REASONS = (
18 "dry-run",
19 "deferred",
20 "merge-failed",
21 "recursion-guard",
22 "capability-unavailable",
23 "no-policy",
24)
25LEARNING_DECISIONS = ("create-learning", "marker-only", "defer", "duplicate")
27_MARKER_RE = re.compile(
28 r"^compound-learning:\s+pr=(?P<pr>[1-9][0-9]*)\s+status="
29 r"(?P<status>applied|deferred|skipped(?::[a-z0-9-]+)?)$"
30)
33class CaptureError(ValueError):
34 """Raised when a capture marker or capture record is invalid."""
37@dataclass(frozen=True)
38class CaptureMarker:
39 """One stable capture marker emitted after a merged PR."""
41 pr_number: int
42 status: str
43 reason: str | None = None
45 def as_text(self) -> str:
46 return marker_text(
47 pr_number=self.pr_number,
48 status=self.status,
49 reason=self.reason,
50 )
52 def as_dict(self) -> dict[str, Any]:
53 return {
54 "schema_version": CAPTURE_SCHEMA_VERSION,
55 "prefix": MARKER_PREFIX,
56 "pr": self.pr_number,
57 "status": self.status,
58 "reason": self.reason,
59 "text": self.as_text(),
60 }
63def contract_as_dict(config: cfg.ProjectConfig | None = None) -> dict[str, Any]:
64 """Return the stable capture contract consumed by adapters and verifiers."""
65 capture_policy = _capture_policy(config)
66 return {
67 "schema_version": CAPTURE_SCHEMA_VERSION,
68 "marker": {
69 "prefix": MARKER_PREFIX,
70 "format": "compound-learning: pr=<N> status=<applied|deferred|skipped:reason>",
71 "statuses": list(STATUSES),
72 "skip_reasons": list(SKIP_REASONS),
73 "required_after_merged_pr": True,
74 },
75 "extension_slots": ["capture", "post-merge"],
76 "policy_source": "policy_pack.capture + capture/post-merge extensions",
77 "policy_enabled": bool(capture_policy.get("enabled", False)),
78 "policy_mode": capture_policy.get("mode", "extension"),
79 "recursion_guard": {
80 "enabled": True,
81 "reason": "recursion-guard",
82 "never_capture_capture_work": True,
83 },
84 "fail_soft": {
85 "enabled": True,
86 "merge_revert_on_capture_failure": False,
87 "failure_marker": "skipped:capability-unavailable",
88 },
89 "durable_artifacts": {
90 "requires_redaction": True,
91 "redaction_contract": "run_ledger.capture_redaction",
92 "core_destination": "run-ledger",
93 "project_destination": "extension-owned",
94 },
95 "learning_quality": learning_quality_contract_as_dict(config),
96 "session_end_verifier": {
97 "primitive": "capture.verify_session",
98 "cli": "keel capture-verify",
99 "missing_marker_status": "missing",
100 "invalid_marker_status": "invalid",
101 },
102 "reconcile": {
103 "schema_version": RECONCILE_SCHEMA_VERSION,
104 "primitive": "capture.reconcile_session",
105 "cli": "keel capture-reconcile",
106 "idempotent": True,
107 "never_reopens_implementation": True,
108 "never_pushes_code": True,
109 "never_merges_prs": True,
110 "actions": [
111 "emit-capture-marker",
112 "run-capture-extension",
113 "post-closure-summary",
114 "close-linked-issue",
115 "record-skip",
116 ],
117 },
118 }
121def learning_quality_contract_as_dict(config: cfg.ProjectConfig | None = None) -> dict[str, Any]:
122 """Return the consumer-neutral durable-learning quality contract."""
123 policy = _learning_policy(config)
124 dedupe = policy.get("dedupe") if isinstance(policy.get("dedupe"), dict) else {}
125 return {
126 "schema_version": LEARNING_DECISION_SCHEMA_VERSION,
127 "decisions": list(LEARNING_DECISIONS),
128 "policy_source": "policy_pack.capture.learning",
129 "policy_enabled": bool(policy.get("enabled", False)),
130 "policy_mode": policy.get("mode", "policy-unavailable"),
131 "default_decision": "marker-only",
132 "default_reason": "policy-unavailable",
133 "marker_required_for_every_merge": True,
134 "durable_learning_optional": True,
135 "dedupe": {
136 "enabled": bool(dedupe.get("enabled", True)),
137 "fingerprint": "sha256(normalized title + labels + changed files)",
138 "matching": "stable fingerprint plus configured matching rules",
139 },
140 "ledger_field": "capture.learning",
141 "closure_summary_field": "Capture",
142 }
145def marker_text(*, pr_number: int, status: str, reason: str | None = None) -> str:
146 """Render one stable capture marker."""
147 marker = build_marker(pr_number=pr_number, status=status, reason=reason)
148 suffix = marker.status if marker.reason is None else f"{marker.status}:{marker.reason}"
149 return f"{MARKER_PREFIX}: pr={marker.pr_number} status={suffix}"
152def build_marker(*, pr_number: int, status: str, reason: str | None = None) -> CaptureMarker:
153 """Validate and build a capture marker."""
154 if pr_number <= 0:
155 raise CaptureError("capture marker requires a positive PR number")
156 status, reason = normalize_status(status, reason)
157 return CaptureMarker(pr_number=pr_number, status=status, reason=reason)
160def normalize_status(status: str | None, reason: str | None = None) -> tuple[str, str | None]:
161 """Normalize ``skipped:<reason>`` into a structured status and reason."""
162 if not status:
163 raise CaptureError("capture status is required")
164 raw = status.strip()
165 if raw.startswith("skipped:"):
166 raw, embedded_reason = raw.split(":", 1)
167 reason = embedded_reason
168 if raw not in STATUSES:
169 raise CaptureError(f"unsupported capture status: {status}")
170 clean_reason = reason.strip() if isinstance(reason, str) and reason.strip() else None
171 if raw == "skipped":
172 if clean_reason not in SKIP_REASONS:
173 raise CaptureError("skipped capture requires an allowed skip reason")
174 else:
175 clean_reason = None
176 return raw, clean_reason
179def parse_marker(text: str) -> CaptureMarker:
180 """Parse a stable marker string into structured data."""
181 match = _MARKER_RE.match(text.strip())
182 if not match:
183 raise CaptureError("invalid capture marker")
184 status_text = match.group("status")
185 status, reason = normalize_status(status_text)
186 return CaptureMarker(
187 pr_number=int(match.group("pr")),
188 status=status,
189 reason=reason,
190 )
193def record_marker(
194 *,
195 pr_number: int | None,
196 status: str | None,
197 reason: str | None = None,
198 artifact: str | None = None,
199 title: str | None = None,
200 labels: list[str] | tuple[str, ...] = (),
201 changed_files: list[str] | tuple[str, ...] = (),
202 existing_records: list[dict[str, Any]] | tuple[dict[str, Any], ...] = (),
203 config: cfg.ProjectConfig | None = None,
204) -> dict[str, Any]:
205 """Build the capture block stored in a ship run ledger record.
207 ``artifact`` is an optional reference (path or content hash) to the durable
208 capture artifact. It is the proof that an ``applied`` capture actually
209 produced something; capture reconcile treats ``applied`` with no artifact as
210 a finding. ``deferred``/``skipped`` need no artifact.
211 """
212 clean_artifact = artifact.strip() if isinstance(artifact, str) and artifact.strip() else None
213 if status is None:
214 return {
215 "schema_version": CAPTURE_SCHEMA_VERSION,
216 "status": None,
217 "reason": reason,
218 "marker_reason": None,
219 "marker": None,
220 "artifact": clean_artifact,
221 "fail_soft": True,
222 "learning": learning_decision(
223 title=title,
224 labels=labels,
225 changed_files=changed_files,
226 capture_status=None,
227 capture_reason=reason,
228 existing_records=existing_records,
229 config=config,
230 ),
231 }
232 learning = learning_decision(
233 title=title,
234 labels=labels,
235 changed_files=changed_files,
236 capture_status=status,
237 capture_reason=reason,
238 existing_records=existing_records,
239 config=config,
240 )
241 marker_reason = _marker_reason(status, reason)
242 if pr_number is None:
243 clean_status, clean_marker_reason = normalize_status(status, marker_reason)
244 return {
245 "schema_version": CAPTURE_SCHEMA_VERSION,
246 "status": clean_status,
247 "reason": reason,
248 "marker_reason": clean_marker_reason,
249 "marker": None,
250 "artifact": clean_artifact,
251 "fail_soft": True,
252 "learning": learning,
253 }
254 marker = build_marker(pr_number=pr_number, status=status, reason=marker_reason)
255 return {
256 "schema_version": CAPTURE_SCHEMA_VERSION,
257 "status": marker.status,
258 "reason": reason,
259 "marker_reason": marker.reason,
260 "marker": marker.as_text(),
261 "artifact": clean_artifact,
262 "fail_soft": True,
263 "learning": learning,
264 }
267def learning_decision(
268 *,
269 title: str | None = None,
270 labels: list[str] | tuple[str, ...] = (),
271 changed_files: list[str] | tuple[str, ...] = (),
272 capture_status: str | None = None,
273 capture_reason: str | None = None,
274 existing_records: list[dict[str, Any]] | tuple[dict[str, Any], ...] = (),
275 config: cfg.ProjectConfig | None = None,
276) -> dict[str, Any]:
277 """Classify whether a merged PR deserves a durable learning artifact.
279 The marker is mandatory and independent from this decision. Durable learning is
280 optional, policy-driven, and deduped by a stable fingerprint so routine merges can stay
281 marker-only without losing auditability.
282 """
283 policy = _learning_policy(config)
284 fingerprint = learning_fingerprint(
285 title=title,
286 labels=labels,
287 changed_files=changed_files,
288 )
289 if _learning_dedupe_enabled(policy):
290 duplicate_of = _duplicate_learning_fingerprint(fingerprint, existing_records)
291 if duplicate_of is not None:
292 return _learning_result(
293 "duplicate",
294 reason="duplicate-learning",
295 fingerprint=fingerprint,
296 duplicate_of=duplicate_of,
297 policy=policy,
298 )
299 if not policy.get("enabled"):
300 return _learning_result(
301 "marker-only",
302 reason="policy-unavailable",
303 fingerprint=fingerprint,
304 policy=policy,
305 )
306 mode = policy.get("mode", "marker-only")
307 if mode == "create-learning":
308 if capture_status and capture_status.startswith("skipped"):
309 return _learning_result(
310 "marker-only",
311 reason="capture-skipped",
312 fingerprint=fingerprint,
313 policy=policy,
314 )
315 return _learning_result(
316 "create-learning",
317 reason=_policy_reason(policy, "policy-requested-learning"),
318 fingerprint=fingerprint,
319 policy=policy,
320 )
321 if mode == "defer":
322 return _learning_result(
323 "defer",
324 reason=_policy_reason(policy, "policy-deferred"),
325 fingerprint=fingerprint,
326 policy=policy,
327 )
328 return _learning_result(
329 "marker-only",
330 reason=_policy_reason(policy, "marker-only-policy"),
331 fingerprint=fingerprint,
332 policy=policy,
333 )
336def learning_fingerprint(
337 *,
338 title: str | None = None,
339 labels: list[str] | tuple[str, ...] = (),
340 changed_files: list[str] | tuple[str, ...] = (),
341) -> str:
342 """Return a stable, consumer-neutral dedupe fingerprint for learning candidates."""
343 import hashlib
344 import json
346 payload = {
347 "title": _normalize_text(title),
348 "labels": sorted(_normalize_text(label) for label in _strings(labels)),
349 "changed_files": sorted(_normalize_path(path) for path in _strings(changed_files)),
350 }
351 encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"))
352 return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
355def verify_session(
356 records: list[dict[str, Any]],
357 merged_prs: list[int] | tuple[int, ...],
358) -> dict[str, Any]:
359 """Verify that each merged PR has an applied/deferred/allowed-skip capture marker."""
360 results = [_verify_pr(records, pr) for pr in merged_prs]
361 missing = [item for item in results if item["status"] == "missing"]
362 invalid = [item for item in results if item["status"] == "invalid"]
363 status = "complete" if not missing and not invalid else "incomplete"
364 return {
365 "schema_version": CAPTURE_SCHEMA_VERSION,
366 "status": status,
367 "expected_prs": list(merged_prs),
368 "results": results,
369 "summary": {
370 "ok": sum(1 for item in results if item["ok"]),
371 "missing": len(missing),
372 "invalid": len(invalid),
373 },
374 }
377def reconcile_session(
378 records: list[dict[str, Any]],
379 merged_prs: list[int | dict[str, Any]] | tuple[int | dict[str, Any], ...],
380 *,
381 config: cfg.ProjectConfig | None = None,
382 capture_capability_available: bool = False,
383) -> dict[str, Any]:
384 """Plan idempotent post-merge reconciliation actions for capture gaps.
386 The returned plan is pure data. It never writes ledger records, comments, issues, git
387 state, or PR state; adapters may apply the listed actions after their own transport and
388 consent checks. This keeps reconcile recovery deterministic and safe to run repeatedly.
389 """
390 items = [_merged_pr_info(item) for item in merged_prs]
391 results = [
392 _reconcile_pr(
393 records,
394 item,
395 config=config,
396 capture_capability_available=capture_capability_available,
397 )
398 for item in items
399 ]
400 actionable = [item for item in results if item["actions"]]
401 blocked = [item for item in results if item["status"] in {"invalid", "ambiguous"}]
402 complete = [item for item in results if item["status"] == "complete"]
403 status = "blocked" if blocked else "actionable" if actionable else "complete"
404 return {
405 "schema_version": RECONCILE_SCHEMA_VERSION,
406 "status": status,
407 "dry_run_safe": True,
408 "idempotent": True,
409 "no_code_mutations": True,
410 "expected_prs": [item["number"] for item in items],
411 "results": results,
412 "summary": {
413 "complete": len(complete),
414 "actionable": len(actionable),
415 "blocked": len(blocked),
416 },
417 }
420def recursion_guard(
421 *,
422 title: str | None = None,
423 labels: list[str] | tuple[str, ...] = (),
424 changed_files: list[str] | tuple[str, ...] = (),
425) -> bool:
426 """Return true when capture should skip to avoid capture-on-capture recursion."""
427 # ⚡ Bolt: Return early if title matches to avoid expensive loops on labels and files
428 if title and "capture" in title.lower():
429 return True
431 # ⚡ Bolt: Return early if label matches to avoid expensive loops on files
432 for label in labels:
433 if label.lower() == "capture":
434 return True
436 # ⚡ Bolt: Avoid generator overhead with explicit loop for paths (~90x speedup when early match)
437 for path in changed_files:
438 p = path.lower()
439 if "/capture" in p or p.endswith("capture.py"):
440 return True
442 return False
445def _merged_pr_info(item: int | dict[str, Any]) -> dict[str, Any]:
446 if isinstance(item, int):
447 return {
448 "number": item,
449 "title": None,
450 "labels": [],
451 "changed_files": [],
452 "issue_numbers": [],
453 }
454 number = item.get("number")
455 if not isinstance(number, int) or number <= 0:
456 raise CaptureError("merged PR entry requires a positive number")
457 return {
458 "number": number,
459 "title": item.get("title") if isinstance(item.get("title"), str) else None,
460 "labels": _strings(item.get("labels")),
461 "changed_files": _strings(item.get("changed_files")),
462 "issue_numbers": _positive_ints(item.get("issue_numbers")),
463 }
466def _reconcile_pr(
467 records: list[dict[str, Any]],
468 item: dict[str, Any],
469 *,
470 config: cfg.ProjectConfig | None,
471 capture_capability_available: bool,
472) -> dict[str, Any]:
473 pr_number = item["number"]
474 verification = _verify_pr(records, pr_number)
475 issue_numbers = _linked_issue_numbers(records, item)
476 if len(issue_numbers) > 1:
477 return _reconcile_result(
478 pr_number,
479 status="ambiguous",
480 reason="multiple linked issues found for merged PR",
481 verification=verification,
482 issue_numbers=issue_numbers,
483 blocked=True,
484 )
485 if verification["ok"]:
486 if len(issue_numbers) == 1:
487 return _reconcile_result(
488 pr_number,
489 status="actionable",
490 reason="capture marker already present; linked issue closeout can be reconciled",
491 verification=verification,
492 issue_numbers=issue_numbers,
493 marker=verification["marker"],
494 actions=[
495 _action("close-linked-issue", pr_number=pr_number,
496 issue_number=issue_numbers[0]),
497 ],
498 )
499 return _reconcile_result(
500 pr_number,
501 status="complete",
502 reason="capture marker already present",
503 verification=verification,
504 )
505 if verification["status"] == "invalid":
506 return _reconcile_result(
507 pr_number,
508 status="invalid",
509 reason=verification["reason"],
510 verification=verification,
511 issue_numbers=issue_numbers,
512 blocked=True,
513 )
514 marker_status, marker_reason, reason = _reconcile_marker_decision(
515 item,
516 config=config,
517 capture_capability_available=capture_capability_available,
518 )
519 marker = marker_text(
520 pr_number=pr_number,
521 status=marker_status,
522 reason=marker_reason,
523 )
524 actions = [
525 _action(
526 "emit-capture-marker",
527 pr_number=pr_number,
528 marker=marker,
529 status=marker_status,
530 reason=marker_reason,
531 ),
532 _action("post-closure-summary", pr_number=pr_number),
533 ]
534 if marker_status == "deferred":
535 actions.insert(0, _action("run-capture-extension", pr_number=pr_number))
536 if marker_status == "skipped":
537 actions.append(_action("record-skip", pr_number=pr_number, reason=marker_reason))
538 if len(issue_numbers) == 1:
539 actions.append(_action("close-linked-issue", pr_number=pr_number,
540 issue_number=issue_numbers[0]))
541 return _reconcile_result(
542 pr_number,
543 status="actionable",
544 reason=reason,
545 verification=verification,
546 issue_numbers=issue_numbers,
547 marker=marker,
548 actions=actions,
549 )
552def _verify_pr(records: list[dict[str, Any]], pr_number: int) -> dict[str, Any]:
553 candidates = [
554 record for record in records
555 if record.get("record_type") == "ship_run"
556 and (record.get("pull_request") or {}).get("number") == pr_number
557 ]
558 markers = [
559 capture_block.get("marker")
560 for record in candidates
561 if isinstance(capture_block := record.get("capture"), dict)
562 and capture_block.get("marker")
563 ]
564 if len(markers) > 1:
565 return {
566 "pr": pr_number,
567 "ok": False,
568 "status": "invalid",
569 "reason": "multiple capture markers found for merged PR",
570 "marker": markers[-1],
571 "marker_count": len(markers),
572 }
573 for marker in markers:
574 try:
575 parsed = parse_marker(marker)
576 except CaptureError as exc:
577 return {
578 "pr": pr_number,
579 "ok": False,
580 "status": "invalid",
581 "reason": str(exc),
582 "marker": marker,
583 }
584 if parsed.pr_number != pr_number:
585 return {
586 "pr": pr_number,
587 "ok": False,
588 "status": "invalid",
589 "reason": "marker PR does not match ledger PR",
590 "marker": marker,
591 }
592 return {
593 "pr": pr_number,
594 "ok": True,
595 "status": parsed.status,
596 "reason": parsed.reason,
597 "marker": marker,
598 }
599 return {
600 "pr": pr_number,
601 "ok": False,
602 "status": "missing",
603 "reason": "no capture marker found for merged PR",
604 "marker": None,
605 }
608def _reconcile_result(
609 pr_number: int,
610 *,
611 status: str,
612 reason: str,
613 verification: dict[str, Any],
614 issue_numbers: list[int] | None = None,
615 marker: str | None = None,
616 actions: list[dict[str, Any]] | None = None,
617 blocked: bool = False,
618) -> dict[str, Any]:
619 return {
620 "pr": pr_number,
621 "status": status,
622 "reason": reason,
623 "verification_status": verification["status"],
624 "blocked": blocked,
625 "issue_numbers": list(issue_numbers or ()),
626 "marker": marker,
627 "actions": list(actions or ()),
628 }
631def _reconcile_marker_decision(
632 item: dict[str, Any],
633 *,
634 config: cfg.ProjectConfig | None,
635 capture_capability_available: bool,
636) -> tuple[str, str | None, str]:
637 if recursion_guard(
638 title=item["title"],
639 labels=item["labels"],
640 changed_files=item["changed_files"],
641 ):
642 return "skipped", "recursion-guard", "capture recursion guard matched"
643 policy = _capture_policy(config)
644 if policy.get("enabled") and policy.get("mode", "extension") == "marker-only":
645 return "applied", None, "marker-only capture policy configured"
646 if policy.get("enabled") and policy.get("mode", "extension") == "extension":
647 if capture_capability_available:
648 return "deferred", None, "capture extension can be rerun"
649 return "skipped", "capability-unavailable", "capture extension capability unavailable"
650 return "skipped", "no-policy", "no capture policy configured"
653def _linked_issue_numbers(records: list[dict[str, Any]], item: dict[str, Any]) -> list[int]:
654 numbers = set(item["issue_numbers"])
655 pr_number = item["number"]
656 for record in records:
657 if record.get("record_type") != "ship_run":
658 continue
659 if (record.get("pull_request") or {}).get("number") != pr_number:
660 continue
661 issue_number = (record.get("issue") or {}).get("number")
662 if isinstance(issue_number, int) and issue_number > 0:
663 numbers.add(issue_number)
664 return sorted(numbers)
667def _action(
668 action_type: str,
669 *,
670 pr_number: int,
671 marker: str | None = None,
672 status: str | None = None,
673 reason: str | None = None,
674 issue_number: int | None = None,
675) -> dict[str, Any]:
676 action = {
677 "type": action_type,
678 "pr": pr_number,
679 "idempotency_key": f"{action_type}:pr-{pr_number}",
680 }
681 if marker is not None:
682 action["marker"] = marker
683 if status is not None:
684 action["status"] = status
685 if reason is not None:
686 action["reason"] = reason
687 if issue_number is not None:
688 action["issue"] = issue_number
689 action["idempotency_key"] = f"{action_type}:issue-{issue_number}:pr-{pr_number}"
690 return action
693def _capture_policy(config: cfg.ProjectConfig | None) -> dict[str, Any]:
694 if config is None or not isinstance(config.policy_pack, dict):
695 return {}
696 policy = config.policy_pack.get("capture")
697 return policy if isinstance(policy, dict) else {}
700def _learning_policy(config: cfg.ProjectConfig | None) -> dict[str, Any]:
701 policy = _capture_policy(config)
702 learning = policy.get("learning") if isinstance(policy, dict) else None
703 return learning if isinstance(learning, dict) else {}
706def _learning_dedupe_enabled(policy: dict[str, Any]) -> bool:
707 dedupe = policy.get("dedupe")
708 if not isinstance(dedupe, dict):
709 return True
710 return bool(dedupe.get("enabled", True))
713def _marker_reason(status: str, reason: str | None) -> str | None:
714 raw = status.strip()
715 if raw.startswith("skipped:"):
716 return None
717 if raw != "skipped":
718 return None
719 if reason in SKIP_REASONS:
720 return reason
721 return "no-policy"
724def _strings(value: Any) -> list[str]:
725 if not isinstance(value, list | tuple):
726 return []
727 return [item for item in value if isinstance(item, str)]
730def _positive_ints(value: Any) -> list[int]:
731 if not isinstance(value, list | tuple):
732 return []
733 return [item for item in value if isinstance(item, int) and item > 0]
736def _duplicate_learning_fingerprint(
737 fingerprint: str,
738 records: list[dict[str, Any]] | tuple[dict[str, Any], ...],
739) -> str | None:
740 for record in records:
741 if not isinstance(record, dict):
742 continue
743 capture_block = record.get("capture")
744 learning = capture_block.get("learning") if isinstance(capture_block, dict) else None
745 if not isinstance(learning, dict):
746 continue
747 if learning.get("fingerprint") != fingerprint:
748 continue
749 decision = learning.get("decision")
750 if decision in {"create-learning", "duplicate"}:
751 return str(record.get("run_id") or (record.get("pull_request") or {}).get("number"))
752 return None
755def _learning_result(
756 decision: str,
757 *,
758 reason: str,
759 fingerprint: str,
760 policy: dict[str, Any],
761 duplicate_of: str | None = None,
762) -> dict[str, Any]:
763 if decision not in LEARNING_DECISIONS:
764 raise CaptureError(f"unsupported learning decision: {decision}")
765 result = {
766 "schema_version": LEARNING_DECISION_SCHEMA_VERSION,
767 "decision": decision,
768 "reason": reason,
769 "fingerprint": fingerprint,
770 "policy_source": "policy_pack.capture.learning",
771 "policy_mode": policy.get("mode", "policy-unavailable"),
772 "durable_artifact": decision == "create-learning",
773 }
774 if duplicate_of is not None:
775 result["duplicate_of"] = duplicate_of
776 return result
779def _policy_reason(policy: dict[str, Any], default: str) -> str:
780 reason = policy.get("reason")
781 return reason.strip() if isinstance(reason, str) and reason.strip() else default
784def _normalize_text(value: str | None) -> str:
785 return " ".join(value.lower().split()) if isinstance(value, str) else ""
788def _normalize_path(value: str) -> str:
789 return "/".join(value.strip().lower().replace("\\", "/").split("/"))
792def retrieve_relevant_learnings(
793 query_text: str,
794 learning_dir: str | Path,
795 *,
796 max_results: int = 3,
797 min_score: int = 1,
798) -> list[dict[str, Any]]:
799 """Retrieve relevant historical learning records for an issue or task.
801 Pure, stdlib-first token matching against Markdown or JSON learning files
802 in ``learning_dir`` (e.g. ``.keel/learning/``). Returns the top matching lessons
803 to be injected into implementation / review contexts.
804 """
805 path = Path(learning_dir)
806 if not path.is_dir():
807 return []
809 tokens = {
810 w.lower()
811 for w in re.findall(r"[A-Za-z0-9_-]{3,}", query_text)
812 if w.lower() not in {"the", "and", "for", "with", "this", "that", "issue", "feat", "fix"}
813 }
814 if not tokens:
815 return []
817 results: list[dict[str, Any]] = []
818 for file_path in sorted(path.glob("*")):
819 if not file_path.is_file() or file_path.suffix not in {".md", ".json", ".txt"}:
820 continue
821 try:
822 content = file_path.read_text(encoding="utf-8", errors="replace")
823 except OSError:
824 continue
826 score = 0
827 content_lower = content.lower()
828 filename_lower = file_path.name.lower()
830 for token in tokens:
831 if token in filename_lower:
832 score += 3
833 count = content_lower.count(token)
834 if count > 0:
835 score += min(count, 5)
837 if score >= min_score:
838 lines = [line.strip() for line in content.splitlines() if line.strip()]
839 title = lines[0].lstrip("#").strip() if lines else file_path.name
840 summary = lines[1] if len(lines) > 1 else ""
841 results.append({
842 "file": file_path.name,
843 "path": str(file_path),
844 "title": title,
845 "summary": summary,
846 "score": score,
847 })
849 results.sort(key=lambda r: (-r["score"], r["file"]))
850 return results[:max_results]