feat(learning): add learning runtime with pattern mining
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
19
backend/app/agents/learning/__init__.py
Normal file
19
backend/app/agents/learning/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from app.agents.learning.jobs import persist_retrospective, schedule_retrospective_job
|
||||
from app.agents.learning.pattern_miner import LearningPatternMiner
|
||||
from app.agents.learning.retrospector import build_session_retrospective
|
||||
from app.agents.learning.session_search import SessionRetrospectiveSearch
|
||||
from app.agents.learning.signal_extractor import RetrospectiveSignalExtractor
|
||||
from app.agents.learning.skill_candidate_builder import SkillCandidateBuilder
|
||||
from app.agents.learning.store import LearningArtifactStore, SessionRetrospectiveStore
|
||||
|
||||
__all__ = [
|
||||
"build_session_retrospective",
|
||||
"LearningArtifactStore",
|
||||
"LearningPatternMiner",
|
||||
"persist_retrospective",
|
||||
"RetrospectiveSignalExtractor",
|
||||
"schedule_retrospective_job",
|
||||
"SessionRetrospectiveSearch",
|
||||
"SessionRetrospectiveStore",
|
||||
"SkillCandidateBuilder",
|
||||
]
|
||||
16
backend/app/agents/learning/audit.py
Normal file
16
backend/app/agents/learning/audit.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.agents.schemas.learning import LearningDecision, SessionRetrospective
|
||||
|
||||
|
||||
def build_learning_audit_entry(retrospective: SessionRetrospective) -> dict[str, object]:
|
||||
decision = retrospective.learning_decision
|
||||
return {
|
||||
"retrospective_id": retrospective.retrospective_id,
|
||||
"decision": decision.decision if isinstance(decision, LearningDecision) else None,
|
||||
"explanation": decision.explanation if isinstance(decision, LearningDecision) else None,
|
||||
"signal_count": len(retrospective.learning_signals),
|
||||
"pattern_count": len(retrospective.pattern_candidates),
|
||||
"skill_candidate_count": len(retrospective.skill_candidates),
|
||||
"outcome": retrospective.outcome,
|
||||
}
|
||||
45
backend/app/agents/learning/bridge.py
Normal file
45
backend/app/agents/learning/bridge.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.agents.schemas.learning import LearningDecision, LearningSignal
|
||||
|
||||
|
||||
def route_learning_signal(signal: LearningSignal) -> str:
|
||||
if signal.signal_type == "preference":
|
||||
return "memory"
|
||||
if signal.signal_type in {"workflow", "decomposition", "tool_success"}:
|
||||
return "skill"
|
||||
if signal.signal_type == "correction":
|
||||
return "audit"
|
||||
return "memory"
|
||||
|
||||
|
||||
def build_learning_bridge_summary(signals: list[LearningSignal]) -> dict[str, object]:
|
||||
memory_count = 0
|
||||
skill_count = 0
|
||||
audit_count = 0
|
||||
|
||||
for signal in signals:
|
||||
route = route_learning_signal(signal)
|
||||
if route == "memory":
|
||||
memory_count += 1
|
||||
elif route == "skill":
|
||||
skill_count += 1
|
||||
else:
|
||||
audit_count += 1
|
||||
|
||||
return {
|
||||
"memory_signal_count": memory_count,
|
||||
"skill_signal_count": skill_count,
|
||||
"audit_signal_count": audit_count,
|
||||
}
|
||||
|
||||
|
||||
def update_learning_decision_with_bridge(
|
||||
decision: LearningDecision,
|
||||
signals: list[LearningSignal],
|
||||
) -> LearningDecision:
|
||||
bridge_summary = build_learning_bridge_summary(signals)
|
||||
metadata = dict(decision.metadata or {})
|
||||
metadata["bridge"] = bridge_summary
|
||||
decision.metadata = metadata
|
||||
return decision
|
||||
222
backend/app/agents/learning/jobs.py
Normal file
222
backend/app/agents/learning/jobs.py
Normal file
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.config import settings
|
||||
from app.database import async_session
|
||||
from app.agents.learning.bridge import update_learning_decision_with_bridge
|
||||
from app.agents.learning.pattern_miner import LearningPatternMiner
|
||||
from app.agents.learning.audit import build_learning_audit_entry
|
||||
from app.agents.learning.retrospector import build_session_retrospective
|
||||
from app.agents.learning.signal_extractor import RetrospectiveSignalExtractor
|
||||
from app.agents.learning.skill_candidate_builder import SkillCandidateBuilder
|
||||
from app.agents.learning.store import LearningArtifactStore, SessionRetrospectiveStore
|
||||
from app.agents.schemas.learning import LearningDecision, SessionRetrospective
|
||||
from app.agents.skills.evaluator import SkillPromotionEvaluator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _enrich_retrospective(retrospective: SessionRetrospective) -> SessionRetrospective:
|
||||
signals = RetrospectiveSignalExtractor().extract(retrospective)
|
||||
patterns = LearningPatternMiner().mine(signals)
|
||||
skill_candidates = SkillCandidateBuilder().build(patterns)
|
||||
|
||||
decision = LearningDecision(
|
||||
decision="create_candidate" if skill_candidates else ("reinforce_memory" if signals else "defer"),
|
||||
explanation=(
|
||||
"Retrospective produced reusable candidate skills."
|
||||
if skill_candidates
|
||||
else "Retrospective only reinforces memory-like evidence."
|
||||
if signals
|
||||
else "No stable signal was extracted from this retrospective."
|
||||
),
|
||||
evidence_refs=(skill_candidates[0].evidence_refs if skill_candidates else retrospective.evidence_refs[:3]),
|
||||
metadata={
|
||||
"signal_count": len(signals),
|
||||
"pattern_count": len(patterns),
|
||||
"skill_candidate_count": len(skill_candidates),
|
||||
},
|
||||
)
|
||||
|
||||
retrospective.learning_signals = signals
|
||||
retrospective.pattern_candidates = patterns
|
||||
retrospective.skill_candidates = skill_candidates
|
||||
retrospective.learning_decision = update_learning_decision_with_bridge(decision, signals)
|
||||
return retrospective
|
||||
|
||||
|
||||
def _build_learning_artifacts(retrospective: SessionRetrospective) -> list[dict[str, object]]:
|
||||
artifacts: list[dict[str, object]] = []
|
||||
for signal in retrospective.learning_signals:
|
||||
artifacts.append(
|
||||
{
|
||||
"artifact_type": "signal",
|
||||
"artifact_key": signal.signal_type,
|
||||
"summary_text": signal.explanation or signal.signal_type,
|
||||
"payload": signal.model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
for pattern in retrospective.pattern_candidates:
|
||||
artifacts.append(
|
||||
{
|
||||
"artifact_type": "pattern_candidate",
|
||||
"artifact_key": pattern.pattern_type,
|
||||
"summary_text": pattern.description,
|
||||
"payload": pattern.model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
for candidate in retrospective.skill_candidates:
|
||||
artifacts.append(
|
||||
{
|
||||
"artifact_type": "skill_candidate",
|
||||
"artifact_key": candidate.name,
|
||||
"summary_text": candidate.summary,
|
||||
"payload": candidate.model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
if retrospective.learning_decision is not None:
|
||||
artifacts.append(
|
||||
{
|
||||
"artifact_type": "learning_decision",
|
||||
"artifact_key": retrospective.learning_decision.decision,
|
||||
"summary_text": retrospective.learning_decision.explanation,
|
||||
"payload": retrospective.learning_decision.model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
artifacts.append(
|
||||
{
|
||||
"artifact_type": "learning_audit",
|
||||
"artifact_key": retrospective.retrospective_id or "retrospective",
|
||||
"summary_text": retrospective.learning_decision.explanation,
|
||||
"payload": build_learning_audit_entry(retrospective),
|
||||
}
|
||||
)
|
||||
return artifacts
|
||||
|
||||
|
||||
def _build_lifecycle_artifacts(decisions: list) -> list[dict[str, object]]:
|
||||
artifacts: list[dict[str, object]] = []
|
||||
for decision in decisions:
|
||||
artifacts.append(
|
||||
{
|
||||
"artifact_type": "skill_lifecycle_decision",
|
||||
"artifact_key": getattr(decision, "skill_name", None) or "skill",
|
||||
"summary_text": getattr(decision, "reason", ""),
|
||||
"payload": decision.model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
return artifacts
|
||||
|
||||
|
||||
async def persist_retrospective(
|
||||
*,
|
||||
user_id: str,
|
||||
conversation_id: str,
|
||||
request_message_id: str | None,
|
||||
response_message_id: str | None,
|
||||
query_text: str,
|
||||
final_response: str | None,
|
||||
state: dict[str, Any] | None,
|
||||
) -> None:
|
||||
retrospective = build_session_retrospective(
|
||||
request_id=response_message_id or request_message_id or conversation_id,
|
||||
session_id=conversation_id,
|
||||
user_query=query_text,
|
||||
state=state,
|
||||
runtime_context={"user_id": user_id},
|
||||
)
|
||||
retrospective = _enrich_retrospective(retrospective)
|
||||
|
||||
async with async_session() as session:
|
||||
saved = await SessionRetrospectiveStore(session).save(retrospective)
|
||||
lifecycle_decisions = []
|
||||
if settings.ENABLE_SKILL_PROMOTION:
|
||||
lifecycle_decisions = await SkillPromotionEvaluator(session).sync_retrospective(
|
||||
user_id=user_id,
|
||||
retrospective=retrospective,
|
||||
)
|
||||
if settings.ENABLE_LEARNING_SIGNALS:
|
||||
await LearningArtifactStore(session).save_batch(
|
||||
user_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
retrospective_id=saved.id,
|
||||
artifacts=[
|
||||
*_build_learning_artifacts(retrospective),
|
||||
*_build_lifecycle_artifacts(lifecycle_decisions),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def schedule_retrospective_job(**kwargs) -> asyncio.Task[None] | None:
|
||||
if not settings.ENABLE_RETROSPECTIVE:
|
||||
return None
|
||||
try:
|
||||
task = asyncio.create_task(persist_retrospective(**kwargs))
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
def _handle_completion(done_task: asyncio.Task[None]) -> None:
|
||||
try:
|
||||
done_task.result()
|
||||
except Exception:
|
||||
logger.exception("retrospective_job_failed")
|
||||
|
||||
task.add_done_callback(_handle_completion)
|
||||
return task
|
||||
|
||||
|
||||
def schedule_retrospective_learning_event(
|
||||
*,
|
||||
user_id: str,
|
||||
conversation_id: str,
|
||||
retrospective: SessionRetrospective,
|
||||
session_factory=async_session,
|
||||
) -> asyncio.Task[None] | None:
|
||||
if not settings.ENABLE_RETROSPECTIVE:
|
||||
return None
|
||||
|
||||
async def _persist_existing() -> None:
|
||||
async with session_factory() as session:
|
||||
enriched = _enrich_retrospective(retrospective)
|
||||
saved = await SessionRetrospectiveStore(session).save(enriched)
|
||||
lifecycle_decisions = []
|
||||
if settings.ENABLE_SKILL_PROMOTION:
|
||||
lifecycle_decisions = await SkillPromotionEvaluator(session).sync_retrospective(
|
||||
user_id=user_id,
|
||||
retrospective=enriched,
|
||||
)
|
||||
if settings.ENABLE_LEARNING_SIGNALS:
|
||||
await LearningArtifactStore(session).save_batch(
|
||||
user_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
retrospective_id=saved.id,
|
||||
artifacts=[
|
||||
*_build_learning_artifacts(enriched),
|
||||
*_build_lifecycle_artifacts(lifecycle_decisions),
|
||||
],
|
||||
)
|
||||
|
||||
try:
|
||||
task = asyncio.create_task(_persist_existing())
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
def _handle_completion(done_task: asyncio.Task[None]) -> None:
|
||||
try:
|
||||
done_task.result()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"retrospective_learning_event_failed",
|
||||
extra={
|
||||
"details": {
|
||||
"user_id": user_id,
|
||||
"conversation_id": conversation_id,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
task.add_done_callback(_handle_completion)
|
||||
return task
|
||||
42
backend/app/agents/learning/pattern_miner.py
Normal file
42
backend/app/agents/learning/pattern_miner.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from app.agents.schemas.learning import LearningSignal, PatternCandidate
|
||||
|
||||
|
||||
class LearningPatternMiner:
|
||||
def mine(self, signals: list[LearningSignal]) -> list[PatternCandidate]:
|
||||
patterns: list[PatternCandidate] = []
|
||||
|
||||
for signal in signals:
|
||||
if signal.signal_type not in {"workflow", "decomposition", "preference"}:
|
||||
continue
|
||||
|
||||
description = self._build_description(signal)
|
||||
patterns.append(
|
||||
PatternCandidate(
|
||||
pattern_id=f"pattern-{uuid4().hex[:10]}",
|
||||
pattern_type=signal.signal_type,
|
||||
description=description,
|
||||
confidence=signal.confidence,
|
||||
evidence_refs=signal.evidence_refs[:4],
|
||||
)
|
||||
)
|
||||
|
||||
return patterns
|
||||
|
||||
@staticmethod
|
||||
def _build_description(signal: LearningSignal) -> str:
|
||||
payload = signal.payload or {}
|
||||
if signal.signal_type == "workflow":
|
||||
task_type = payload.get("task_type") or "general"
|
||||
execution_mode = payload.get("execution_mode") or "direct"
|
||||
return f"Completed {task_type} requests worked under {execution_mode} execution."
|
||||
if signal.signal_type == "decomposition":
|
||||
task_count = payload.get("task_count") or 0
|
||||
return f"Requests with {task_count} concrete task refs benefit from structured decomposition."
|
||||
if signal.signal_type == "preference":
|
||||
preference = payload.get("preference") or "structured response"
|
||||
return f"User preference repeatedly points to {preference}."
|
||||
return signal.explanation or signal.signal_type
|
||||
115
backend/app/agents/learning/retrospector.py
Normal file
115
backend/app/agents/learning/retrospector.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.agents.schemas.learning import SessionRetrospective
|
||||
|
||||
|
||||
def _classify_task_type(query_text: str) -> str:
|
||||
normalized = (query_text or "").lower()
|
||||
if any(token in normalized for token in ("总结", "分析", "对比", "report", "analyze")):
|
||||
return "analysis"
|
||||
if any(token in normalized for token in ("安排", "提醒", "日程", "todo", "task")):
|
||||
return "planning_or_execution"
|
||||
if any(token in normalized for token in ("文档", "资料", "年报", "search", "查")):
|
||||
return "retrieval"
|
||||
return "general"
|
||||
|
||||
|
||||
def build_session_retrospective(
|
||||
*,
|
||||
request_id: str,
|
||||
session_id: str,
|
||||
user_query: str,
|
||||
state: dict[str, Any] | None,
|
||||
runtime_context: dict[str, Any] | None = None,
|
||||
) -> SessionRetrospective:
|
||||
state = state or {}
|
||||
if hasattr(runtime_context, "model_dump"):
|
||||
runtime_context = runtime_context.model_dump(mode="json")
|
||||
runtime_context = runtime_context or {}
|
||||
skill_shortlist = state.get("skill_shortlist") or []
|
||||
used_skill_names = [
|
||||
item.get("skill_name")
|
||||
for item in skill_shortlist
|
||||
if isinstance(item, dict) and item.get("skill_name")
|
||||
]
|
||||
|
||||
task_refs = []
|
||||
for task in (state.get("completed_tasks") or [])[:4]:
|
||||
if isinstance(task, dict):
|
||||
task_refs.append(
|
||||
{
|
||||
"task_id": task.get("task_id"),
|
||||
"title": task.get("title"),
|
||||
"status": task.get("status"),
|
||||
}
|
||||
)
|
||||
|
||||
event_refs = []
|
||||
for event in (state.get("event_trace") or [])[:8]:
|
||||
if isinstance(event, dict):
|
||||
event_refs.append(
|
||||
{
|
||||
"event_type": event.get("event_type"),
|
||||
"task_id": event.get("task_id"),
|
||||
"agent_id": event.get("agent_id"),
|
||||
}
|
||||
)
|
||||
|
||||
verification_evidence = []
|
||||
for evidence in (state.get("verification_evidence") or [])[:6]:
|
||||
if isinstance(evidence, dict):
|
||||
verification_evidence.append(evidence)
|
||||
|
||||
verification_status = state.get("verification_status")
|
||||
execution_mode = state.get("execution_mode")
|
||||
primary_agent = state.get("current_agent") or "master"
|
||||
retrospective_shortlist = state.get("retrospective_shortlist") or []
|
||||
|
||||
summary_parts = [
|
||||
f"本轮请求按 {execution_mode or 'unknown'} 模式处理",
|
||||
f"主要负责 agent 为 {primary_agent}",
|
||||
]
|
||||
if verification_status:
|
||||
summary_parts.append(f"验证结果为 {verification_status}")
|
||||
if used_skill_names:
|
||||
summary_parts.append(f"命中技能候选 {', '.join(used_skill_names[:3])}")
|
||||
if retrospective_shortlist:
|
||||
summary_parts.append(f"参考了 {len(retrospective_shortlist)} 条历史复盘")
|
||||
|
||||
final_response = state.get("final_response")
|
||||
outcome = "completed" if final_response else "failed"
|
||||
if not final_response and verification_status == "passed":
|
||||
outcome = "completed"
|
||||
if final_response and verification_status == "skipped":
|
||||
outcome = "partial"
|
||||
|
||||
return SessionRetrospective(
|
||||
retrospective_id=request_id,
|
||||
user_id=str(runtime_context.get("user_id") or ""),
|
||||
conversation_id=session_id,
|
||||
response_message_id=request_id,
|
||||
query_text=user_query,
|
||||
final_response=final_response,
|
||||
summary=";".join(summary_parts) + "。",
|
||||
task_type=_classify_task_type(user_query),
|
||||
execution_mode=execution_mode,
|
||||
primary_agent=primary_agent,
|
||||
verification_status=verification_status,
|
||||
verification_summary=state.get("verification_summary"),
|
||||
used_skill_names=used_skill_names,
|
||||
evidence_refs=verification_evidence,
|
||||
task_refs=task_refs,
|
||||
event_refs=event_refs,
|
||||
context_snapshot={
|
||||
"runtime_request_context": runtime_context,
|
||||
"recommended_runtime_mode": runtime_context.get("recommended_runtime_mode"),
|
||||
"parallel_worthiness": state.get("parallel_worthiness"),
|
||||
"retrospective_shortlist_count": len(retrospective_shortlist),
|
||||
"scheduled_subtask_count": len(state.get("scheduled_subtasks") or []),
|
||||
"merge_report": dict(state.get("merge_report") or {}),
|
||||
"verification_report": dict(state.get("verification_report") or {}),
|
||||
},
|
||||
outcome=outcome,
|
||||
)
|
||||
95
backend/app/agents/learning/session_search.py
Normal file
95
backend/app/agents/learning/session_search.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.agents.schemas.learning import SessionRetrospective
|
||||
from app.agents.skills.matcher import score_text_match
|
||||
from app.agents.learning.store import SessionRetrospectiveStore
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class SessionRetrospectiveSearch:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
async def shortlist(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
query_text: str,
|
||||
conversation_id: str | None = None,
|
||||
task_type: str | None = None,
|
||||
skill_name: str | None = None,
|
||||
limit: int = 3,
|
||||
) -> list[SessionRetrospective]:
|
||||
records = await SessionRetrospectiveStore(self.db).list_recent(user_id=user_id, limit=25)
|
||||
scored: list[tuple[float, SessionRetrospective]] = []
|
||||
|
||||
for record in records:
|
||||
if task_type and record.task_type != task_type:
|
||||
continue
|
||||
if skill_name and skill_name not in (record.skill_names or []):
|
||||
continue
|
||||
score, _matched_terms = score_text_match(
|
||||
query_text,
|
||||
record.query_text,
|
||||
record.summary_text,
|
||||
" ".join(record.skill_names or []),
|
||||
)
|
||||
if conversation_id and record.conversation_id == conversation_id:
|
||||
score = min(1.0, score + 0.1)
|
||||
if score <= 0:
|
||||
continue
|
||||
|
||||
payload = dict(record.payload or {})
|
||||
payload["retrospective_id"] = record.id
|
||||
retrospective = SessionRetrospective.model_validate(payload)
|
||||
scored.append((score, retrospective))
|
||||
|
||||
scored.sort(key=lambda item: item[0], reverse=True)
|
||||
return [item for _score, item in scored[:limit]]
|
||||
|
||||
|
||||
async def search_recent_retrospectives(
|
||||
db,
|
||||
*,
|
||||
user_id: str,
|
||||
query: str,
|
||||
conversation_id: str | None = None,
|
||||
task_type: str | None = None,
|
||||
skill_name: str | None = None,
|
||||
limit: int = 3,
|
||||
) -> list[SessionRetrospective]:
|
||||
if not settings.ENABLE_SESSION_RETROSPECTIVE_SEARCH:
|
||||
return []
|
||||
return await SessionRetrospectiveSearch(db).shortlist(
|
||||
user_id=user_id,
|
||||
query_text=query,
|
||||
conversation_id=conversation_id,
|
||||
task_type=task_type,
|
||||
skill_name=skill_name,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
def summarize_retrospective(retrospective: SessionRetrospective) -> dict[str, object]:
|
||||
verification_status = retrospective.verification_status or retrospective.outcome
|
||||
success_score = 1.0 if verification_status == "passed" else 0.6 if verification_status == "skipped" else 0.2
|
||||
reusable_patterns = []
|
||||
if retrospective.used_skill_names:
|
||||
reusable_patterns.append("skill_shortlist_hit")
|
||||
if retrospective.execution_mode:
|
||||
reusable_patterns.append(f"mode:{retrospective.execution_mode}")
|
||||
|
||||
avoid_patterns = []
|
||||
if retrospective.outcome == "failed":
|
||||
avoid_patterns.append("failed_outcome")
|
||||
|
||||
return {
|
||||
"retrospective_id": retrospective.retrospective_id,
|
||||
"task_type": retrospective.task_type,
|
||||
"request_summary": retrospective.query_text[:120],
|
||||
"summary": retrospective.summary,
|
||||
"execution_mode": retrospective.execution_mode,
|
||||
"success_score": round(success_score, 2),
|
||||
"reusable_patterns": reusable_patterns,
|
||||
"avoid_patterns": avoid_patterns,
|
||||
}
|
||||
72
backend/app/agents/learning/signal_extractor.py
Normal file
72
backend/app/agents/learning/signal_extractor.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.agents.schemas.learning import LearningSignal, SessionRetrospective
|
||||
|
||||
|
||||
class RetrospectiveSignalExtractor:
|
||||
def extract(self, retrospective: SessionRetrospective) -> list[LearningSignal]:
|
||||
signals: list[LearningSignal] = []
|
||||
|
||||
if retrospective.outcome == "completed":
|
||||
signals.append(
|
||||
LearningSignal(
|
||||
signal_type="workflow",
|
||||
confidence=0.8,
|
||||
evidence_refs=retrospective.evidence_refs[:3],
|
||||
explanation="Completed runs can be mined as workflow hints later.",
|
||||
payload={
|
||||
"task_type": retrospective.task_type,
|
||||
"execution_mode": retrospective.execution_mode,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
if len(retrospective.task_refs) > 1:
|
||||
context_snapshot = retrospective.context_snapshot or {}
|
||||
merge_report = dict(context_snapshot.get("merge_report") or {})
|
||||
verification_report = dict(context_snapshot.get("verification_report") or {})
|
||||
effectiveness_score = 1.0
|
||||
if merge_report.get("status") == "conflicted":
|
||||
effectiveness_score = 0.45
|
||||
elif merge_report.get("status") == "fallback":
|
||||
effectiveness_score = 0.25
|
||||
elif verification_report.get("status") == "failed":
|
||||
effectiveness_score = 0.3
|
||||
signals.append(
|
||||
LearningSignal(
|
||||
signal_type="decomposition",
|
||||
confidence=0.7,
|
||||
evidence_refs=retrospective.task_refs[:3],
|
||||
explanation="Multiple completed task refs indicate a decomposition pattern.",
|
||||
payload={
|
||||
"task_count": len(retrospective.task_refs),
|
||||
"scheduled_subtask_count": context_snapshot.get("scheduled_subtask_count", 0),
|
||||
"effectiveness_score": effectiveness_score,
|
||||
"merge_status": merge_report.get("status"),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
if retrospective.used_skill_names:
|
||||
signals.append(
|
||||
LearningSignal(
|
||||
signal_type="tool_success",
|
||||
confidence=0.65 if retrospective.outcome == "completed" else 0.35,
|
||||
evidence_refs=retrospective.evidence_refs[:2],
|
||||
explanation="Task-scoped skill shortlist was available during this run.",
|
||||
payload={"skills": retrospective.used_skill_names[:3]},
|
||||
)
|
||||
)
|
||||
|
||||
if retrospective.outcome == "failed":
|
||||
signals.append(
|
||||
LearningSignal(
|
||||
signal_type="correction",
|
||||
confidence=0.75,
|
||||
evidence_refs=retrospective.evidence_refs[:2],
|
||||
explanation="Failed retrospectives should remain auditable before any promotion.",
|
||||
payload={"verification_status": retrospective.verification_status},
|
||||
)
|
||||
)
|
||||
|
||||
return signals
|
||||
54
backend/app/agents/learning/skill_candidate_builder.py
Normal file
54
backend/app/agents/learning/skill_candidate_builder.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
from app.agents.schemas.learning import PatternCandidate, SkillCandidate
|
||||
|
||||
|
||||
class SkillCandidateBuilder:
|
||||
def build(self, patterns: list[PatternCandidate]) -> list[SkillCandidate]:
|
||||
candidates: list[SkillCandidate] = []
|
||||
|
||||
for pattern in patterns:
|
||||
if pattern.confidence < 0.55:
|
||||
continue
|
||||
|
||||
name = self._build_name(pattern)
|
||||
candidates.append(
|
||||
SkillCandidate(
|
||||
candidate_id=f"candidate-{self._stable_suffix(pattern)}",
|
||||
name=name,
|
||||
summary=pattern.description,
|
||||
candidate_type=self._map_candidate_type(pattern.pattern_type),
|
||||
source_pattern_ids=[pattern.pattern_id],
|
||||
confidence=pattern.confidence,
|
||||
evidence_refs=pattern.evidence_refs[:4],
|
||||
recommended_status="candidate",
|
||||
)
|
||||
)
|
||||
|
||||
return candidates
|
||||
|
||||
@staticmethod
|
||||
def _build_name(pattern: PatternCandidate) -> str:
|
||||
prefix = {
|
||||
"workflow": "workflow",
|
||||
"decomposition": "decomposition",
|
||||
"preference": "preference",
|
||||
}.get(pattern.pattern_type, "learned")
|
||||
stable_suffix = SkillCandidateBuilder._stable_suffix(pattern)
|
||||
return f"{prefix}-{stable_suffix}"
|
||||
|
||||
@staticmethod
|
||||
def _map_candidate_type(pattern_type: str) -> str:
|
||||
mapping = {
|
||||
"workflow": "workflow_skill",
|
||||
"decomposition": "decomposition_skill",
|
||||
"preference": "preference_skill",
|
||||
}
|
||||
return mapping.get(pattern_type, "workflow_skill")
|
||||
|
||||
@staticmethod
|
||||
def _stable_suffix(pattern: PatternCandidate) -> str:
|
||||
raw = f"{pattern.pattern_type}:{pattern.description}".encode("utf-8")
|
||||
return hashlib.sha1(raw).hexdigest()[:10]
|
||||
129
backend/app/agents/learning/store.py
Normal file
129
backend/app/agents/learning/store.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.agents.schemas.learning import SessionRetrospective
|
||||
from app.models.learning import LearningArtifactRecord, SessionRetrospectiveRecord
|
||||
|
||||
|
||||
class SessionRetrospectiveStore:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def save(self, retrospective: SessionRetrospective) -> SessionRetrospectiveRecord:
|
||||
payload = retrospective.model_dump(mode="json")
|
||||
record = SessionRetrospectiveRecord(
|
||||
user_id=retrospective.user_id,
|
||||
conversation_id=retrospective.conversation_id,
|
||||
request_message_id=retrospective.request_message_id,
|
||||
response_message_id=retrospective.response_message_id,
|
||||
query_text=retrospective.query_text,
|
||||
final_response=retrospective.final_response,
|
||||
summary_text=retrospective.summary,
|
||||
task_type=retrospective.task_type,
|
||||
execution_mode=retrospective.execution_mode,
|
||||
primary_agent=retrospective.primary_agent,
|
||||
verification_status=retrospective.verification_status,
|
||||
verification_summary=retrospective.verification_summary,
|
||||
skill_names=retrospective.used_skill_names,
|
||||
evidence=retrospective.evidence_refs,
|
||||
task_refs=retrospective.task_refs,
|
||||
payload=payload,
|
||||
)
|
||||
self.db.add(record)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(record)
|
||||
return record
|
||||
|
||||
async def list_recent(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
limit: int = 20,
|
||||
) -> list[SessionRetrospectiveRecord]:
|
||||
result = await self.db.execute(
|
||||
select(SessionRetrospectiveRecord)
|
||||
.where(SessionRetrospectiveRecord.user_id == user_id)
|
||||
.order_by(desc(SessionRetrospectiveRecord.recorded_at), desc(SessionRetrospectiveRecord.created_at))
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
class LearningArtifactStore:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def save_batch(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
conversation_id: str,
|
||||
retrospective_id: str | None,
|
||||
artifacts: list[dict[str, object]],
|
||||
) -> list[LearningArtifactRecord]:
|
||||
records: list[LearningArtifactRecord] = []
|
||||
for artifact in artifacts:
|
||||
record = LearningArtifactRecord(
|
||||
user_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
retrospective_id=retrospective_id,
|
||||
artifact_type=str(artifact.get("artifact_type") or "unknown"),
|
||||
artifact_key=str(artifact.get("artifact_key") or "") or None,
|
||||
summary_text=str(artifact.get("summary_text") or ""),
|
||||
payload=dict(artifact.get("payload") or {}),
|
||||
)
|
||||
self.db.add(record)
|
||||
records.append(record)
|
||||
|
||||
await self.db.commit()
|
||||
for record in records:
|
||||
await self.db.refresh(record)
|
||||
return records
|
||||
|
||||
async def list_recent(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
artifact_type: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[LearningArtifactRecord]:
|
||||
query = select(LearningArtifactRecord).where(LearningArtifactRecord.user_id == user_id)
|
||||
if artifact_type:
|
||||
query = query.where(LearningArtifactRecord.artifact_type == artifact_type)
|
||||
result = await self.db.execute(
|
||||
query.order_by(
|
||||
desc(LearningArtifactRecord.recorded_at),
|
||||
desc(LearningArtifactRecord.created_at),
|
||||
).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def aggregate_counts_by_key(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
artifact_type: str,
|
||||
limit: int = 100,
|
||||
) -> dict[str, int]:
|
||||
records = await self.list_recent(user_id=user_id, artifact_type=artifact_type, limit=limit)
|
||||
counts: dict[str, int] = {}
|
||||
for record in records:
|
||||
key = record.artifact_key or "unknown"
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def append_retrospective_attachment(
|
||||
attachments: list[dict] | None,
|
||||
retrospective: SessionRetrospective,
|
||||
) -> list[dict]:
|
||||
next_attachments = list(attachments or [])
|
||||
next_attachments.append(
|
||||
{
|
||||
"kind": "session_retrospective",
|
||||
"payload": retrospective.model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
return next_attachments
|
||||
76
backend/app/agents/schemas/learning.py
Normal file
76
backend/app/agents/schemas/learning.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
LearningSignalType = Literal[
|
||||
"preference",
|
||||
"workflow",
|
||||
"decomposition",
|
||||
"tool_success",
|
||||
"correction",
|
||||
]
|
||||
|
||||
|
||||
class SessionRetrospective(BaseModel):
|
||||
retrospective_id: str | None = None
|
||||
user_id: str
|
||||
conversation_id: str
|
||||
request_message_id: str | None = None
|
||||
response_message_id: str | None = None
|
||||
query_text: str
|
||||
final_response: str | None = None
|
||||
summary: str
|
||||
task_type: str | None = None
|
||||
execution_mode: str | None = None
|
||||
primary_agent: str | None = None
|
||||
verification_status: str | None = None
|
||||
verification_summary: str | None = None
|
||||
used_skill_names: list[str] = Field(default_factory=list)
|
||||
evidence_refs: list[dict[str, Any]] = Field(default_factory=list)
|
||||
task_refs: list[dict[str, Any]] = Field(default_factory=list)
|
||||
event_refs: list[dict[str, Any]] = Field(default_factory=list)
|
||||
context_snapshot: dict[str, Any] = Field(default_factory=dict)
|
||||
learning_signals: list["LearningSignal"] = Field(default_factory=list)
|
||||
pattern_candidates: list["PatternCandidate"] = Field(default_factory=list)
|
||||
skill_candidates: list["SkillCandidate"] = Field(default_factory=list)
|
||||
learning_decision: "LearningDecision | None" = None
|
||||
outcome: Literal["completed", "partial", "failed"] = "completed"
|
||||
captured_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class LearningSignal(BaseModel):
|
||||
signal_type: LearningSignalType
|
||||
confidence: float = 0.0
|
||||
evidence_refs: list[dict[str, Any]] = Field(default_factory=list)
|
||||
explanation: str | None = None
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PatternCandidate(BaseModel):
|
||||
pattern_id: str
|
||||
pattern_type: str
|
||||
description: str
|
||||
confidence: float = 0.0
|
||||
evidence_refs: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SkillCandidate(BaseModel):
|
||||
candidate_id: str
|
||||
name: str
|
||||
summary: str
|
||||
candidate_type: Literal["workflow_skill", "preference_skill", "decomposition_skill"] = "workflow_skill"
|
||||
source_pattern_ids: list[str] = Field(default_factory=list)
|
||||
confidence: float = 0.0
|
||||
evidence_refs: list[dict[str, Any]] = Field(default_factory=list)
|
||||
recommended_status: Literal["candidate", "shadow"] = "candidate"
|
||||
|
||||
|
||||
class LearningDecision(BaseModel):
|
||||
decision: Literal["reinforce_memory", "create_candidate", "promote_skill", "defer", "reject"]
|
||||
explanation: str
|
||||
evidence_refs: list[dict[str, Any]] = Field(default_factory=list)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
38
backend/app/models/learning.py
Normal file
38
backend/app/models/learning.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, JSON, String, Text
|
||||
|
||||
from app.models.base import BaseModel, utc_now
|
||||
|
||||
|
||||
class SessionRetrospectiveRecord(BaseModel):
|
||||
__tablename__ = "session_retrospectives"
|
||||
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||
conversation_id = Column(String(36), ForeignKey("conversations.id"), nullable=False, index=True)
|
||||
request_message_id = Column(String(36), ForeignKey("messages.id"), nullable=True, index=True)
|
||||
response_message_id = Column(String(36), ForeignKey("messages.id"), nullable=True, index=True)
|
||||
query_text = Column(Text, nullable=False)
|
||||
final_response = Column(Text, nullable=True)
|
||||
summary_text = Column(Text, nullable=False)
|
||||
task_type = Column(String(64), nullable=True, index=True)
|
||||
execution_mode = Column(String(32), nullable=True, index=True)
|
||||
primary_agent = Column(String(64), nullable=True)
|
||||
verification_status = Column(String(32), nullable=True)
|
||||
verification_summary = Column(Text, nullable=True)
|
||||
skill_names = Column(JSON, default=list, nullable=False)
|
||||
evidence = Column(JSON, default=list, nullable=False)
|
||||
task_refs = Column(JSON, default=list, nullable=False)
|
||||
payload = Column(JSON, default=dict, nullable=False)
|
||||
recorded_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
|
||||
|
||||
class LearningArtifactRecord(BaseModel):
|
||||
__tablename__ = "learning_artifacts"
|
||||
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||
conversation_id = Column(String(36), ForeignKey("conversations.id"), nullable=False, index=True)
|
||||
retrospective_id = Column(String(36), ForeignKey("session_retrospectives.id"), nullable=True, index=True)
|
||||
artifact_type = Column(String(32), nullable=False, index=True)
|
||||
artifact_key = Column(String(128), nullable=True, index=True)
|
||||
summary_text = Column(Text, nullable=False)
|
||||
payload = Column(JSON, default=dict, nullable=False)
|
||||
recorded_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
Reference in New Issue
Block a user