feat(services): enhance services with rollback and observability
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -7,12 +7,13 @@ import json
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from time import perf_counter
|
||||
from typing import Any, AsyncGenerator
|
||||
import asyncio
|
||||
from openai import BadRequestError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from langchain_core.messages import HumanMessage, AIMessage
|
||||
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
|
||||
|
||||
from app.database import async_session
|
||||
from app.logging_utils import summarize_llm_config
|
||||
@@ -21,10 +22,24 @@ from app.models.conversation import Conversation, Message
|
||||
from app.models.user import User
|
||||
from app.agents.graph import get_agent_graph
|
||||
from app.agents.context import set_current_user, clear_current_user
|
||||
from app.agents.learning.jobs import schedule_retrospective_job
|
||||
from app.agents.learning.retrospector import build_session_retrospective
|
||||
from app.agents.learning.session_search import SessionRetrospectiveSearch, summarize_retrospective
|
||||
from app.agents.orchestration.task_graph import build_bounded_task_graph
|
||||
from app.agents.learning.store import append_retrospective_attachment
|
||||
from app.agents.schemas.orchestration import (
|
||||
RuntimeRequestContext,
|
||||
assess_parallel_worthiness,
|
||||
render_runtime_request_context_summary,
|
||||
)
|
||||
from app.agents.schemas.skills import SkillActivationRecord
|
||||
from app.agents.skills.registry import get_skill_registry
|
||||
from app.agents.skills.retriever import shortlist_skills_for_request
|
||||
from app.services import memory_service
|
||||
from app.services.brain_service import BrainService
|
||||
from app.services.llm_service import create_llm_from_config, resolve_provider_capabilities
|
||||
from app.services.rollback_controller import RollbackController
|
||||
from app.services.runtime_observability import build_runtime_observability_report
|
||||
from app.agents.tools.time_reasoning import extract_reference_datetime
|
||||
from app.agents.state import initial_state
|
||||
|
||||
@@ -36,6 +51,7 @@ MEMORY_SECTION_HEADERS = (
|
||||
"【之前对话摘要】",
|
||||
"【知识大脑】",
|
||||
)
|
||||
MEMORY_INLINE_HEADERS = {"[关于你的记忆]"}
|
||||
|
||||
|
||||
def _split_memory_context_sections(memory_context: str | None) -> dict[str, str]:
|
||||
@@ -81,6 +97,41 @@ def _derive_role_memory_contexts(memory_context: str | None) -> dict[str, str |
|
||||
}
|
||||
|
||||
|
||||
def _extract_memory_highlights(memory_context: str | None, *, limit: int = 5) -> list[str]:
|
||||
text = (memory_context or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
|
||||
highlights: list[str] = []
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line in MEMORY_SECTION_HEADERS or line in MEMORY_INLINE_HEADERS:
|
||||
continue
|
||||
if line.startswith("-"):
|
||||
normalized = line[1:].strip()
|
||||
else:
|
||||
normalized = line
|
||||
if normalized:
|
||||
highlights.append(normalized)
|
||||
if len(highlights) >= limit:
|
||||
break
|
||||
return highlights
|
||||
|
||||
|
||||
def _summarize_retrospective(retrospective: Any) -> str:
|
||||
summary = str(getattr(retrospective, "summary", "") or "").strip()
|
||||
task_type = str(getattr(retrospective, "task_type", "") or "").strip()
|
||||
execution_mode = str(getattr(retrospective, "execution_mode", "") or "").strip()
|
||||
outcome = str(getattr(retrospective, "outcome", "") or "").strip()
|
||||
|
||||
parts = [summary[:80] or task_type or "历史复盘"]
|
||||
if execution_mode:
|
||||
parts.append(f"mode={execution_mode}")
|
||||
if outcome:
|
||||
parts.append(f"outcome={outcome}")
|
||||
return ";".join(parts)
|
||||
|
||||
|
||||
def _is_streaming_rejection_error(error: Exception, user_llm_config: dict | None) -> bool:
|
||||
capabilities = resolve_provider_capabilities(user_llm_config)
|
||||
error_text = str(error).lower()
|
||||
@@ -461,18 +512,27 @@ class AgentService:
|
||||
async def _build_agent_state(
|
||||
self,
|
||||
*,
|
||||
request_id: str,
|
||||
user_id: str,
|
||||
conversation: Conversation,
|
||||
raw_user_query: str,
|
||||
full_message: str,
|
||||
memory_context: str | None,
|
||||
current_datetime_context: str,
|
||||
current_datetime_reference: dict[str, str],
|
||||
user_llm_config: dict | None,
|
||||
runtime_request_context: RuntimeRequestContext,
|
||||
recalled_retrospectives: list[dict[str, Any]],
|
||||
skill_shortlist: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
state = initial_state(user_id, conversation.id)
|
||||
runtime_summary = render_runtime_request_context_summary(runtime_request_context)
|
||||
state.update(
|
||||
{
|
||||
"messages": [HumanMessage(content=full_message)],
|
||||
"messages": [
|
||||
SystemMessage(content=runtime_summary),
|
||||
HumanMessage(content=full_message),
|
||||
],
|
||||
"memory_context": memory_context,
|
||||
"current_datetime_context": current_datetime_context,
|
||||
"current_datetime_reference": current_datetime_reference,
|
||||
@@ -482,9 +542,119 @@ class AgentService:
|
||||
previous_snapshot = await self._load_continuity_snapshot(conversation)
|
||||
if previous_snapshot:
|
||||
state.update(previous_snapshot)
|
||||
state["messages"] = [HumanMessage(content=full_message)]
|
||||
state["messages"] = [
|
||||
SystemMessage(content=runtime_summary),
|
||||
HumanMessage(content=full_message),
|
||||
]
|
||||
state.update(
|
||||
{
|
||||
"runtime_request_context": runtime_request_context.model_dump(mode="json"),
|
||||
"task_graph": (
|
||||
runtime_request_context.task_graph.model_dump(mode="json")
|
||||
if runtime_request_context.task_graph is not None
|
||||
else None
|
||||
),
|
||||
"feature_flags": RollbackController().snapshot_flags(),
|
||||
"recalled_retrospectives": recalled_retrospectives,
|
||||
"retrospective_shortlist": recalled_retrospectives,
|
||||
"skill_shortlist": skill_shortlist,
|
||||
"skill_activation_records": [
|
||||
SkillActivationRecord(
|
||||
skill_name=item.get("skill_name"),
|
||||
source=item.get("source", "runtime"),
|
||||
source_id=item.get("source_id"),
|
||||
status=item.get("status", "active"),
|
||||
injection_mode=item.get("injection_mode", "metadata_only"),
|
||||
matched_terms=item.get("matched_terms", []),
|
||||
rationale=item.get("rationale"),
|
||||
).model_dump(mode="json")
|
||||
for item in skill_shortlist
|
||||
if item.get("skill_name")
|
||||
],
|
||||
"parallel_worthiness": runtime_request_context.parallel_worthiness.model_dump(
|
||||
mode="json"
|
||||
),
|
||||
}
|
||||
)
|
||||
return state
|
||||
|
||||
async def _build_runtime_request_context(
|
||||
self,
|
||||
*,
|
||||
request_id: str,
|
||||
user_id: str,
|
||||
conversation: Conversation,
|
||||
user_query: str,
|
||||
memory_context: str | None,
|
||||
) -> tuple[RuntimeRequestContext, list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
started_at = perf_counter()
|
||||
retrospectives_started = perf_counter()
|
||||
recent_retrospectives = await SessionRetrospectiveSearch(self.db).shortlist(
|
||||
user_id=user_id,
|
||||
query_text=user_query,
|
||||
conversation_id=conversation.id,
|
||||
limit=3,
|
||||
)
|
||||
retrospective_ms = (perf_counter() - retrospectives_started) * 1000
|
||||
feature_flags = RollbackController().snapshot_flags()
|
||||
shortlist_started = perf_counter()
|
||||
skill_shortlist = await shortlist_skills_for_request(
|
||||
self.db,
|
||||
user_id=user_id,
|
||||
user_query=user_query,
|
||||
memory_context=memory_context,
|
||||
retrospectives=[item.model_dump(mode="json") for item in recent_retrospectives],
|
||||
include_learned=feature_flags["ENABLE_LEARNED_SKILL_LOADING"],
|
||||
limit=4,
|
||||
)
|
||||
skill_shortlist_ms = (perf_counter() - shortlist_started) * 1000
|
||||
parallel_worthiness = assess_parallel_worthiness(
|
||||
user_query,
|
||||
retrospective_count=len(recent_retrospectives),
|
||||
skill_count=len(skill_shortlist),
|
||||
)
|
||||
recommended_runtime_mode = (
|
||||
"collaboration" if parallel_worthiness.preferred_mode != "direct" else "direct"
|
||||
)
|
||||
task_graph = (
|
||||
build_bounded_task_graph(
|
||||
query_text=user_query,
|
||||
parallel_worthiness=parallel_worthiness,
|
||||
)
|
||||
if feature_flags["ENABLE_PARALLEL_TASK_GRAPH"]
|
||||
else None
|
||||
)
|
||||
runtime_request_context = RuntimeRequestContext(
|
||||
request_id=request_id,
|
||||
session_id=conversation.id,
|
||||
conversation_id=conversation.id,
|
||||
user_id=user_id,
|
||||
query_text=user_query,
|
||||
raw_user_query=user_query,
|
||||
recalled_memories=_extract_memory_highlights(memory_context),
|
||||
recalled_retrospectives=[
|
||||
summarize_retrospective(retrospective) for retrospective in recent_retrospectives
|
||||
],
|
||||
shortlisted_skills=[entry.skill_name for entry in skill_shortlist],
|
||||
skill_shortlist=skill_shortlist,
|
||||
current_agent_role="master",
|
||||
execution_mode=recommended_runtime_mode,
|
||||
conversation_state_ref=conversation.id,
|
||||
parallel_worthiness=parallel_worthiness,
|
||||
task_graph=task_graph,
|
||||
recommended_runtime_mode=recommended_runtime_mode,
|
||||
assembly_metrics={
|
||||
"retrospective_ms": round(retrospective_ms, 3),
|
||||
"skill_shortlist_ms": round(skill_shortlist_ms, 3),
|
||||
"total_ms": round((perf_counter() - started_at) * 1000, 3),
|
||||
},
|
||||
)
|
||||
return (
|
||||
runtime_request_context,
|
||||
[item.model_dump(mode="json") for item in recent_retrospectives],
|
||||
[item.model_dump(mode="json") for item in skill_shortlist],
|
||||
)
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
user_id: str,
|
||||
@@ -610,21 +780,38 @@ class AgentService:
|
||||
async def run_agent():
|
||||
collected = ""
|
||||
state: dict[str, Any] | None = None
|
||||
runtime_request_context: RuntimeRequestContext | None = None
|
||||
set_current_user(user_id)
|
||||
try:
|
||||
graph = get_agent_graph()
|
||||
current_datetime_context, current_datetime_reference = (
|
||||
self._build_current_datetime_context()
|
||||
)
|
||||
|
||||
state = await self._build_agent_state(
|
||||
(
|
||||
runtime_request_context,
|
||||
recalled_retrospectives,
|
||||
skill_shortlist,
|
||||
) = await self._build_runtime_request_context(
|
||||
request_id=assistant_msg.id,
|
||||
user_id=user_id,
|
||||
conversation=conv,
|
||||
user_query=message,
|
||||
memory_context=memory_ctx,
|
||||
)
|
||||
|
||||
state = await self._build_agent_state(
|
||||
request_id=assistant_msg.id,
|
||||
user_id=user_id,
|
||||
conversation=conv,
|
||||
raw_user_query=message,
|
||||
full_message=full_message,
|
||||
memory_context=memory_ctx,
|
||||
current_datetime_context=current_datetime_context,
|
||||
current_datetime_reference=current_datetime_reference,
|
||||
user_llm_config=user_llm_config,
|
||||
runtime_request_context=runtime_request_context,
|
||||
recalled_retrospectives=recalled_retrospectives,
|
||||
skill_shortlist=skill_shortlist,
|
||||
)
|
||||
state.update(_derive_role_memory_contexts(memory_ctx))
|
||||
|
||||
@@ -749,7 +936,7 @@ class AgentService:
|
||||
if collected:
|
||||
assistant_msg.content = collected
|
||||
continuity_snapshot = _build_continuity_snapshot(state or {})
|
||||
assistant_msg.attachments = (
|
||||
attachments = (
|
||||
[
|
||||
{
|
||||
"kind": "agent_continuity_state",
|
||||
@@ -757,8 +944,26 @@ class AgentService:
|
||||
}
|
||||
]
|
||||
if continuity_snapshot
|
||||
else None
|
||||
else []
|
||||
)
|
||||
if state is not None and runtime_request_context is not None:
|
||||
retrospective = build_session_retrospective(
|
||||
request_id=assistant_msg.id,
|
||||
session_id=conversation_id,
|
||||
user_query=message,
|
||||
state=state,
|
||||
runtime_context=runtime_request_context,
|
||||
)
|
||||
attachments = append_retrospective_attachment(attachments, retrospective)
|
||||
attachments.append(
|
||||
{
|
||||
"kind": "runtime_observability",
|
||||
"payload": build_runtime_observability_report(
|
||||
state=state,
|
||||
feature_flags=state.get("feature_flags") or {},
|
||||
),
|
||||
}
|
||||
)
|
||||
conv.agent_state = (
|
||||
{
|
||||
"kind": "agent_continuity_state",
|
||||
@@ -771,8 +976,18 @@ class AgentService:
|
||||
user_id,
|
||||
**_build_assistant_event_payload(collected),
|
||||
)
|
||||
assistant_msg.attachments = attachments or None
|
||||
await self.db.commit()
|
||||
await self.db.refresh(assistant_msg)
|
||||
schedule_retrospective_job(
|
||||
user_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
request_message_id=user_msg.id,
|
||||
response_message_id=assistant_msg.id,
|
||||
query_text=message,
|
||||
final_response=collected,
|
||||
state=state,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("save_assistant_message_failed")
|
||||
asyncio.create_task(self._try_auto_summarize_background(user_id, conversation_id))
|
||||
@@ -863,14 +1078,30 @@ class AgentService:
|
||||
current_datetime_context, current_datetime_reference = (
|
||||
self._build_current_datetime_context()
|
||||
)
|
||||
state = await self._build_agent_state(
|
||||
(
|
||||
runtime_request_context,
|
||||
recalled_retrospectives,
|
||||
skill_shortlist,
|
||||
) = await self._build_runtime_request_context(
|
||||
request_id=assistant_msg.id,
|
||||
user_id=user_id,
|
||||
conversation=conv,
|
||||
user_query=message,
|
||||
memory_context=memory_ctx,
|
||||
)
|
||||
state = await self._build_agent_state(
|
||||
request_id=assistant_msg.id,
|
||||
user_id=user_id,
|
||||
conversation=conv,
|
||||
raw_user_query=message,
|
||||
full_message=message,
|
||||
memory_context=memory_ctx,
|
||||
current_datetime_context=current_datetime_context,
|
||||
current_datetime_reference=current_datetime_reference,
|
||||
user_llm_config=user_llm_config,
|
||||
runtime_request_context=runtime_request_context,
|
||||
recalled_retrospectives=recalled_retrospectives,
|
||||
skill_shortlist=skill_shortlist,
|
||||
)
|
||||
state.update(_derive_role_memory_contexts(memory_ctx))
|
||||
result_state = await graph.ainvoke(state)
|
||||
@@ -900,7 +1131,7 @@ class AgentService:
|
||||
continuity_snapshot = (
|
||||
_build_continuity_snapshot(result_state) if "result_state" in locals() else None
|
||||
)
|
||||
assistant_msg.attachments = (
|
||||
attachments = (
|
||||
[
|
||||
{
|
||||
"kind": "agent_continuity_state",
|
||||
@@ -908,8 +1139,26 @@ class AgentService:
|
||||
}
|
||||
]
|
||||
if continuity_snapshot
|
||||
else None
|
||||
else []
|
||||
)
|
||||
if "result_state" in locals() and "runtime_request_context" in locals():
|
||||
retrospective = build_session_retrospective(
|
||||
request_id=assistant_msg.id,
|
||||
session_id=conversation_id,
|
||||
user_query=message,
|
||||
state=result_state,
|
||||
runtime_context=runtime_request_context,
|
||||
)
|
||||
attachments = append_retrospective_attachment(attachments, retrospective)
|
||||
attachments.append(
|
||||
{
|
||||
"kind": "runtime_observability",
|
||||
"payload": build_runtime_observability_report(
|
||||
state=result_state,
|
||||
feature_flags=result_state.get("feature_flags") or {},
|
||||
),
|
||||
}
|
||||
)
|
||||
conv.agent_state = (
|
||||
{
|
||||
"kind": "agent_continuity_state",
|
||||
@@ -918,7 +1167,17 @@ class AgentService:
|
||||
if continuity_snapshot
|
||||
else None
|
||||
)
|
||||
assistant_msg.attachments = attachments or None
|
||||
await self.db.commit()
|
||||
await self.db.refresh(assistant_msg)
|
||||
schedule_retrospective_job(
|
||||
user_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
request_message_id=user_msg.id,
|
||||
response_message_id=assistant_msg.id,
|
||||
query_text=message,
|
||||
final_response=response_content,
|
||||
state=result_state if "result_state" in locals() else None,
|
||||
)
|
||||
|
||||
return conversation_id, assistant_msg.id, response_content, model_name_used
|
||||
|
||||
25
backend/app/services/rollback_controller.py
Normal file
25
backend/app/services/rollback_controller.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
FEATURE_FLAG_NAMES = (
|
||||
"ENABLE_RETROSPECTIVE",
|
||||
"ENABLE_SESSION_RETROSPECTIVE_SEARCH",
|
||||
"ENABLE_RUNTIME_SKILL_SHORTLIST",
|
||||
"ENABLE_LEARNING_SIGNALS",
|
||||
"ENABLE_SKILL_PROMOTION",
|
||||
"ENABLE_LEARNED_SKILL_LOADING",
|
||||
"ENABLE_PARALLEL_TASK_GRAPH",
|
||||
)
|
||||
|
||||
|
||||
class RollbackController:
|
||||
def snapshot_flags(self) -> dict[str, bool]:
|
||||
return {
|
||||
flag_name: bool(getattr(settings, flag_name, False))
|
||||
for flag_name in FEATURE_FLAG_NAMES
|
||||
}
|
||||
|
||||
def is_enabled(self, flag_name: str) -> bool:
|
||||
return bool(getattr(settings, flag_name, False))
|
||||
32
backend/app/services/runtime_observability.py
Normal file
32
backend/app/services/runtime_observability.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.agents.orchestration.monitor import build_parallel_runtime_metrics
|
||||
|
||||
|
||||
def build_runtime_observability_report(
|
||||
*,
|
||||
state: dict[str, Any],
|
||||
feature_flags: dict[str, bool] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
task_graph = state.get("task_graph") if isinstance(state.get("task_graph"), dict) else None
|
||||
scheduled_subtasks = (
|
||||
state.get("scheduled_subtasks") if isinstance(state.get("scheduled_subtasks"), list) else []
|
||||
)
|
||||
task_results = state.get("task_results") if isinstance(state.get("task_results"), list) else []
|
||||
merge_report = state.get("merge_report") if isinstance(state.get("merge_report"), dict) else None
|
||||
|
||||
return {
|
||||
"execution_mode": state.get("execution_mode"),
|
||||
"verification_status": state.get("verification_status"),
|
||||
"skill_shortlist_count": len(state.get("skill_shortlist") or []),
|
||||
"retrospective_shortlist_count": len(state.get("retrospective_shortlist") or []),
|
||||
"feature_flags": feature_flags or {},
|
||||
"parallel_metrics": build_parallel_runtime_metrics(
|
||||
task_graph=task_graph,
|
||||
scheduled_subtasks=scheduled_subtasks,
|
||||
task_results=task_results,
|
||||
merge_report=merge_report,
|
||||
),
|
||||
}
|
||||
@@ -3,9 +3,13 @@ Skill Service - 技能管理服务层
|
||||
负责技能的创建、查询、更新、删除等操作
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, or_
|
||||
from app.agents.schemas.learning import SkillCandidate
|
||||
from app.agents.skills.models import SkillLifecycleDecision
|
||||
from app.models.skill import Skill
|
||||
from app.models.user import User
|
||||
|
||||
@@ -28,6 +32,10 @@ class SkillService:
|
||||
visibility=data.get("visibility", "private"),
|
||||
team_id=data.get("team_id"),
|
||||
is_active=data.get("is_active", True),
|
||||
status=data.get("status", "active"),
|
||||
scope=data.get("scope", []),
|
||||
effectiveness=data.get("effectiveness", 0.0),
|
||||
review_after=data.get("review_after"),
|
||||
)
|
||||
self.db.add(skill)
|
||||
await self.db.commit()
|
||||
@@ -41,6 +49,17 @@ class SkillService:
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_name_for_user(self, user_id: str, name: str) -> Optional[Skill]:
|
||||
access_scope = or_(
|
||||
Skill.owner_id == user_id,
|
||||
Skill.visibility == "market",
|
||||
Skill.team_id == user_id,
|
||||
)
|
||||
result = await self.db.execute(
|
||||
select(Skill).where(and_(Skill.name == name, access_scope))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_for_user(
|
||||
self,
|
||||
user_id: str,
|
||||
@@ -56,7 +75,7 @@ class SkillService:
|
||||
Skill.team_id == user_id,
|
||||
)
|
||||
|
||||
filters = [access_scope, Skill.is_active == True]
|
||||
filters = [access_scope, Skill.is_active == True, Skill.status != "retired"]
|
||||
|
||||
if agent_type:
|
||||
filters.append(Skill.agent_type == agent_type)
|
||||
@@ -83,7 +102,7 @@ class SkillService:
|
||||
update_fields = [
|
||||
"name", "description", "instructions", "agent_type",
|
||||
"tools", "required_context", "output_format", "visibility",
|
||||
"team_id", "is_active"
|
||||
"team_id", "is_active", "status", "scope", "effectiveness", "review_after"
|
||||
]
|
||||
|
||||
for field in update_fields:
|
||||
@@ -117,6 +136,7 @@ class SkillService:
|
||||
and_(
|
||||
Skill.agent_type == agent_type,
|
||||
Skill.is_active == True,
|
||||
Skill.status == "active",
|
||||
or_(
|
||||
Skill.visibility == "market",
|
||||
Skill.visibility == "private"
|
||||
@@ -125,3 +145,234 @@ class SkillService:
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_runtime_candidates(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
agent_type: Optional[str] = None,
|
||||
include_shadow: bool = True,
|
||||
include_learned: bool = True,
|
||||
) -> list[Skill]:
|
||||
allowed_statuses = ["active", "shadow"] if include_shadow else ["active"]
|
||||
access_scope = or_(
|
||||
Skill.owner_id == user_id,
|
||||
Skill.visibility == "market",
|
||||
Skill.team_id == user_id,
|
||||
)
|
||||
|
||||
filters = [
|
||||
access_scope,
|
||||
Skill.is_active == True,
|
||||
Skill.status.in_(allowed_statuses),
|
||||
]
|
||||
if not include_learned:
|
||||
filters.append(Skill.is_builtin == True)
|
||||
if agent_type:
|
||||
filters.append(Skill.agent_type == agent_type)
|
||||
|
||||
result = await self.db.execute(select(Skill).where(and_(*filters)))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def upsert_learned_candidate(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
candidate: SkillCandidate,
|
||||
primary_agent: str | None,
|
||||
evidence_refs: list[dict] | None = None,
|
||||
) -> SkillLifecycleDecision:
|
||||
source_hash = self._build_candidate_source_hash(candidate)
|
||||
skill = await self.get_by_name_for_user(user_id, candidate.name)
|
||||
if skill is None:
|
||||
review_after = datetime.now(UTC) + timedelta(days=7)
|
||||
skill = Skill(
|
||||
owner_id=user_id,
|
||||
name=candidate.name,
|
||||
description=candidate.summary,
|
||||
instructions=candidate.summary,
|
||||
agent_type=primary_agent or "master",
|
||||
tools=[],
|
||||
required_context=[],
|
||||
output_format=None,
|
||||
visibility="private",
|
||||
is_active=True,
|
||||
status="candidate",
|
||||
scope=[primary_agent or "master", "learned", candidate.candidate_type],
|
||||
effectiveness=candidate.confidence,
|
||||
review_after=review_after,
|
||||
candidate_count=1,
|
||||
candidate_source_hashes=[source_hash],
|
||||
)
|
||||
self.db.add(skill)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(skill)
|
||||
return SkillLifecycleDecision(
|
||||
skill_name=skill.name,
|
||||
action="created_candidate",
|
||||
previous_status=None,
|
||||
new_status="candidate",
|
||||
reason="First learned candidate created from retrospective evidence.",
|
||||
evidence_refs=evidence_refs or [],
|
||||
confidence=candidate.confidence,
|
||||
review_after=review_after,
|
||||
)
|
||||
|
||||
previous_status = skill.status
|
||||
known_hashes = list(skill.candidate_source_hashes or [])
|
||||
is_duplicate_candidate = source_hash in known_hashes
|
||||
if not is_duplicate_candidate:
|
||||
skill.candidate_count = int(skill.candidate_count or 0) + 1
|
||||
known_hashes.append(source_hash)
|
||||
skill.candidate_source_hashes = known_hashes
|
||||
current_effectiveness = float(skill.effectiveness or 0.0)
|
||||
skill.effectiveness = round(max(current_effectiveness, float(candidate.confidence or 0.0)), 3)
|
||||
skill.review_after = datetime.now(UTC) + timedelta(days=7)
|
||||
if primary_agent and primary_agent not in (skill.scope or []):
|
||||
skill.scope = [*(skill.scope or []), primary_agent]
|
||||
|
||||
action = "no_change"
|
||||
reason = "Candidate evidence refreshed."
|
||||
if is_duplicate_candidate:
|
||||
reason = "Duplicate candidate evidence ignored for promotion counting."
|
||||
if (
|
||||
not is_duplicate_candidate
|
||||
and skill.status == "candidate"
|
||||
and skill.candidate_count >= 2
|
||||
and skill.effectiveness >= 0.6
|
||||
):
|
||||
skill.status = "shadow"
|
||||
action = "promoted_to_shadow"
|
||||
reason = "Repeated candidate evidence promoted the learned skill to shadow."
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(skill)
|
||||
return SkillLifecycleDecision(
|
||||
skill_name=skill.name,
|
||||
action=action,
|
||||
previous_status=previous_status,
|
||||
new_status=skill.status,
|
||||
reason=reason,
|
||||
evidence_refs=evidence_refs or [],
|
||||
confidence=skill.effectiveness,
|
||||
review_after=skill.review_after,
|
||||
)
|
||||
|
||||
async def record_activation_feedback(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
skill_name: str,
|
||||
outcome_score: float,
|
||||
evidence_refs: list[dict] | None = None,
|
||||
) -> SkillLifecycleDecision | None:
|
||||
skill = await self.get_by_name_for_user(user_id, skill_name)
|
||||
if skill is None or skill.status not in {"shadow", "active", "deprecated"}:
|
||||
return None
|
||||
|
||||
previous_status = skill.status
|
||||
previous_activation_count = int(skill.activation_count or 0)
|
||||
skill.activation_count = previous_activation_count + 1
|
||||
skill.last_activated_at = datetime.now(UTC)
|
||||
|
||||
previous_effectiveness = float(skill.effectiveness or 0.0)
|
||||
if previous_activation_count <= 0:
|
||||
skill.effectiveness = round(outcome_score, 3)
|
||||
else:
|
||||
skill.effectiveness = round(
|
||||
((previous_effectiveness * previous_activation_count) + outcome_score)
|
||||
/ skill.activation_count,
|
||||
3,
|
||||
)
|
||||
|
||||
action = "feedback_recorded"
|
||||
reason = "Activation outcome recorded."
|
||||
if skill.status == "shadow" and skill.activation_count >= 2 and skill.effectiveness >= 0.7:
|
||||
skill.status = "active"
|
||||
action = "promoted_to_active"
|
||||
reason = "Shadow skill proved effective enough to become active."
|
||||
elif skill.status == "active" and skill.activation_count >= 3 and skill.effectiveness < 0.35:
|
||||
skill.status = "deprecated"
|
||||
action = "degraded_to_deprecated"
|
||||
reason = "Active skill underperformed repeatedly and was deprecated."
|
||||
elif skill.status == "deprecated" and skill.activation_count >= 4 and skill.effectiveness < 0.2:
|
||||
skill.status = "retired"
|
||||
action = "retired"
|
||||
reason = "Deprecated skill stayed ineffective and was retired."
|
||||
elif skill.status == "deprecated" and skill.effectiveness >= 0.65 and outcome_score >= 0.8:
|
||||
skill.status = "active"
|
||||
action = "reactivated"
|
||||
reason = "Deprecated skill recovered with strong positive feedback."
|
||||
|
||||
skill.review_after = datetime.now(UTC) + timedelta(days=7)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(skill)
|
||||
return SkillLifecycleDecision(
|
||||
skill_name=skill.name,
|
||||
action=action,
|
||||
previous_status=previous_status,
|
||||
new_status=skill.status,
|
||||
reason=reason,
|
||||
evidence_refs=evidence_refs or [],
|
||||
confidence=skill.effectiveness,
|
||||
review_after=skill.review_after,
|
||||
)
|
||||
|
||||
async def run_decay_review(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
as_of: datetime | None = None,
|
||||
) -> list[SkillLifecycleDecision]:
|
||||
review_time = as_of or datetime.now(UTC)
|
||||
result = await self.db.execute(
|
||||
select(Skill).where(
|
||||
and_(
|
||||
Skill.owner_id == user_id,
|
||||
Skill.is_active == True,
|
||||
Skill.status.in_(["shadow", "active", "deprecated"]),
|
||||
Skill.review_after.is_not(None),
|
||||
Skill.review_after <= review_time,
|
||||
)
|
||||
)
|
||||
)
|
||||
skills = list(result.scalars().all())
|
||||
decisions: list[SkillLifecycleDecision] = []
|
||||
for skill in skills:
|
||||
previous_status = skill.status
|
||||
action = "no_change"
|
||||
reason = "Review completed without status change."
|
||||
|
||||
if skill.status == "shadow" and float(skill.effectiveness or 0.0) < 0.45:
|
||||
skill.status = "deprecated"
|
||||
action = "degraded_to_deprecated"
|
||||
reason = "Shadow skill review found low effectiveness."
|
||||
elif skill.status == "deprecated" and float(skill.effectiveness or 0.0) < 0.2:
|
||||
skill.status = "retired"
|
||||
action = "retired"
|
||||
reason = "Deprecated skill remained weak through review."
|
||||
|
||||
skill.review_after = review_time + timedelta(days=7)
|
||||
decisions.append(
|
||||
SkillLifecycleDecision(
|
||||
skill_name=skill.name,
|
||||
action=action,
|
||||
previous_status=previous_status,
|
||||
new_status=skill.status,
|
||||
reason=reason,
|
||||
confidence=skill.effectiveness,
|
||||
review_after=skill.review_after,
|
||||
)
|
||||
)
|
||||
|
||||
await self.db.commit()
|
||||
return decisions
|
||||
|
||||
@staticmethod
|
||||
def _build_candidate_source_hash(candidate: SkillCandidate) -> str:
|
||||
raw = (
|
||||
f"{candidate.name}|{candidate.summary}|"
|
||||
f"{','.join(candidate.source_pattern_ids)}|"
|
||||
f"{len(candidate.evidence_refs)}"
|
||||
).encode("utf-8")
|
||||
return hashlib.sha1(raw).hexdigest()
|
||||
|
||||
@@ -4,6 +4,9 @@ import os
|
||||
import platform
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
try:
|
||||
import psutil
|
||||
@@ -15,6 +18,10 @@ class SystemService:
|
||||
_last_net_bytes_sent: int | None = None
|
||||
_last_net_bytes_recv: int | None = None
|
||||
_last_net_sample_at: float | None = None
|
||||
_weather_cache: dict | None = None
|
||||
_weather_cached_at: float | None = None
|
||||
_weather_cached_location: str | None = None
|
||||
_weather_cache_ttl_seconds: float = 10 * 60 # 10 minutes
|
||||
|
||||
def __init__(self):
|
||||
# Import settings here to avoid circular imports
|
||||
@@ -134,8 +141,95 @@ class SystemService:
|
||||
'timestamp': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
def get_config(self) -> dict:
|
||||
async def _fetch_weather(self, location: str) -> dict:
|
||||
try:
|
||||
timeout = httpx.Timeout(10.0, connect=5.0)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.get(f'https://wttr.in/{location}', params={'format': 'j1'})
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
current = (payload.get('current_condition') or [{}])[0]
|
||||
weather_code = current.get('weatherCode')
|
||||
temp = current.get('temp_C')
|
||||
parsed_code = int(weather_code) if weather_code is not None and str(weather_code).isdigit() else None
|
||||
if parsed_code is None or temp in (None, ''):
|
||||
return {'weather_code': None, 'weather_summary': 'Weather unavailable'}
|
||||
|
||||
label = self._weather_code_label(parsed_code)
|
||||
return {
|
||||
'weather_code': parsed_code,
|
||||
'weather_summary': f'{label} {temp}°C',
|
||||
}
|
||||
except (httpx.HTTPError, ValueError, TypeError):
|
||||
return {'weather_code': None, 'weather_summary': 'Weather unavailable'}
|
||||
|
||||
@staticmethod
|
||||
def _weather_code_label(code: int | None) -> str:
|
||||
if code == 0:
|
||||
return 'Clear'
|
||||
if code in {1, 2}:
|
||||
return 'Partly Cloudy'
|
||||
if code == 3:
|
||||
return 'Overcast'
|
||||
if code in {45, 48}:
|
||||
return 'Fog'
|
||||
if code in {51, 53, 55, 56, 57}:
|
||||
return 'Drizzle'
|
||||
if code in {61, 63, 65, 66, 67, 80, 81, 82}:
|
||||
return 'Rain'
|
||||
if code in {71, 73, 75, 77, 85, 86}:
|
||||
return 'Snow'
|
||||
if code in {95, 96, 99}:
|
||||
return 'Thunderstorm'
|
||||
return 'Weather'
|
||||
|
||||
async def get_config(self) -> dict:
|
||||
"""Get public system configuration."""
|
||||
location = self._settings.LOCATION
|
||||
now = time.time()
|
||||
cached_weather = self.__class__._weather_cache
|
||||
cached_at = self.__class__._weather_cached_at
|
||||
cached_location = self.__class__._weather_cached_location
|
||||
|
||||
cache_is_valid = (
|
||||
cached_weather is not None
|
||||
and cached_at is not None
|
||||
and cached_location == location
|
||||
and (now - cached_at) < self.__class__._weather_cache_ttl_seconds
|
||||
)
|
||||
|
||||
if cache_is_valid:
|
||||
return {
|
||||
'location': location,
|
||||
**cached_weather,
|
||||
'weather_cached': True,
|
||||
'weather_cached_at': cached_at,
|
||||
}
|
||||
|
||||
weather = await self._fetch_weather(location)
|
||||
|
||||
# If fetch failed but we have *any* last known weather for same location, return it to avoid UI flicker.
|
||||
if (
|
||||
(weather.get('weather_code') is None)
|
||||
and cached_weather is not None
|
||||
and cached_location == location
|
||||
):
|
||||
return {
|
||||
'location': location,
|
||||
**cached_weather,
|
||||
'weather_cached': True,
|
||||
'weather_cached_at': cached_at,
|
||||
'weather_stale': True,
|
||||
}
|
||||
|
||||
# Update cache on successful/meaningful payload (or keep "unavailable" if never succeeded).
|
||||
self.__class__._weather_cache = weather
|
||||
self.__class__._weather_cached_at = now
|
||||
self.__class__._weather_cached_location = location
|
||||
|
||||
return {
|
||||
'location': self._settings.LOCATION,
|
||||
'location': location,
|
||||
**weather,
|
||||
'weather_cached': False,
|
||||
'weather_cached_at': now,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user