buildlog 0.2.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,12 +6,14 @@ MCP, CLI, HTTP, or any other interface.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import hashlib
9
10
  import json
10
11
  from dataclasses import dataclass, field
11
- from datetime import datetime
12
+ from datetime import datetime, timezone
12
13
  from pathlib import Path
13
- from typing import Literal
14
+ from typing import Literal, TypedDict
14
15
 
16
+ from buildlog.confidence import ConfidenceMetrics, merge_confidence_metrics
15
17
  from buildlog.render import get_renderer
16
18
  from buildlog.skills import Skill, SkillSet, generate_skills
17
19
 
@@ -20,11 +22,15 @@ __all__ = [
20
22
  "PromoteResult",
21
23
  "RejectResult",
22
24
  "DiffResult",
25
+ "ReviewIssue",
26
+ "ReviewLearning",
27
+ "LearnFromReviewResult",
23
28
  "status",
24
29
  "promote",
25
30
  "reject",
26
31
  "diff",
27
32
  "find_skills_by_ids",
33
+ "learn_from_review",
28
34
  ]
29
35
 
30
36
 
@@ -108,6 +114,175 @@ class DiffResult:
108
114
  """Error message if operation failed."""
109
115
 
110
116
 
117
+ # -----------------------------------------------------------------------------
118
+ # Review Learning Data Structures
119
+ # -----------------------------------------------------------------------------
120
+
121
+
122
+ class ReviewIssueDict(TypedDict, total=False):
123
+ """Serializable form of ReviewIssue."""
124
+
125
+ severity: str
126
+ category: str
127
+ description: str
128
+ rule_learned: str
129
+ location: str | None
130
+ why_it_matters: str | None
131
+ functional_principle: str | None
132
+
133
+
134
+ @dataclass
135
+ class ReviewIssue:
136
+ """A single issue identified during code review.
137
+
138
+ Attributes:
139
+ severity: How serious the issue is (critical/major/minor/nitpick).
140
+ category: What kind of issue (architectural/workflow/tool_usage/domain_knowledge).
141
+ description: What's wrong (concrete).
142
+ rule_learned: The generalizable rule extracted from this issue.
143
+ location: File:line where the issue was found.
144
+ why_it_matters: Why this issue matters (consequences).
145
+ functional_principle: Related FP principle, if applicable.
146
+ """
147
+
148
+ severity: Literal["critical", "major", "minor", "nitpick"]
149
+ category: Literal["architectural", "workflow", "tool_usage", "domain_knowledge"]
150
+ description: str
151
+ rule_learned: str
152
+ location: str | None = None
153
+ why_it_matters: str | None = None
154
+ functional_principle: str | None = None
155
+
156
+ @classmethod
157
+ def from_dict(cls, data: dict) -> "ReviewIssue":
158
+ """Construct from dictionary (e.g., from JSON)."""
159
+ return cls(
160
+ severity=data.get("severity", "minor"),
161
+ category=data.get("category", "workflow"),
162
+ description=data.get("description", ""),
163
+ rule_learned=data.get("rule_learned", ""),
164
+ location=data.get("location"),
165
+ why_it_matters=data.get("why_it_matters"),
166
+ functional_principle=data.get("functional_principle"),
167
+ )
168
+
169
+
170
+ class ReviewLearningDict(TypedDict, total=False):
171
+ """Serializable form of ReviewLearning."""
172
+
173
+ id: str
174
+ rule: str
175
+ category: str
176
+ severity: str
177
+ source: str
178
+ first_seen: str
179
+ last_reinforced: str
180
+ reinforcement_count: int
181
+ contradiction_count: int
182
+ functional_principle: str | None
183
+
184
+
185
+ @dataclass
186
+ class ReviewLearning:
187
+ """A learning extracted from review, with confidence tracking.
188
+
189
+ Attributes:
190
+ id: Deterministic hash of rule_learned (category prefix + hash).
191
+ rule: The generalizable rule text.
192
+ category: Category of the learning.
193
+ severity: Severity of the original issue.
194
+ source: Where this learning came from (e.g., "review:PR#13").
195
+ first_seen: When this rule was first identified.
196
+ last_reinforced: When this rule was last seen/reinforced.
197
+ reinforcement_count: How many times this rule has been seen.
198
+ contradiction_count: How many times this rule was contradicted.
199
+ functional_principle: Related FP principle, if applicable.
200
+ """
201
+
202
+ id: str
203
+ rule: str
204
+ category: str
205
+ severity: str
206
+ source: str
207
+ first_seen: datetime
208
+ last_reinforced: datetime
209
+ reinforcement_count: int = 1
210
+ contradiction_count: int = 0
211
+ functional_principle: str | None = None
212
+
213
+ def to_confidence_metrics(self) -> ConfidenceMetrics:
214
+ """Convert to ConfidenceMetrics for scoring."""
215
+ return ConfidenceMetrics(
216
+ reinforcement_count=self.reinforcement_count,
217
+ last_reinforced=self.last_reinforced,
218
+ contradiction_count=self.contradiction_count,
219
+ first_seen=self.first_seen,
220
+ )
221
+
222
+ def to_dict(self) -> ReviewLearningDict:
223
+ """Convert to serializable dictionary."""
224
+ result: ReviewLearningDict = {
225
+ "id": self.id,
226
+ "rule": self.rule,
227
+ "category": self.category,
228
+ "severity": self.severity,
229
+ "source": self.source,
230
+ "first_seen": self.first_seen.isoformat(),
231
+ "last_reinforced": self.last_reinforced.isoformat(),
232
+ "reinforcement_count": self.reinforcement_count,
233
+ "contradiction_count": self.contradiction_count,
234
+ }
235
+ if self.functional_principle:
236
+ result["functional_principle"] = self.functional_principle
237
+ return result
238
+
239
+ @classmethod
240
+ def from_dict(cls, data: ReviewLearningDict) -> "ReviewLearning":
241
+ """Reconstruct from serialized dictionary."""
242
+ first_seen = datetime.fromisoformat(data["first_seen"])
243
+ last_reinforced = datetime.fromisoformat(data["last_reinforced"])
244
+
245
+ # Ensure timezone awareness
246
+ if first_seen.tzinfo is None:
247
+ first_seen = first_seen.replace(tzinfo=timezone.utc)
248
+ if last_reinforced.tzinfo is None:
249
+ last_reinforced = last_reinforced.replace(tzinfo=timezone.utc)
250
+
251
+ return cls(
252
+ id=data["id"],
253
+ rule=data["rule"],
254
+ category=data["category"],
255
+ severity=data["severity"],
256
+ source=data["source"],
257
+ first_seen=first_seen,
258
+ last_reinforced=last_reinforced,
259
+ reinforcement_count=data.get("reinforcement_count", 1),
260
+ contradiction_count=data.get("contradiction_count", 0),
261
+ functional_principle=data.get("functional_principle"),
262
+ )
263
+
264
+
265
+ @dataclass
266
+ class LearnFromReviewResult:
267
+ """Result of learning from a review.
268
+
269
+ Attributes:
270
+ new_learnings: IDs of newly created learnings.
271
+ reinforced_learnings: IDs of existing learnings that were reinforced.
272
+ total_issues_processed: Total number of issues processed.
273
+ source: Review source identifier.
274
+ message: Human-readable summary.
275
+ error: Error message if operation failed.
276
+ """
277
+
278
+ new_learnings: list[str]
279
+ reinforced_learnings: list[str]
280
+ total_issues_processed: int
281
+ source: str
282
+ message: str = ""
283
+ error: str | None = None
284
+
285
+
111
286
  def _get_rejected_path(buildlog_dir: Path) -> Path:
112
287
  """Get path to rejected.json file."""
113
288
  return buildlog_dir / ".buildlog" / "rejected.json"
@@ -386,3 +561,169 @@ def diff(
386
561
  already_promoted=len(promoted_ids),
387
562
  already_rejected=len(rejected_ids),
388
563
  )
564
+
565
+
566
+ # -----------------------------------------------------------------------------
567
+ # Review Learning Operations
568
+ # -----------------------------------------------------------------------------
569
+
570
+
571
+ def _get_learnings_path(buildlog_dir: Path) -> Path:
572
+ """Get path to review_learnings.json file."""
573
+ return buildlog_dir / ".buildlog" / "review_learnings.json"
574
+
575
+
576
+ def _generate_learning_id(category: str, rule: str) -> str:
577
+ """Generate deterministic ID for a learning.
578
+
579
+ Uses category prefix + first 10 chars of SHA256 hash.
580
+ """
581
+ # Normalize: lowercase, strip whitespace
582
+ normalized = rule.lower().strip()
583
+ hash_input = f"{category}:{normalized}".encode("utf-8")
584
+ hash_hex = hashlib.sha256(hash_input).hexdigest()[:10]
585
+
586
+ # Category prefix mapping
587
+ prefix_map = {
588
+ "architectural": "arch",
589
+ "workflow": "wf",
590
+ "tool_usage": "tool",
591
+ "domain_knowledge": "dom",
592
+ }
593
+ prefix = prefix_map.get(category, category[:4])
594
+
595
+ return f"{prefix}-{hash_hex}"
596
+
597
+
598
+ def _load_learnings(path: Path) -> dict:
599
+ """Load learnings from JSON file."""
600
+ if not path.exists():
601
+ return {"learnings": {}, "review_history": []}
602
+ try:
603
+ return json.loads(path.read_text())
604
+ except (json.JSONDecodeError, OSError):
605
+ return {"learnings": {}, "review_history": []}
606
+
607
+
608
+ def _save_learnings(path: Path, data: dict) -> None:
609
+ """Save learnings to JSON file."""
610
+ path.parent.mkdir(parents=True, exist_ok=True)
611
+ path.write_text(json.dumps(data, indent=2))
612
+
613
+
614
+ def learn_from_review(
615
+ buildlog_dir: Path,
616
+ issues: list[dict],
617
+ source: str | None = None,
618
+ ) -> LearnFromReviewResult:
619
+ """Capture learnings from a code review and update confidence metrics.
620
+
621
+ For each issue:
622
+ 1. Generate deterministic ID from rule text
623
+ 2. If exists: reinforce (increment count, update timestamp)
624
+ 3. If new: create ReviewLearning with initial metrics
625
+ 4. Persist to .buildlog/review_learnings.json
626
+
627
+ Args:
628
+ buildlog_dir: Path to buildlog directory.
629
+ issues: List of review issues with rule_learned field.
630
+ source: Optional source identifier (defaults to timestamp).
631
+
632
+ Returns:
633
+ LearnFromReviewResult with new/reinforced learning IDs.
634
+ """
635
+ if not issues:
636
+ return LearnFromReviewResult(
637
+ new_learnings=[],
638
+ reinforced_learnings=[],
639
+ total_issues_processed=0,
640
+ source=source or "",
641
+ error="No issues provided",
642
+ )
643
+
644
+ # Default source to timestamp
645
+ now = datetime.now(timezone.utc)
646
+ if source is None:
647
+ source = f"review:{now.isoformat()}"
648
+ elif not source.startswith("review:"):
649
+ source = f"review:{source}"
650
+
651
+ learnings_path = _get_learnings_path(buildlog_dir)
652
+ data = _load_learnings(learnings_path)
653
+
654
+ new_ids: list[str] = []
655
+ reinforced_ids: list[str] = []
656
+ processed = 0
657
+
658
+ for issue_dict in issues:
659
+ # Skip issues without rule_learned
660
+ rule = issue_dict.get("rule_learned", "").strip()
661
+ if not rule:
662
+ continue
663
+
664
+ # Parse issue
665
+ issue = ReviewIssue.from_dict(issue_dict)
666
+ learning_id = _generate_learning_id(issue.category, rule)
667
+
668
+ if learning_id in data["learnings"]:
669
+ # Reinforce existing learning
670
+ existing_data = data["learnings"][learning_id]
671
+ existing = ReviewLearning.from_dict(existing_data)
672
+
673
+ # Use merge_confidence_metrics pattern
674
+ updated_metrics = merge_confidence_metrics(
675
+ existing.to_confidence_metrics(), now
676
+ )
677
+
678
+ # Update the learning
679
+ existing_data["last_reinforced"] = now.isoformat()
680
+ existing_data["reinforcement_count"] = updated_metrics.reinforcement_count
681
+ reinforced_ids.append(learning_id)
682
+ else:
683
+ # Create new learning
684
+ learning = ReviewLearning(
685
+ id=learning_id,
686
+ rule=rule,
687
+ category=issue.category,
688
+ severity=issue.severity,
689
+ source=source,
690
+ first_seen=now,
691
+ last_reinforced=now,
692
+ reinforcement_count=1,
693
+ contradiction_count=0,
694
+ functional_principle=issue.functional_principle,
695
+ )
696
+ data["learnings"][learning_id] = learning.to_dict()
697
+ new_ids.append(learning_id)
698
+
699
+ processed += 1
700
+
701
+ # Record in review history
702
+ data["review_history"].append(
703
+ {
704
+ "timestamp": now.isoformat(),
705
+ "source": source,
706
+ "issues_count": processed,
707
+ "new_learning_ids": new_ids,
708
+ "reinforced_learning_ids": reinforced_ids,
709
+ }
710
+ )
711
+
712
+ # Persist
713
+ _save_learnings(learnings_path, data)
714
+
715
+ # Build message
716
+ msg_parts = []
717
+ if new_ids:
718
+ msg_parts.append(f"{len(new_ids)} new learning(s)")
719
+ if reinforced_ids:
720
+ msg_parts.append(f"{len(reinforced_ids)} reinforced")
721
+ message = ", ".join(msg_parts) if msg_parts else "No learnings captured"
722
+
723
+ return LearnFromReviewResult(
724
+ new_learnings=new_ids,
725
+ reinforced_learnings=reinforced_ids,
726
+ total_issues_processed=processed,
727
+ source=source,
728
+ message=message,
729
+ )
buildlog/mcp/__init__.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  from buildlog.mcp.tools import (
4
4
  buildlog_diff,
5
+ buildlog_learn_from_review,
5
6
  buildlog_promote,
6
7
  buildlog_reject,
7
8
  buildlog_status,
@@ -12,4 +13,5 @@ __all__ = [
12
13
  "buildlog_promote",
13
14
  "buildlog_reject",
14
15
  "buildlog_diff",
16
+ "buildlog_learn_from_review",
15
17
  ]
buildlog/mcp/server.py CHANGED
@@ -6,6 +6,7 @@ from mcp.server.fastmcp import FastMCP
6
6
 
7
7
  from buildlog.mcp.tools import (
8
8
  buildlog_diff,
9
+ buildlog_learn_from_review,
9
10
  buildlog_promote,
10
11
  buildlog_reject,
11
12
  buildlog_status,
@@ -18,6 +19,7 @@ mcp.tool()(buildlog_status)
18
19
  mcp.tool()(buildlog_promote)
19
20
  mcp.tool()(buildlog_reject)
20
21
  mcp.tool()(buildlog_diff)
22
+ mcp.tool()(buildlog_learn_from_review)
21
23
 
22
24
 
23
25
  def main() -> None:
buildlog/mcp/tools.py CHANGED
@@ -9,7 +9,7 @@ from dataclasses import asdict
9
9
  from pathlib import Path
10
10
  from typing import Literal
11
11
 
12
- from buildlog.core import diff, promote, reject, status
12
+ from buildlog.core import diff, learn_from_review, promote, reject, status
13
13
 
14
14
 
15
15
  def _validate_skill_ids(skill_ids: list[str]) -> list[str]:
@@ -95,3 +95,48 @@ def buildlog_diff(
95
95
  """
96
96
  result = diff(Path(buildlog_dir))
97
97
  return asdict(result)
98
+
99
+
100
+ def buildlog_learn_from_review(
101
+ issues: list[dict],
102
+ source: str | None = None,
103
+ buildlog_dir: str = "buildlog",
104
+ ) -> dict:
105
+ """Capture learnings from code review feedback.
106
+
107
+ Call this after a review loop completes to persist learnings.
108
+ Each issue's rule_learned becomes a tracked learning that gains
109
+ confidence through reinforcement.
110
+
111
+ Args:
112
+ issues: List of issues with structure:
113
+ {
114
+ "severity": "critical|major|minor|nitpick",
115
+ "category": "architectural|workflow|tool_usage|domain_knowledge",
116
+ "description": "What's wrong",
117
+ "rule_learned": "Generalizable rule",
118
+ "location": "file:line (optional)",
119
+ "why_it_matters": "Why this matters (optional)",
120
+ "functional_principle": "FP principle (optional)"
121
+ }
122
+ source: Optional identifier (e.g., "PR#13")
123
+ buildlog_dir: Path to buildlog directory
124
+
125
+ Returns:
126
+ Result with new_learnings, reinforced_learnings, total processed
127
+
128
+ Example:
129
+ buildlog_learn_from_review(
130
+ issues=[
131
+ {
132
+ "severity": "critical",
133
+ "category": "architectural",
134
+ "description": "Score bounds not validated",
135
+ "rule_learned": "Validate invariants at function boundaries"
136
+ }
137
+ ],
138
+ source="PR#13"
139
+ )
140
+ """
141
+ result = learn_from_review(Path(buildlog_dir), issues, source)
142
+ return asdict(result)