Files
beaver_project/app-instance/backend/beaver/skills/learning/worker.py
steven_li 8aeb97a5fc feat(app): 移除内置agents并添加CORS支持和技能上传优化
移除了agents/registry.json中的所有内置agents配置,将agents数组清空。
为web应用添加了CORS中间件支持,允许指定的前端地址跨域访问。
重构了技能上传功能,增加了LLM重写机制,自动规范化上传的技能格式。
新增了工具名称提取逻辑,从技能正文中自动识别Required Tools段落。
更新了技能学习候选者和草稿的载荷结构,添加评估报告统计信息。
修改了意图路由技能的说明,改进任务状态管理逻辑。
2026-06-12 13:25:20 +08:00

180 lines
6.5 KiB
Python

"""Background worker for assisted skill learning."""
from __future__ import annotations
import asyncio
import os
from dataclasses import dataclass, field
from typing import Callable
from beaver.engine.providers import ProviderBundle
from beaver.memory.skills import SkillLearningCandidate
from beaver.skills.learning.pipeline import SkillLearningPipelineService
from beaver.skills.learning.replay import ReplayRunner
@dataclass(slots=True)
class SkillLearningWorkerConfig:
enabled: bool = True
max_drafts_per_run: int = 5
max_retries: int = 3
interval_seconds: float = 300.0
@classmethod
def from_env(cls) -> "SkillLearningWorkerConfig":
return cls(
enabled=_env_bool("BEAVER_SKILL_LEARNING_WORKER_ENABLED", True),
max_drafts_per_run=_env_int("BEAVER_SKILL_LEARNING_MAX_DRAFTS_PER_RUN", 5),
max_retries=_env_int("BEAVER_SKILL_LEARNING_MAX_RETRIES", 3),
interval_seconds=float(os.getenv("BEAVER_SKILL_LEARNING_INTERVAL_SECONDS", "300") or "300"),
)
@dataclass(slots=True)
class SkillLearningWorkerResult:
processed: int = 0
succeeded: int = 0
failed: int = 0
skipped: int = 0
failures: list[dict[str, str]] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"processed": self.processed,
"succeeded": self.succeeded,
"failed": self.failed,
"skipped": self.skipped,
"failures": [dict(item) for item in self.failures],
}
class SkillLearningWorker:
"""Synthesizes drafts for open candidates; never approves or publishes."""
_ACTIVE_DRAFT_STATUSES = {"queued", "synthesizing", "draft_ready", "review_pending", "approved"}
def __init__(
self,
*,
pipeline: SkillLearningPipelineService,
provider_bundle_factory: Callable[[], ProviderBundle],
replay_runner_factory: Callable[[], ReplayRunner] | None = None,
config: SkillLearningWorkerConfig | None = None,
) -> None:
self.pipeline = pipeline
self.provider_bundle_factory = provider_bundle_factory
self.replay_runner_factory = replay_runner_factory
self.config = config or SkillLearningWorkerConfig.from_env()
self._running = False
self._lock = asyncio.Lock()
async def run_forever(self) -> None:
if not self.config.enabled:
return
self._running = True
try:
while self._running:
await self.run_once()
await asyncio.sleep(self.config.interval_seconds)
finally:
self._running = False
def stop(self) -> None:
self._running = False
async def run_once(self) -> SkillLearningWorkerResult:
if not self.config.enabled:
return SkillLearningWorkerResult()
async with self._lock:
result = SkillLearningWorkerResult()
candidates = self._select_candidates()
for candidate in candidates[: self.config.max_drafts_per_run]:
result.processed += 1
try:
handled = await self._process_candidate(candidate)
if handled:
result.succeeded += 1
else:
result.skipped += 1
except Exception as exc:
result.failed += 1
result.failures.append({"candidate_id": candidate.candidate_id, "error": str(exc)})
self._mark_failure(candidate, str(exc))
return result
def _select_candidates(self) -> list[SkillLearningCandidate]:
candidates = [
item
for item in self.pipeline.list_candidates()
if item.status == "open" and item.retry_count < self.config.max_retries
]
return sorted(candidates, key=lambda item: (item.priority, item.confidence, item.created_at), reverse=True)
async def _process_candidate(self, candidate: SkillLearningCandidate) -> bool:
if self._has_active_draft(candidate):
self.pipeline.mark_candidate_superseded(candidate.candidate_id, "active draft already exists for this skill")
return False
self.pipeline.mark_candidate_queued(candidate.candidate_id)
self.pipeline.mark_candidate_synthesizing(candidate.candidate_id)
draft = await self.pipeline.synthesize_draft(
candidate.candidate_id,
provider_bundle=self.provider_bundle_factory(),
)
self.pipeline.mark_draft_synthesized(candidate.candidate_id, draft)
safety = self.pipeline.check_safety(draft.skill_name, draft.draft_id)
if not safety.passed or safety.risk_level == "critical":
return True
await self.pipeline.evaluate_draft(
candidate.candidate_id,
draft.skill_name,
draft.draft_id,
provider_bundle=self.provider_bundle_factory(),
replay_runner=self.replay_runner_factory() if self.replay_runner_factory is not None else None,
)
return True
def _has_active_draft(self, candidate: SkillLearningCandidate) -> bool:
target_names = set(candidate.related_skill_names)
if candidate.draft_skill_name:
target_names.add(candidate.draft_skill_name)
if not target_names:
return False
for item in self.pipeline.list_candidates():
if item.candidate_id == candidate.candidate_id:
continue
if item.status not in self._ACTIVE_DRAFT_STATUSES:
continue
item_names = set(item.related_skill_names)
if item.draft_skill_name:
item_names.add(item.draft_skill_name)
if target_names.intersection(item_names):
return True
return False
def _mark_failure(self, candidate: SkillLearningCandidate, error: str) -> None:
retry_count = candidate.retry_count + 1
status = "failed" if retry_count >= self.config.max_retries else "open"
self.pipeline.mark_candidate_failed(
candidate.candidate_id,
error,
retry_count=retry_count,
terminal=(status == "failed"),
)
def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() not in {"0", "false", "no", "off"}
def _env_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw in (None, ""):
return default
try:
return int(raw)
except ValueError:
return default