移除了agents/registry.json中的所有内置agents配置,将agents数组清空。 为web应用添加了CORS中间件支持,允许指定的前端地址跨域访问。 重构了技能上传功能,增加了LLM重写机制,自动规范化上传的技能格式。 新增了工具名称提取逻辑,从技能正文中自动识别Required Tools段落。 更新了技能学习候选者和草稿的载荷结构,添加评估报告统计信息。 修改了意图路由技能的说明,改进任务状态管理逻辑。
388 lines
15 KiB
Python
388 lines
15 KiB
Python
"""Manual skill learning pipeline orchestration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from beaver.engine.providers import ProviderBundle
|
|
from beaver.memory.skills import SkillDraftEvalReport, SkillDraftSafetyReport, SkillLearningCandidate, SkillLearningStore
|
|
from beaver.skills.drafts import DraftService
|
|
from beaver.skills.learning.eval import SkillDraftEvaluator
|
|
from beaver.skills.learning.replay import ReplayRunner
|
|
from beaver.skills.learning.service import SkillLearningService
|
|
from beaver.skills.learning.safety import SkillDraftSafetyChecker
|
|
from beaver.skills.publisher import SkillPublisher
|
|
from beaver.skills.reviews import ReviewService
|
|
from beaver.skills.specs import SkillDraft, SkillReviewRecord, SkillReviewState, SkillSpec, SkillVersion
|
|
|
|
_REJECTABLE_DRAFT_STATUSES = {
|
|
SkillReviewState.DRAFT.value,
|
|
SkillReviewState.IN_REVIEW.value,
|
|
SkillReviewState.APPROVED.value,
|
|
}
|
|
|
|
|
|
class SkillLearningPipelineService:
|
|
"""Coordinates candidate -> draft -> review -> publish lifecycle."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
learning_store: SkillLearningStore,
|
|
learning_service: SkillLearningService,
|
|
draft_service: DraftService,
|
|
review_service: ReviewService,
|
|
publisher: SkillPublisher,
|
|
safety_checker: SkillDraftSafetyChecker | None = None,
|
|
evaluator: SkillDraftEvaluator | None = None,
|
|
) -> None:
|
|
self.learning_store = learning_store
|
|
self.learning_service = learning_service
|
|
self.draft_service = draft_service
|
|
self.review_service = review_service
|
|
self.publisher = publisher
|
|
self.safety_checker = safety_checker or SkillDraftSafetyChecker()
|
|
self.evaluator = evaluator
|
|
|
|
def list_candidates(self, status: str | None = None) -> list[SkillLearningCandidate]:
|
|
return self.learning_store.list_learning_candidates(status=status)
|
|
|
|
def get_candidate(self, candidate_id: str) -> SkillLearningCandidate:
|
|
for candidate in self.learning_store.list_learning_candidates():
|
|
if candidate.candidate_id == candidate_id:
|
|
return candidate
|
|
raise ValueError(f"Unknown learning candidate: {candidate_id}")
|
|
|
|
async def synthesize_draft(
|
|
self,
|
|
candidate_id: str,
|
|
*,
|
|
provider_bundle: ProviderBundle,
|
|
) -> SkillDraft:
|
|
draft = await self.learning_service.synthesize_draft(candidate_id, provider_bundle)
|
|
self.mark_draft_synthesized(candidate_id, draft)
|
|
return draft
|
|
|
|
async def regenerate_draft(
|
|
self,
|
|
candidate_id: str,
|
|
*,
|
|
provider_bundle: ProviderBundle,
|
|
) -> SkillDraft:
|
|
self.learning_store.transition_learning_candidate(
|
|
candidate_id,
|
|
"synthesizing",
|
|
event_type="draft_synthesis_started",
|
|
last_error=None,
|
|
)
|
|
return await self.synthesize_draft(candidate_id, provider_bundle=provider_bundle)
|
|
|
|
def mark_candidate_queued(self, candidate_id: str) -> SkillLearningCandidate:
|
|
return self._require_updated(
|
|
self.learning_store.transition_learning_candidate(
|
|
candidate_id,
|
|
"queued",
|
|
event_type="candidate_queued",
|
|
last_error=None,
|
|
),
|
|
candidate_id,
|
|
)
|
|
|
|
def mark_candidate_synthesizing(self, candidate_id: str) -> SkillLearningCandidate:
|
|
return self._require_updated(
|
|
self.learning_store.transition_learning_candidate(
|
|
candidate_id,
|
|
"synthesizing",
|
|
event_type="draft_synthesis_started",
|
|
last_error=None,
|
|
),
|
|
candidate_id,
|
|
)
|
|
|
|
def mark_draft_synthesized(self, candidate_id: str, draft: SkillDraft) -> SkillLearningCandidate:
|
|
candidate = self.get_candidate(candidate_id)
|
|
evidence = dict(candidate.evidence)
|
|
evidence["draft_id"] = draft.draft_id
|
|
evidence["draft_skill_name"] = draft.skill_name
|
|
return self._require_updated(
|
|
self.learning_store.transition_learning_candidate(
|
|
candidate_id,
|
|
"draft_ready",
|
|
event_type="draft_synthesis_completed",
|
|
evidence=evidence,
|
|
draft_id=draft.draft_id,
|
|
draft_skill_name=draft.skill_name,
|
|
risk_level=candidate.risk_level,
|
|
last_error=None,
|
|
payload={"draft_id": draft.draft_id, "skill_name": draft.skill_name},
|
|
),
|
|
candidate_id,
|
|
)
|
|
|
|
def mark_candidate_failed(
|
|
self,
|
|
candidate_id: str,
|
|
error: str,
|
|
*,
|
|
retry_count: int,
|
|
terminal: bool,
|
|
) -> SkillLearningCandidate:
|
|
return self._require_updated(
|
|
self.learning_store.transition_learning_candidate(
|
|
candidate_id,
|
|
"failed" if terminal else "open",
|
|
event_type="failed",
|
|
retry_count=retry_count,
|
|
last_error=error,
|
|
payload={"error": error, "terminal": terminal, "retry_count": retry_count},
|
|
),
|
|
candidate_id,
|
|
)
|
|
|
|
def mark_candidate_superseded(self, candidate_id: str, reason: str) -> SkillLearningCandidate:
|
|
return self._require_updated(
|
|
self.learning_store.transition_learning_candidate(
|
|
candidate_id,
|
|
"superseded",
|
|
event_type="superseded",
|
|
last_error=reason,
|
|
payload={"reason": reason},
|
|
),
|
|
candidate_id,
|
|
)
|
|
|
|
def list_drafts(self, skill_name: str | None = None) -> list[SkillDraft]:
|
|
return self.draft_service.list_drafts(skill_name)
|
|
|
|
def get_draft(self, skill_name: str, draft_id: str) -> SkillDraft:
|
|
draft = self.draft_service.get_draft(skill_name, draft_id)
|
|
if draft is None:
|
|
raise ValueError(f"Draft not found: {skill_name}/{draft_id}")
|
|
return draft
|
|
|
|
def submit_review(
|
|
self,
|
|
skill_name: str,
|
|
draft_id: str,
|
|
*,
|
|
requested_by: str = "system",
|
|
notes: str = "",
|
|
) -> SkillReviewRecord:
|
|
draft = self.get_draft(skill_name, draft_id)
|
|
if draft.status != SkillReviewState.DRAFT.value:
|
|
raise ValueError("Draft must be in draft status before review submission")
|
|
safety = self.get_safety_report(skill_name, draft_id)
|
|
if safety is not None and (not safety.passed or safety.risk_level == "critical"):
|
|
raise ValueError("Draft cannot enter review because safety check failed")
|
|
return self.review_service.submit_for_review(
|
|
skill_name,
|
|
draft_id,
|
|
reviewer_request=notes,
|
|
requested_by=requested_by,
|
|
)
|
|
|
|
def approve(
|
|
self,
|
|
skill_name: str,
|
|
draft_id: str,
|
|
*,
|
|
reviewer: str = "system",
|
|
notes: str = "",
|
|
) -> SkillReviewRecord:
|
|
draft = self.get_draft(skill_name, draft_id)
|
|
if draft.status != SkillReviewState.IN_REVIEW.value:
|
|
raise ValueError("Draft must be in review before approval")
|
|
safety = self.get_safety_report(skill_name, draft_id)
|
|
if safety is not None and (not safety.passed or safety.risk_level == "critical"):
|
|
raise ValueError("Draft cannot be approved because safety check failed")
|
|
review = self.review_service.approve(skill_name, draft_id, reviewer=reviewer, notes=notes)
|
|
self._mark_candidate_by_draft(skill_name, draft_id, "approved", "approved")
|
|
return review
|
|
|
|
def reject(
|
|
self,
|
|
skill_name: str,
|
|
draft_id: str,
|
|
*,
|
|
reviewer: str = "system",
|
|
notes: str = "",
|
|
) -> SkillReviewRecord:
|
|
draft = self.get_draft(skill_name, draft_id)
|
|
if draft.status not in _REJECTABLE_DRAFT_STATUSES:
|
|
raise ValueError("Draft is not rejectable from its current status")
|
|
review = self.review_service.reject(skill_name, draft_id, reviewer=reviewer, notes=notes)
|
|
self._mark_candidate_by_draft(skill_name, draft_id, "rejected", "rejected")
|
|
return review
|
|
|
|
def publish(
|
|
self,
|
|
skill_name: str,
|
|
draft_id: str,
|
|
*,
|
|
publisher: str = "system",
|
|
notes: str = "",
|
|
confirm_high_risk: bool = False,
|
|
) -> SkillVersion | SkillSpec:
|
|
draft = self.get_draft(skill_name, draft_id)
|
|
self._validate_publish_gates(draft, confirm_high_risk=confirm_high_risk)
|
|
if draft.proposal_kind == "retire_skill":
|
|
result = self.publisher.apply_retire_proposal(skill_name, draft_id, actor=publisher, notes=notes)
|
|
else:
|
|
result = self.publisher.publish(skill_name, draft_id, publisher=publisher, notes=notes)
|
|
self._mark_candidate_by_draft(skill_name, draft_id, "published", "published")
|
|
return result
|
|
|
|
def rollback(
|
|
self,
|
|
skill_name: str,
|
|
target_version: str,
|
|
*,
|
|
actor: str = "system",
|
|
reason: str = "",
|
|
) -> SkillSpec:
|
|
return self.publisher.rollback(skill_name, target_version, actor=actor, reason=reason or "manual rollback")
|
|
|
|
def disable(
|
|
self,
|
|
skill_name: str,
|
|
*,
|
|
actor: str = "system",
|
|
reason: str = "",
|
|
) -> SkillSpec:
|
|
return self.publisher.disable(skill_name, actor=actor, reason=reason or "manual disable")
|
|
|
|
def reviews_for_draft(self, skill_name: str, draft_id: str) -> list[SkillReviewRecord]:
|
|
return self.review_service.store.list_reviews(skill_name, draft_id=draft_id)
|
|
|
|
def check_safety(self, skill_name: str, draft_id: str) -> SkillDraftSafetyReport:
|
|
draft = self.get_draft(skill_name, draft_id)
|
|
report = self.safety_checker.check(draft)
|
|
self.learning_store.write_safety_report(report)
|
|
status = "safety_failed" if not report.passed or report.risk_level == "critical" else "draft_ready"
|
|
current = self._candidate_by_draft(skill_name, draft_id)
|
|
if current is not None and current.status == "eval_failed" and status == "draft_ready":
|
|
status = "eval_failed"
|
|
self._mark_candidate_by_draft(
|
|
skill_name,
|
|
draft_id,
|
|
status,
|
|
"safety_checked",
|
|
safety_report_id=report.report_id,
|
|
risk_level=report.risk_level,
|
|
last_error="; ".join(report.blocked_reasons) if status == "safety_failed" else None,
|
|
)
|
|
return report
|
|
|
|
def get_safety_report(self, skill_name: str, draft_id: str) -> SkillDraftSafetyReport | None:
|
|
return self.learning_store.get_safety_report(skill_name, draft_id)
|
|
|
|
def get_eval_report(self, skill_name: str, draft_id: str) -> SkillDraftEvalReport | None:
|
|
return self.learning_store.get_eval_report(skill_name, draft_id)
|
|
|
|
async def evaluate_draft(
|
|
self,
|
|
candidate_id: str,
|
|
skill_name: str,
|
|
draft_id: str,
|
|
*,
|
|
provider_bundle: ProviderBundle | None,
|
|
replay_runner: ReplayRunner | None = None,
|
|
) -> SkillDraftEvalReport:
|
|
draft = self.get_draft(skill_name, draft_id)
|
|
candidate = self.get_candidate(candidate_id)
|
|
evaluator = self.evaluator or SkillDraftEvaluator(self.learning_service.run_store)
|
|
report = await evaluator.evaluate(
|
|
candidate=candidate,
|
|
draft=draft,
|
|
provider_bundle=provider_bundle,
|
|
replay_runner=replay_runner,
|
|
)
|
|
self.learning_store.write_eval_report(report)
|
|
if report.status == "skipped_provider_unavailable":
|
|
status = "draft_ready"
|
|
error = "eval skipped: provider unavailable"
|
|
elif report.passed:
|
|
status = "draft_ready"
|
|
error = None
|
|
else:
|
|
status = "eval_failed"
|
|
error = "eval failed"
|
|
current = self._candidate_by_draft(skill_name, draft_id)
|
|
if current is not None and current.status == "safety_failed" and status == "draft_ready":
|
|
status = "safety_failed"
|
|
error = current.last_error
|
|
self.learning_store.transition_learning_candidate(
|
|
candidate_id,
|
|
status,
|
|
event_type="eval_completed",
|
|
eval_report_id=report.report_id,
|
|
last_error=error,
|
|
payload=report.to_dict(),
|
|
)
|
|
return report
|
|
|
|
def _validate_publish_gates(self, draft: SkillDraft, *, confirm_high_risk: bool) -> None:
|
|
reviews = self.reviews_for_draft(draft.skill_name, draft.draft_id)
|
|
if not any(review.status in {SkillReviewState.IN_REVIEW.value, SkillReviewState.APPROVED.value} for review in reviews):
|
|
raise ValueError("Draft must be submitted for review before publish")
|
|
safety = self.get_safety_report(draft.skill_name, draft.draft_id)
|
|
if safety is None:
|
|
raise ValueError("Draft requires a passing safety report before publish")
|
|
if not safety.passed:
|
|
raise ValueError("Draft safety report did not pass")
|
|
if safety.risk_level == "critical":
|
|
raise ValueError("Critical risk drafts cannot be published")
|
|
if safety.risk_level == "high" and not confirm_high_risk:
|
|
raise ValueError("High risk draft publish requires confirm_high_risk=true")
|
|
eval_report = self.get_eval_report(draft.skill_name, draft.draft_id)
|
|
if eval_report is not None and eval_report.status != "skipped_provider_unavailable" and not eval_report.passed:
|
|
raise ValueError("Draft eval report did not pass")
|
|
if eval_report is not None and eval_report.mode == "replay":
|
|
if eval_report.confidence == "low":
|
|
raise ValueError("Draft replay eval has low confidence and requires revision before publish")
|
|
if eval_report.blocked_coverage >= 1.0:
|
|
raise ValueError("Draft replay eval blocked all important tool calls")
|
|
preservation = eval_report.preservation_report or {}
|
|
if preservation.get("passed") is False:
|
|
raise ValueError("Draft preservation check did not pass")
|
|
|
|
def _mark_candidate_by_draft(
|
|
self,
|
|
skill_name: str,
|
|
draft_id: str,
|
|
status: str,
|
|
event_type: str,
|
|
**updates: object,
|
|
) -> SkillLearningCandidate | None:
|
|
candidate = self._candidate_by_draft(skill_name, draft_id)
|
|
if candidate is None:
|
|
return None
|
|
if candidate.status in {"safety_failed", "eval_failed"} and status in {"review_pending", "approved"}:
|
|
return candidate
|
|
return self.learning_store.transition_learning_candidate(
|
|
candidate.candidate_id,
|
|
status,
|
|
event_type=event_type,
|
|
**updates,
|
|
)
|
|
|
|
def _candidate_by_draft(self, skill_name: str, draft_id: str) -> SkillLearningCandidate | None:
|
|
for candidate in self.learning_store.list_learning_candidates():
|
|
if candidate.draft_skill_name == skill_name and candidate.draft_id == draft_id:
|
|
return candidate
|
|
return None
|
|
|
|
@staticmethod
|
|
def _require_updated(candidate: SkillLearningCandidate | None, candidate_id: str) -> SkillLearningCandidate:
|
|
if candidate is None:
|
|
raise ValueError(f"Unknown learning candidate: {candidate_id}")
|
|
return candidate
|
|
|
|
|
|
def model_to_dict(value: Any) -> dict[str, Any]:
|
|
if hasattr(value, "to_dict"):
|
|
return value.to_dict()
|
|
if isinstance(value, dict):
|
|
return dict(value)
|
|
raise TypeError(f"Cannot convert {type(value).__name__} to dict")
|