feat(plugins): enqueue skill upgrade candidates
This commit is contained in:
@ -4,7 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import threading
|
||||
from uuid import uuid4
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
|
||||
from beaver.foundation.utils.file_lock import WorkspaceWriteLock
|
||||
|
||||
from .models import (
|
||||
SkillDraftEvalReport,
|
||||
@ -16,9 +21,11 @@ from .models import (
|
||||
|
||||
|
||||
class SkillLearningStore:
|
||||
def __init__(self, root: str | Path) -> None:
|
||||
def __init__(self, root: str | Path, *, write_lock: WorkspaceWriteLock | None = None) -> None:
|
||||
self.root = Path(root)
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self.write_lock = write_lock
|
||||
self._local_lock = threading.RLock()
|
||||
self.performance_path = self.root / "performance.jsonl"
|
||||
self.candidates_path = self.root / "learning-candidates.jsonl"
|
||||
self.audit_path = self.root / "learning-audit.jsonl"
|
||||
@ -26,42 +33,58 @@ class SkillLearningStore:
|
||||
self.eval_reports_dir = self.root / "eval-reports"
|
||||
|
||||
def record_learning_candidate(self, candidate: SkillLearningCandidate) -> None:
|
||||
self.record_learning_candidate_if_absent(candidate)
|
||||
|
||||
def record_learning_candidate_if_absent(
|
||||
self,
|
||||
candidate: SkillLearningCandidate,
|
||||
) -> tuple[SkillLearningCandidate, bool]:
|
||||
normalized = SkillLearningCandidate.from_dict(candidate.to_dict())
|
||||
self._append_jsonl(self.candidates_path, normalized.to_dict())
|
||||
self.append_audit_event(
|
||||
normalized.candidate_id,
|
||||
"candidate_created",
|
||||
{
|
||||
"kind": normalized.kind,
|
||||
"status": normalized.status,
|
||||
"reason": normalized.reason,
|
||||
},
|
||||
)
|
||||
with self._locked():
|
||||
existing = {
|
||||
item.candidate_id: item
|
||||
for item in self.list_learning_candidates()
|
||||
}
|
||||
found = existing.get(normalized.candidate_id)
|
||||
if found is not None:
|
||||
return found, False
|
||||
self._append_jsonl(self.candidates_path, normalized.to_dict())
|
||||
self.append_audit_event(
|
||||
normalized.candidate_id,
|
||||
"candidate_created",
|
||||
{
|
||||
"kind": normalized.kind,
|
||||
"status": normalized.status,
|
||||
"reason": normalized.reason,
|
||||
},
|
||||
)
|
||||
return normalized, True
|
||||
|
||||
def update_learning_candidate(self, candidate_id: str, **updates: object) -> SkillLearningCandidate | None:
|
||||
candidates = self.list_learning_candidates()
|
||||
updated: SkillLearningCandidate | None = None
|
||||
for index, candidate in enumerate(candidates):
|
||||
if candidate.candidate_id != candidate_id:
|
||||
continue
|
||||
payload = candidate.to_dict()
|
||||
payload.update(updates)
|
||||
if "updated_at" not in updates:
|
||||
payload["updated_at"] = _utc_now()
|
||||
updated = SkillLearningCandidate.from_dict(payload)
|
||||
candidates[index] = updated
|
||||
break
|
||||
if updated is None:
|
||||
return None
|
||||
self.candidates_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.candidates_path.write_text(
|
||||
"".join(
|
||||
json.dumps(candidate.to_dict(), ensure_ascii=False, sort_keys=True) + "\n"
|
||||
for candidate in candidates
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return updated
|
||||
with self._locked():
|
||||
candidates = self.list_learning_candidates()
|
||||
updated: SkillLearningCandidate | None = None
|
||||
for index, candidate in enumerate(candidates):
|
||||
if candidate.candidate_id != candidate_id:
|
||||
continue
|
||||
payload = candidate.to_dict()
|
||||
payload.update(updates)
|
||||
if "updated_at" not in updates:
|
||||
payload["updated_at"] = _utc_now()
|
||||
updated = SkillLearningCandidate.from_dict(payload)
|
||||
candidates[index] = updated
|
||||
break
|
||||
if updated is None:
|
||||
return None
|
||||
self.candidates_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.candidates_path.write_text(
|
||||
"".join(
|
||||
json.dumps(candidate.to_dict(), ensure_ascii=False, sort_keys=True) + "\n"
|
||||
for candidate in candidates
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return updated
|
||||
|
||||
def transition_learning_candidate(
|
||||
self,
|
||||
@ -209,6 +232,15 @@ class SkillLearningStore:
|
||||
raise ValueError(f"Expected JSON object in {path}")
|
||||
return payload
|
||||
|
||||
@contextmanager
|
||||
def _locked(self) -> Iterator[None]:
|
||||
if self.write_lock is not None:
|
||||
with self.write_lock.acquire(timeout_seconds=10):
|
||||
yield
|
||||
return
|
||||
with self._local_lock:
|
||||
yield
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
Reference in New Issue
Block a user