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:
2026-04-08 00:12:08 +08:00
parent 36c93a764f
commit 74fdfc2652
5 changed files with 675 additions and 14 deletions

View File

@@ -7,12 +7,13 @@ import json
import uuid import uuid
import logging import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from time import perf_counter
from typing import Any, AsyncGenerator from typing import Any, AsyncGenerator
import asyncio import asyncio
from openai import BadRequestError from openai import BadRequestError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select 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.database import async_session
from app.logging_utils import summarize_llm_config 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.models.user import User
from app.agents.graph import get_agent_graph from app.agents.graph import get_agent_graph
from app.agents.context import set_current_user, clear_current_user 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.registry import get_skill_registry
from app.agents.skills.retriever import shortlist_skills_for_request
from app.services import memory_service from app.services import memory_service
from app.services.brain_service import BrainService from app.services.brain_service import BrainService
from app.services.llm_service import create_llm_from_config, resolve_provider_capabilities 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.tools.time_reasoning import extract_reference_datetime
from app.agents.state import initial_state 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]: 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: def _is_streaming_rejection_error(error: Exception, user_llm_config: dict | None) -> bool:
capabilities = resolve_provider_capabilities(user_llm_config) capabilities = resolve_provider_capabilities(user_llm_config)
error_text = str(error).lower() error_text = str(error).lower()
@@ -461,18 +512,27 @@ class AgentService:
async def _build_agent_state( async def _build_agent_state(
self, self,
*, *,
request_id: str,
user_id: str, user_id: str,
conversation: Conversation, conversation: Conversation,
raw_user_query: str,
full_message: str, full_message: str,
memory_context: str | None, memory_context: str | None,
current_datetime_context: str, current_datetime_context: str,
current_datetime_reference: dict[str, str], current_datetime_reference: dict[str, str],
user_llm_config: dict | None, user_llm_config: dict | None,
runtime_request_context: RuntimeRequestContext,
recalled_retrospectives: list[dict[str, Any]],
skill_shortlist: list[dict[str, Any]],
) -> dict[str, Any]: ) -> dict[str, Any]:
state = initial_state(user_id, conversation.id) state = initial_state(user_id, conversation.id)
runtime_summary = render_runtime_request_context_summary(runtime_request_context)
state.update( state.update(
{ {
"messages": [HumanMessage(content=full_message)], "messages": [
SystemMessage(content=runtime_summary),
HumanMessage(content=full_message),
],
"memory_context": memory_context, "memory_context": memory_context,
"current_datetime_context": current_datetime_context, "current_datetime_context": current_datetime_context,
"current_datetime_reference": current_datetime_reference, "current_datetime_reference": current_datetime_reference,
@@ -482,9 +542,119 @@ class AgentService:
previous_snapshot = await self._load_continuity_snapshot(conversation) previous_snapshot = await self._load_continuity_snapshot(conversation)
if previous_snapshot: if previous_snapshot:
state.update(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 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( async def chat(
self, self,
user_id: str, user_id: str,
@@ -610,21 +780,38 @@ class AgentService:
async def run_agent(): async def run_agent():
collected = "" collected = ""
state: dict[str, Any] | None = None state: dict[str, Any] | None = None
runtime_request_context: RuntimeRequestContext | None = None
set_current_user(user_id) set_current_user(user_id)
try: try:
graph = get_agent_graph() graph = get_agent_graph()
current_datetime_context, current_datetime_reference = ( current_datetime_context, current_datetime_reference = (
self._build_current_datetime_context() 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, user_id=user_id,
conversation=conv, 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, full_message=full_message,
memory_context=memory_ctx, memory_context=memory_ctx,
current_datetime_context=current_datetime_context, current_datetime_context=current_datetime_context,
current_datetime_reference=current_datetime_reference, current_datetime_reference=current_datetime_reference,
user_llm_config=user_llm_config, 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)) state.update(_derive_role_memory_contexts(memory_ctx))
@@ -749,7 +936,7 @@ class AgentService:
if collected: if collected:
assistant_msg.content = collected assistant_msg.content = collected
continuity_snapshot = _build_continuity_snapshot(state or {}) continuity_snapshot = _build_continuity_snapshot(state or {})
assistant_msg.attachments = ( attachments = (
[ [
{ {
"kind": "agent_continuity_state", "kind": "agent_continuity_state",
@@ -757,8 +944,26 @@ class AgentService:
} }
] ]
if continuity_snapshot 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 = ( conv.agent_state = (
{ {
"kind": "agent_continuity_state", "kind": "agent_continuity_state",
@@ -771,8 +976,18 @@ class AgentService:
user_id, user_id,
**_build_assistant_event_payload(collected), **_build_assistant_event_payload(collected),
) )
assistant_msg.attachments = attachments or None
await self.db.commit() await self.db.commit()
await self.db.refresh(assistant_msg) 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: except Exception:
logger.exception("save_assistant_message_failed") logger.exception("save_assistant_message_failed")
asyncio.create_task(self._try_auto_summarize_background(user_id, conversation_id)) asyncio.create_task(self._try_auto_summarize_background(user_id, conversation_id))
@@ -863,14 +1078,30 @@ class AgentService:
current_datetime_context, current_datetime_reference = ( current_datetime_context, current_datetime_reference = (
self._build_current_datetime_context() 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, user_id=user_id,
conversation=conv, 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, full_message=message,
memory_context=memory_ctx, memory_context=memory_ctx,
current_datetime_context=current_datetime_context, current_datetime_context=current_datetime_context,
current_datetime_reference=current_datetime_reference, current_datetime_reference=current_datetime_reference,
user_llm_config=user_llm_config, 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)) state.update(_derive_role_memory_contexts(memory_ctx))
result_state = await graph.ainvoke(state) result_state = await graph.ainvoke(state)
@@ -900,7 +1131,7 @@ class AgentService:
continuity_snapshot = ( continuity_snapshot = (
_build_continuity_snapshot(result_state) if "result_state" in locals() else None _build_continuity_snapshot(result_state) if "result_state" in locals() else None
) )
assistant_msg.attachments = ( attachments = (
[ [
{ {
"kind": "agent_continuity_state", "kind": "agent_continuity_state",
@@ -908,8 +1139,26 @@ class AgentService:
} }
] ]
if continuity_snapshot 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 = ( conv.agent_state = (
{ {
"kind": "agent_continuity_state", "kind": "agent_continuity_state",
@@ -918,7 +1167,17 @@ class AgentService:
if continuity_snapshot if continuity_snapshot
else None else None
) )
assistant_msg.attachments = attachments or None
await self.db.commit() await self.db.commit()
await self.db.refresh(assistant_msg) 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 return conversation_id, assistant_msg.id, response_content, model_name_used

View 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))

View 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,
),
}

View File

@@ -3,9 +3,13 @@ Skill Service - 技能管理服务层
负责技能的创建、查询、更新、删除等操作 负责技能的创建、查询、更新、删除等操作
""" """
import hashlib
from datetime import UTC, datetime, timedelta
from typing import Optional from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_ 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.skill import Skill
from app.models.user import User from app.models.user import User
@@ -28,6 +32,10 @@ class SkillService:
visibility=data.get("visibility", "private"), visibility=data.get("visibility", "private"),
team_id=data.get("team_id"), team_id=data.get("team_id"),
is_active=data.get("is_active", True), 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) self.db.add(skill)
await self.db.commit() await self.db.commit()
@@ -41,6 +49,17 @@ class SkillService:
) )
return result.scalar_one_or_none() 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( async def list_for_user(
self, self,
user_id: str, user_id: str,
@@ -56,7 +75,7 @@ class SkillService:
Skill.team_id == user_id, 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: if agent_type:
filters.append(Skill.agent_type == agent_type) filters.append(Skill.agent_type == agent_type)
@@ -83,7 +102,7 @@ class SkillService:
update_fields = [ update_fields = [
"name", "description", "instructions", "agent_type", "name", "description", "instructions", "agent_type",
"tools", "required_context", "output_format", "visibility", "tools", "required_context", "output_format", "visibility",
"team_id", "is_active" "team_id", "is_active", "status", "scope", "effectiveness", "review_after"
] ]
for field in update_fields: for field in update_fields:
@@ -117,6 +136,7 @@ class SkillService:
and_( and_(
Skill.agent_type == agent_type, Skill.agent_type == agent_type,
Skill.is_active == True, Skill.is_active == True,
Skill.status == "active",
or_( or_(
Skill.visibility == "market", Skill.visibility == "market",
Skill.visibility == "private" Skill.visibility == "private"
@@ -125,3 +145,234 @@ class SkillService:
) )
) )
return list(result.scalars().all()) 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()

View File

@@ -4,6 +4,9 @@ import os
import platform import platform
import socket import socket
import subprocess import subprocess
import time
import httpx
try: try:
import psutil import psutil
@@ -15,6 +18,10 @@ class SystemService:
_last_net_bytes_sent: int | None = None _last_net_bytes_sent: int | None = None
_last_net_bytes_recv: int | None = None _last_net_bytes_recv: int | None = None
_last_net_sample_at: float | 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): def __init__(self):
# Import settings here to avoid circular imports # Import settings here to avoid circular imports
@@ -134,8 +141,95 @@ class SystemService:
'timestamp': datetime.now(UTC).isoformat(), '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.""" """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 { return {
'location': self._settings.LOCATION, 'location': location,
**weather,
'weather_cached': False,
'weather_cached_at': now,
} }