Compare commits
11 Commits
536c541a5b
...
51e38e039b
| Author | SHA1 | Date | |
|---|---|---|---|
| 51e38e039b | |||
| e637c8ca2f | |||
| 52fb619084 | |||
| dc9051debc | |||
| 74fdfc2652 | |||
| 36c93a764f | |||
| 72a60c698a | |||
| 4ef7549efe | |||
| de08165e07 | |||
| 4702cc8ed2 | |||
| 62bf414ff2 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -40,6 +40,9 @@ logs/
|
|||||||
.claude/
|
.claude/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
|
# Demo (excluded from version control)
|
||||||
|
demo/
|
||||||
|
|
||||||
# Lock files (use in development, commit in production)
|
# Lock files (use in development, commit in production)
|
||||||
# uv.lock - uncomment if you want to commit lock file
|
# uv.lock - uncomment if you want to commit lock file
|
||||||
# package-lock.json - uncomment if you want to commit lock file
|
# package-lock.json - uncomment if you want to commit lock file
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ from app.agents.prompts import (
|
|||||||
MASTER_SYSTEM_PROMPT,
|
MASTER_SYSTEM_PROMPT,
|
||||||
SCHEDULE_PLANNER_SYSTEM_PROMPT,
|
SCHEDULE_PLANNER_SYSTEM_PROMPT,
|
||||||
)
|
)
|
||||||
|
from app.agents.orchestration.result_merge import merge_task_results
|
||||||
|
from app.agents.orchestration.scheduler import build_subtask_specs, ensure_child_links
|
||||||
|
from app.agents.orchestration.subagent_runtime import subtask_spec_to_agent_task
|
||||||
from app.agents.registry import load_builtin_registry_indexes
|
from app.agents.registry import load_builtin_registry_indexes
|
||||||
from app.agents.runtime_metrics import (
|
from app.agents.runtime_metrics import (
|
||||||
coerce_cost_thresholds,
|
coerce_cost_thresholds,
|
||||||
@@ -36,6 +39,14 @@ from app.agents.runtime_metrics import (
|
|||||||
)
|
)
|
||||||
from app.agents.schemas.event import AgentEvent
|
from app.agents.schemas.event import AgentEvent
|
||||||
from app.agents.schemas.message import AgentMessage
|
from app.agents.schemas.message import AgentMessage
|
||||||
|
from app.agents.schemas.orchestration import (
|
||||||
|
ExecutionDecision,
|
||||||
|
MergeReport,
|
||||||
|
RuntimeRequestContext,
|
||||||
|
TaskGraph,
|
||||||
|
VerificationReport,
|
||||||
|
render_runtime_request_context_summary,
|
||||||
|
)
|
||||||
from app.agents.schemas.task import (
|
from app.agents.schemas.task import (
|
||||||
AgentTask,
|
AgentTask,
|
||||||
CollaborationBudget,
|
CollaborationBudget,
|
||||||
@@ -44,6 +55,7 @@ from app.agents.schemas.task import (
|
|||||||
TaskResult,
|
TaskResult,
|
||||||
)
|
)
|
||||||
from app.agents.skill_registry import build_skill_context
|
from app.agents.skill_registry import build_skill_context
|
||||||
|
from app.agents.skills.retriever import build_shortlisted_skill_context
|
||||||
from app.agents.state import AgentRole, AgentState
|
from app.agents.state import AgentRole, AgentState
|
||||||
from app.agents.tools import SUB_COMMANDER_TOOLSETS
|
from app.agents.tools import SUB_COMMANDER_TOOLSETS
|
||||||
from app.agents.tools.time_reasoning import normalize_tool_time_arguments
|
from app.agents.tools.time_reasoning import normalize_tool_time_arguments
|
||||||
@@ -1148,6 +1160,57 @@ def _parse_json_action(content: str, allowed_tools: list[str]) -> dict[str, Any]
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_internal_tool_markup(content: str) -> bool:
|
||||||
|
text = (content or "").strip()
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
|
||||||
|
lowered = text.lower()
|
||||||
|
xml_markers = (
|
||||||
|
"<minimax:tool_call",
|
||||||
|
"</minimax:tool_call>",
|
||||||
|
"<invoke name=",
|
||||||
|
"</invoke>",
|
||||||
|
"<parameter name=",
|
||||||
|
"</parameter>",
|
||||||
|
)
|
||||||
|
if any(marker in lowered for marker in xml_markers):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return "分发说明" in text and ("<invoke name=" in lowered or "tool_call" in lowered)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_tool_result_for_user(tool_result: str | None) -> str:
|
||||||
|
text = (tool_result or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
cleaned_lines = [
|
||||||
|
re.sub(r"^\[[^\]]+\]\s*", "", line).strip()
|
||||||
|
for line in text.splitlines()
|
||||||
|
if line.strip()
|
||||||
|
]
|
||||||
|
return "\n".join(line for line in cleaned_lines if line).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_internal_markup_fallback_response(
|
||||||
|
state: AgentState,
|
||||||
|
*,
|
||||||
|
sub_commander: str,
|
||||||
|
) -> str | None:
|
||||||
|
tool_result = state.get("last_tool_result")
|
||||||
|
cleaned_tool_result = _clean_tool_result_for_user(tool_result)
|
||||||
|
if not cleaned_tool_result or _tool_result_indicates_failure(cleaned_tool_result):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if sub_commander == "librarian_retrieval":
|
||||||
|
if _is_missing_knowledge_result(tool_result):
|
||||||
|
return "这次检索没有拿到有效证据。我先不展示内部调度过程;如果您愿意,我可以直接基于常识回答,或改为联网搜索后再整理。"
|
||||||
|
return f"我已经完成检索,直接给您可用信息:\n\n{cleaned_tool_result}"
|
||||||
|
|
||||||
|
return cleaned_tool_result
|
||||||
|
|
||||||
|
|
||||||
def _has_active_structured_continuation(state: AgentState) -> bool:
|
def _has_active_structured_continuation(state: AgentState) -> bool:
|
||||||
pending_action = state.get("pending_action") or {}
|
pending_action = state.get("pending_action") or {}
|
||||||
routing_decision = state.get("routing_decision") or {}
|
routing_decision = state.get("routing_decision") or {}
|
||||||
@@ -1205,6 +1268,70 @@ def _build_structured_continuity_summary(state: AgentState) -> str | None:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_retrospective_context_summary(state: AgentState) -> str | None:
|
||||||
|
retrospectives = list(state.get("recalled_retrospectives") or [])
|
||||||
|
if not retrospectives:
|
||||||
|
return None
|
||||||
|
|
||||||
|
lines = ["【相关历史复盘】"]
|
||||||
|
for item in retrospectives[:2]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
request_summary = str(item.get("request_summary") or item.get("task_type") or "").strip()
|
||||||
|
execution_mode = str(item.get("execution_mode") or "").strip()
|
||||||
|
success_score = float(item.get("success_score") or 0.0)
|
||||||
|
reusable_patterns = list(item.get("reusable_patterns") or [])
|
||||||
|
avoid_patterns = list(item.get("avoid_patterns") or [])
|
||||||
|
|
||||||
|
summary_parts = [request_summary[:80] or execution_mode or "历史任务"]
|
||||||
|
if execution_mode:
|
||||||
|
summary_parts.append(f"mode={execution_mode}")
|
||||||
|
summary_parts.append(f"score={success_score:.2f}")
|
||||||
|
if reusable_patterns:
|
||||||
|
summary_parts.append(f"可复用={','.join(reusable_patterns[:2])}")
|
||||||
|
elif avoid_patterns:
|
||||||
|
summary_parts.append(f"避坑={','.join(avoid_patterns[:2])}")
|
||||||
|
|
||||||
|
lines.append(f"- {';'.join(summary_parts)}")
|
||||||
|
|
||||||
|
return "\n".join(lines) if len(lines) > 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_request_complexity(user_query: str, selected_roles: list[str]) -> float:
|
||||||
|
text = (user_query or "").strip()
|
||||||
|
base = min(len(text) / 120.0, 1.0)
|
||||||
|
role_boost = min(len(selected_roles) * 0.2, 0.6)
|
||||||
|
return round(min(base + role_boost, 1.0), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_execution_decision(
|
||||||
|
state: AgentState,
|
||||||
|
*,
|
||||||
|
user_query: str,
|
||||||
|
mode: Literal["direct", "collaboration", "parallel"],
|
||||||
|
reason: str,
|
||||||
|
selected_roles: list[str] | None = None,
|
||||||
|
parallel_worthiness_score: float | None = None,
|
||||||
|
) -> None:
|
||||||
|
runtime_request_context = state.get("runtime_request_context") or {}
|
||||||
|
request_id = str(runtime_request_context.get("request_id") or state.get("conversation_id") or "")
|
||||||
|
roles = list(selected_roles or [])
|
||||||
|
decision = ExecutionDecision(
|
||||||
|
request_id=request_id or f"request-{uuid4().hex[:8]}",
|
||||||
|
mode=mode,
|
||||||
|
reason=reason,
|
||||||
|
complexity_score=_estimate_request_complexity(user_query, roles),
|
||||||
|
parallel_worthiness_score=parallel_worthiness_score,
|
||||||
|
selected_roles=roles,
|
||||||
|
)
|
||||||
|
state["execution_decision"] = decision.model_dump(mode="json")
|
||||||
|
_append_event_trace(
|
||||||
|
state,
|
||||||
|
"agent.execution.decided",
|
||||||
|
payload=state["execution_decision"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_system_messages(
|
def _build_system_messages(
|
||||||
state: AgentState, system_prompt: str, role: AgentRole, sub_commander: str
|
state: AgentState, system_prompt: str, role: AgentRole, sub_commander: str
|
||||||
) -> list[BaseMessage]:
|
) -> list[BaseMessage]:
|
||||||
@@ -1214,6 +1341,19 @@ def _build_system_messages(
|
|||||||
if current_datetime_context:
|
if current_datetime_context:
|
||||||
messages.append(SystemMessage(content=current_datetime_context))
|
messages.append(SystemMessage(content=current_datetime_context))
|
||||||
|
|
||||||
|
runtime_request_context = state.get("runtime_request_context")
|
||||||
|
if isinstance(runtime_request_context, dict) and runtime_request_context:
|
||||||
|
try:
|
||||||
|
runtime_context_model = RuntimeRequestContext.model_validate(runtime_request_context)
|
||||||
|
except Exception:
|
||||||
|
runtime_context_model = None
|
||||||
|
if runtime_context_model is not None:
|
||||||
|
messages.append(
|
||||||
|
SystemMessage(
|
||||||
|
content=render_runtime_request_context_summary(runtime_context_model)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
continuity_summary = _build_structured_continuity_summary(state)
|
continuity_summary = _build_structured_continuity_summary(state)
|
||||||
if continuity_summary:
|
if continuity_summary:
|
||||||
messages.append(SystemMessage(content=continuity_summary))
|
messages.append(SystemMessage(content=continuity_summary))
|
||||||
@@ -1226,6 +1366,10 @@ def _build_system_messages(
|
|||||||
if collaboration_summary:
|
if collaboration_summary:
|
||||||
messages.append(SystemMessage(content=collaboration_summary))
|
messages.append(SystemMessage(content=collaboration_summary))
|
||||||
|
|
||||||
|
retrospective_summary = _build_retrospective_context_summary(state)
|
||||||
|
if retrospective_summary:
|
||||||
|
messages.append(SystemMessage(content=retrospective_summary))
|
||||||
|
|
||||||
role_context_map = {
|
role_context_map = {
|
||||||
AgentRole.SCHEDULE_PLANNER: state.get("schedule_context_summary"),
|
AgentRole.SCHEDULE_PLANNER: state.get("schedule_context_summary"),
|
||||||
AgentRole.LIBRARIAN: state.get("knowledge_context"),
|
AgentRole.LIBRARIAN: state.get("knowledge_context"),
|
||||||
@@ -1237,7 +1381,11 @@ def _build_system_messages(
|
|||||||
|
|
||||||
role_skill_key = ROLE_SKILL_CONTEXT.get(role)
|
role_skill_key = ROLE_SKILL_CONTEXT.get(role)
|
||||||
if role_skill_key:
|
if role_skill_key:
|
||||||
skill_context = build_skill_context(role_skill_key)
|
shortlisted_context = build_shortlisted_skill_context(
|
||||||
|
state.get("skill_shortlist"),
|
||||||
|
agent_type=role_skill_key,
|
||||||
|
)
|
||||||
|
skill_context = shortlisted_context or build_skill_context(role_skill_key)
|
||||||
if skill_context:
|
if skill_context:
|
||||||
messages.append(SystemMessage(content=skill_context))
|
messages.append(SystemMessage(content=skill_context))
|
||||||
|
|
||||||
@@ -1322,6 +1470,29 @@ def _build_collaboration_tasks(user_query: str) -> list[AgentTask]:
|
|||||||
return tasks
|
return tasks
|
||||||
|
|
||||||
|
|
||||||
|
def _build_collaboration_plan_from_task_graph(
|
||||||
|
state: AgentState,
|
||||||
|
user_query: str,
|
||||||
|
) -> tuple[list[AgentTask], list[dict[str, Any]]]:
|
||||||
|
raw_task_graph = state.get("task_graph")
|
||||||
|
if not isinstance(raw_task_graph, dict) or not raw_task_graph.get("nodes"):
|
||||||
|
return _build_collaboration_tasks(user_query), []
|
||||||
|
|
||||||
|
task_graph = TaskGraph.model_validate(raw_task_graph)
|
||||||
|
specs = [
|
||||||
|
spec
|
||||||
|
for spec in build_subtask_specs(task_graph, query_text=user_query)
|
||||||
|
if spec.role != "master"
|
||||||
|
]
|
||||||
|
child_links = ensure_child_links(specs)
|
||||||
|
tasks: list[AgentTask] = []
|
||||||
|
for spec in specs:
|
||||||
|
task = subtask_spec_to_agent_task(spec)
|
||||||
|
task.child_task_ids = child_links.get(spec.subtask_id, [])
|
||||||
|
tasks.append(task)
|
||||||
|
return tasks, [spec.model_dump(mode="json") for spec in specs]
|
||||||
|
|
||||||
|
|
||||||
def _build_collaboration_context_summary(state: AgentState) -> str | None:
|
def _build_collaboration_context_summary(state: AgentState) -> str | None:
|
||||||
if state.get("execution_mode") != "collaboration":
|
if state.get("execution_mode") != "collaboration":
|
||||||
return None
|
return None
|
||||||
@@ -2076,7 +2247,36 @@ async def _run_sub_commander(
|
|||||||
)
|
)
|
||||||
_record_response_usage(state, response)
|
_record_response_usage(state, response)
|
||||||
response_text = _stringify_message_content(response.content)
|
response_text = _stringify_message_content(response.content)
|
||||||
|
response_text_stripped = response_text.strip()
|
||||||
parsed = _parse_json_action(response_text, allowed_tools)
|
parsed = _parse_json_action(response_text, allowed_tools)
|
||||||
|
if parsed is None and response_text_stripped and _looks_like_internal_tool_markup(
|
||||||
|
response_text_stripped
|
||||||
|
):
|
||||||
|
if int(state.get("retry_count") or 0) >= int(state.get("max_retries") or 0):
|
||||||
|
state["fallback_parse_error"] = "internal_tool_markup"
|
||||||
|
state["final_response"] = _build_internal_markup_fallback_response(
|
||||||
|
state,
|
||||||
|
sub_commander=sub_commander,
|
||||||
|
) or (
|
||||||
|
"这次内部调度没有正确收束成最终答复。我先不展示内部调用过程;您重试一次,我会直接用自然语言回答。"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
if not _guard_sub_commander_budget(
|
||||||
|
state, "iteration_count", "max_iterations", "max_iterations_exceeded"
|
||||||
|
):
|
||||||
|
parsed = None
|
||||||
|
break
|
||||||
|
state["iteration_count"] = int(state.get("iteration_count") or 0) + 1
|
||||||
|
state["retry_count"] = int(state.get("retry_count") or 0) + 1
|
||||||
|
retry_instruction = SystemMessage(
|
||||||
|
content=(
|
||||||
|
"上一轮输出了内部调度或工具调用标记,这是协议错误。"
|
||||||
|
"不要再输出分发说明、XML 标签、<invoke>、<parameter>、JSON 或 tool_call。"
|
||||||
|
"请直接面向用户给出最终自然语言答复;如果已有工具结果,就基于结果整理;"
|
||||||
|
"如果工具没有找到证据,可以基于常识直接回答。"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
if parsed is None and response_text.strip() and state.get("tool_round_count"):
|
if parsed is None and response_text.strip() and state.get("tool_round_count"):
|
||||||
state["fallback_parse_error"] = None
|
state["fallback_parse_error"] = None
|
||||||
state["final_response"] = response_text.strip()
|
state["final_response"] = response_text.strip()
|
||||||
@@ -2382,6 +2582,35 @@ def _build_collaboration_final_response(task_results: list[TaskResult | dict[str
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_serial_fallback_response(
|
||||||
|
user_query: str,
|
||||||
|
task_results: list[TaskResult | dict[str, Any]],
|
||||||
|
merge_report: MergeReport | dict[str, Any] | None,
|
||||||
|
) -> str:
|
||||||
|
normalized_results = [normalize_task_result(item) for item in task_results]
|
||||||
|
completed = [item for item in normalized_results if item.status == "completed"]
|
||||||
|
merge_payload = (
|
||||||
|
merge_report.model_dump(mode="json")
|
||||||
|
if isinstance(merge_report, MergeReport)
|
||||||
|
else dict(merge_report or {})
|
||||||
|
)
|
||||||
|
lines = [
|
||||||
|
"并行/协作结果出现冲突或失败,我已切回保守收敛路径。",
|
||||||
|
f"原始请求:{user_query}",
|
||||||
|
]
|
||||||
|
if completed:
|
||||||
|
lines.append("当前仍可确认的结果:")
|
||||||
|
for item in completed[:3]:
|
||||||
|
lines.append(f"- [{item.owner_agent_id or 'unknown'}] {item.summary or '已完成'}")
|
||||||
|
if merge_payload.get("conflict_flags"):
|
||||||
|
lines.append("冲突/回退原因:")
|
||||||
|
for flag in list(merge_payload.get("conflict_flags") or [])[:3]:
|
||||||
|
lines.append(f"- {flag}")
|
||||||
|
if not completed:
|
||||||
|
lines.append("目前没有足够稳定的子任务结果,建议改走 direct 或更小范围的 collaboration。")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _verify_collaboration_results(
|
def _verify_collaboration_results(
|
||||||
state: AgentState,
|
state: AgentState,
|
||||||
tasks: list[AgentTask],
|
tasks: list[AgentTask],
|
||||||
@@ -2411,6 +2640,14 @@ def _verify_collaboration_results(
|
|||||||
}
|
}
|
||||||
for item in normalized_results
|
for item in normalized_results
|
||||||
]
|
]
|
||||||
|
merge_report = merge_task_results([item.model_dump(mode="json") for item in normalized_results])
|
||||||
|
state["merge_report"] = merge_report.model_dump(mode="json")
|
||||||
|
_append_event_trace(
|
||||||
|
state,
|
||||||
|
"agent.merge.completed",
|
||||||
|
payload=state["merge_report"],
|
||||||
|
)
|
||||||
|
|
||||||
if missing_task_ids:
|
if missing_task_ids:
|
||||||
summary = f"协作结果不完整,缺少任务结果: {', '.join(missing_task_ids)}"
|
summary = f"协作结果不完整,缺少任务结果: {', '.join(missing_task_ids)}"
|
||||||
verdict = verify_task_result(
|
verdict = verify_task_result(
|
||||||
@@ -2426,20 +2663,51 @@ def _verify_collaboration_results(
|
|||||||
verdict = verify_task_result(
|
verdict = verify_task_result(
|
||||||
status="failed", summary=summary, evidence=verification_evidence
|
status="failed", summary=summary, evidence=verification_evidence
|
||||||
)
|
)
|
||||||
|
elif merge_report.status == "conflicted":
|
||||||
|
verdict = verify_task_result(
|
||||||
|
status="failed",
|
||||||
|
summary=merge_report.summary,
|
||||||
|
evidence=[
|
||||||
|
*verification_evidence,
|
||||||
|
{"type": "merge_conflict", "conflict_flags": merge_report.conflict_flags},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
elif merge_report.status == "fallback":
|
||||||
|
verdict = verify_task_result(
|
||||||
|
status="failed",
|
||||||
|
summary=merge_report.summary,
|
||||||
|
evidence=[
|
||||||
|
*verification_evidence,
|
||||||
|
{"type": "merge_fallback", "conflict_flags": merge_report.conflict_flags},
|
||||||
|
],
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
summary = f"协作模式已完成 {len(normalized_results)}/{len(tasks)} 个子任务,并为每个子任务回收了结果与 evidence。"
|
summary = (
|
||||||
|
merge_report.summary
|
||||||
|
or f"协作模式已完成 {len(normalized_results)}/{len(tasks)} 个子任务,并为每个子任务回收了结果与 evidence。"
|
||||||
|
)
|
||||||
verdict = verify_task_result(
|
verdict = verify_task_result(
|
||||||
status="passed", summary=summary, evidence=verification_evidence
|
status="passed", summary=summary, evidence=verification_evidence
|
||||||
)
|
)
|
||||||
|
|
||||||
updated_state = apply_verification_verdict(state, verdict)
|
updated_state = apply_verification_verdict(state, verdict)
|
||||||
state.update(updated_state)
|
state.update(updated_state)
|
||||||
|
state["verification_report"] = VerificationReport(
|
||||||
|
status=state.get("verification_status") or "skipped",
|
||||||
|
summary=state.get("verification_summary"),
|
||||||
|
evidence=list(state.get("verification_evidence") or []),
|
||||||
|
).model_dump(mode="json")
|
||||||
|
_append_event_trace(
|
||||||
|
state,
|
||||||
|
"agent.verify.completed",
|
||||||
|
payload=state["verification_report"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentState:
|
async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentState:
|
||||||
_set_phase(state, "phase_2_controlled_collaboration", reason="collaboration_flow_started")
|
_set_phase(state, "phase_2_controlled_collaboration", reason="collaboration_flow_started")
|
||||||
_record_checkpoint(state, "collaboration.tasks_planning", reason="collaboration_flow_started")
|
_record_checkpoint(state, "collaboration.tasks_planning", reason="collaboration_flow_started")
|
||||||
tasks = _build_collaboration_tasks(user_query)
|
tasks, scheduled_subtasks = _build_collaboration_plan_from_task_graph(state, user_query)
|
||||||
if len(tasks) < 2:
|
if len(tasks) < 2:
|
||||||
state["execution_mode"] = "direct"
|
state["execution_mode"] = "direct"
|
||||||
state["routing_decision"] = {"mode": "direct", "reason": "collaboration_plan_fell_back"}
|
state["routing_decision"] = {"mode": "direct", "reason": "collaboration_plan_fell_back"}
|
||||||
@@ -2487,6 +2755,7 @@ async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentSt
|
|||||||
"agent.collaboration.budget.updated",
|
"agent.collaboration.budget.updated",
|
||||||
payload=budget_snapshot,
|
payload=budget_snapshot,
|
||||||
)
|
)
|
||||||
|
state["scheduled_subtasks"] = scheduled_subtasks
|
||||||
state["active_tasks"] = [task.model_dump(mode="json") for task in tasks]
|
state["active_tasks"] = [task.model_dump(mode="json") for task in tasks]
|
||||||
_record_checkpoint(
|
_record_checkpoint(
|
||||||
state, "collaboration.tasks_ready", reason="tasks_built", payload={"task_count": len(tasks)}
|
state, "collaboration.tasks_ready", reason="tasks_built", payload={"task_count": len(tasks)}
|
||||||
@@ -2500,6 +2769,21 @@ async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentSt
|
|||||||
|
|
||||||
_set_phase(state, "phase_3_dynamic_collaboration", reason="collaboration_workers_dispatch")
|
_set_phase(state, "phase_3_dynamic_collaboration", reason="collaboration_workers_dispatch")
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
|
scheduled_subtask = next(
|
||||||
|
(item for item in scheduled_subtasks if item.get("subtask_id") == task.task_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if scheduled_subtask is not None:
|
||||||
|
_append_event_trace(
|
||||||
|
state,
|
||||||
|
"agent.subtask.started",
|
||||||
|
payload={
|
||||||
|
"subtask_id": scheduled_subtask.get("subtask_id"),
|
||||||
|
"role": scheduled_subtask.get("role"),
|
||||||
|
"dependencies": scheduled_subtask.get("dependencies") or [],
|
||||||
|
},
|
||||||
|
task_id=task.task_id,
|
||||||
|
)
|
||||||
_record_checkpoint(
|
_record_checkpoint(
|
||||||
state,
|
state,
|
||||||
"collaboration.task_dispatch",
|
"collaboration.task_dispatch",
|
||||||
@@ -2583,6 +2867,17 @@ async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentSt
|
|||||||
child_task_id=(task.child_task_ids or [None])[0],
|
child_task_id=(task.child_task_ids or [None])[0],
|
||||||
message_id=str(state.get("last_message_id") or "") or None,
|
message_id=str(state.get("last_message_id") or "") or None,
|
||||||
)
|
)
|
||||||
|
if scheduled_subtask is not None:
|
||||||
|
_append_event_trace(
|
||||||
|
state,
|
||||||
|
"agent.subtask.completed",
|
||||||
|
payload={
|
||||||
|
"subtask_id": scheduled_subtask.get("subtask_id"),
|
||||||
|
"status": task_result.status,
|
||||||
|
"summary": task_result.summary,
|
||||||
|
},
|
||||||
|
task_id=task.task_id,
|
||||||
|
)
|
||||||
_apply_task_result_to_state(state, task, task_result)
|
_apply_task_result_to_state(state, task, task_result)
|
||||||
|
|
||||||
if task_result.status != "completed":
|
if task_result.status != "completed":
|
||||||
@@ -2618,6 +2913,26 @@ async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentSt
|
|||||||
},
|
},
|
||||||
severity="error" if state.get("verification_status") == "failed" else "info",
|
severity="error" if state.get("verification_status") == "failed" else "info",
|
||||||
)
|
)
|
||||||
|
merge_report = state.get("merge_report") or {}
|
||||||
|
if state.get("verification_status") == "failed" and merge_report.get("fallback_used"):
|
||||||
|
state["final_response"] = _build_serial_fallback_response(
|
||||||
|
user_query,
|
||||||
|
state.get("task_results") or [],
|
||||||
|
merge_report,
|
||||||
|
)
|
||||||
|
state["routing_decision"] = {
|
||||||
|
"mode": "direct",
|
||||||
|
"reason": "fallback_to_serial_recovery",
|
||||||
|
}
|
||||||
|
_append_event_trace(
|
||||||
|
state,
|
||||||
|
"agent.rollback.triggered",
|
||||||
|
payload={
|
||||||
|
"layer": "collaboration_runtime",
|
||||||
|
"reason": "merge_fallback_used",
|
||||||
|
},
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
_record_checkpoint(
|
_record_checkpoint(
|
||||||
state,
|
state,
|
||||||
"collaboration.completed",
|
"collaboration.completed",
|
||||||
@@ -2679,23 +2994,99 @@ async def master_node(state: AgentState) -> AgentState:
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
state["current_agent"] = _normalize_current_agent(state.get("current_agent"))
|
state["current_agent"] = _normalize_current_agent(state.get("current_agent"))
|
||||||
|
parallel_worthiness = state.get("parallel_worthiness")
|
||||||
|
if not isinstance(parallel_worthiness, dict):
|
||||||
|
runtime_request_context = state.get("runtime_request_context") or {}
|
||||||
|
if isinstance(runtime_request_context, dict):
|
||||||
|
candidate_parallel = runtime_request_context.get("parallel_worthiness")
|
||||||
|
if isinstance(candidate_parallel, dict):
|
||||||
|
parallel_worthiness = candidate_parallel
|
||||||
|
if isinstance(parallel_worthiness, dict) and parallel_worthiness:
|
||||||
|
_append_event_trace(
|
||||||
|
state,
|
||||||
|
"agent.parallel.assessed",
|
||||||
|
payload=parallel_worthiness,
|
||||||
|
)
|
||||||
|
skill_shortlist = list(state.get("skill_shortlist") or [])
|
||||||
|
if skill_shortlist:
|
||||||
|
_append_event_trace(
|
||||||
|
state,
|
||||||
|
"agent.skill.shortlisted",
|
||||||
|
payload={
|
||||||
|
"count": len(skill_shortlist),
|
||||||
|
"skills": [str(item.get("skill_name") or "") for item in skill_shortlist[:4]],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
task_graph = state.get("task_graph")
|
||||||
|
if isinstance(task_graph, dict) and task_graph.get("nodes"):
|
||||||
|
_append_event_trace(
|
||||||
|
state,
|
||||||
|
"agent.task_graph.built",
|
||||||
|
payload={
|
||||||
|
"graph_id": task_graph.get("graph_id"),
|
||||||
|
"node_count": len(task_graph.get("nodes") or []),
|
||||||
|
"entry_node_ids": task_graph.get("entry_node_ids") or [],
|
||||||
|
"max_parallelism": task_graph.get("max_parallelism"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
isinstance(parallel_worthiness, dict)
|
||||||
|
and parallel_worthiness.get("preferred_mode") in {"collaboration", "parallel"}
|
||||||
|
and not (state.get("feature_flags") or {}).get("ENABLE_PARALLEL_TASK_GRAPH", True)
|
||||||
|
):
|
||||||
|
_append_event_trace(
|
||||||
|
state,
|
||||||
|
"agent.rollback.triggered",
|
||||||
|
payload={
|
||||||
|
"layer": "parallel_task_graph",
|
||||||
|
"reason": "feature_flag_disabled",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
structured_continuity_route = _route_from_structured_continuity(state, user_query)
|
structured_continuity_route = _route_from_structured_continuity(state, user_query)
|
||||||
clarification_route = _route_from_clarification_context(state, user_query)
|
clarification_route = _route_from_clarification_context(state, user_query)
|
||||||
if structured_continuity_route is not None:
|
if structured_continuity_route is not None:
|
||||||
state["execution_mode"] = "direct"
|
state["execution_mode"] = "direct"
|
||||||
routed_agent = structured_continuity_route
|
routed_agent = structured_continuity_route
|
||||||
|
_record_execution_decision(
|
||||||
|
state,
|
||||||
|
user_query=user_query,
|
||||||
|
mode="direct",
|
||||||
|
reason="continue_pending_action",
|
||||||
|
selected_roles=[routed_agent.value],
|
||||||
|
)
|
||||||
elif clarification_route is not None:
|
elif clarification_route is not None:
|
||||||
state["execution_mode"] = "direct"
|
state["execution_mode"] = "direct"
|
||||||
routed_agent = clarification_route
|
routed_agent = clarification_route
|
||||||
|
_record_execution_decision(
|
||||||
|
state,
|
||||||
|
user_query=user_query,
|
||||||
|
mode="direct",
|
||||||
|
reason="clarification_follow_up",
|
||||||
|
selected_roles=[routed_agent.value],
|
||||||
|
)
|
||||||
elif _is_short_confirmation(user_query) and _previous_turn_proposed_schedule_creation(
|
elif _is_short_confirmation(user_query) and _previous_turn_proposed_schedule_creation(
|
||||||
state.get("messages", [])
|
state.get("messages", [])
|
||||||
):
|
):
|
||||||
state["execution_mode"] = "direct"
|
state["execution_mode"] = "direct"
|
||||||
routed_agent = AgentRole.SCHEDULE_PLANNER
|
routed_agent = AgentRole.SCHEDULE_PLANNER
|
||||||
|
_record_execution_decision(
|
||||||
|
state,
|
||||||
|
user_query=user_query,
|
||||||
|
mode="direct",
|
||||||
|
reason="schedule_confirmation_follow_up",
|
||||||
|
selected_roles=[routed_agent.value],
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
request_mode, routing_metadata = _select_request_mode(user_query)
|
request_mode, routing_metadata = _select_request_mode(user_query)
|
||||||
state["routing_decision"] = routing_metadata
|
state["routing_decision"] = routing_metadata
|
||||||
|
_record_execution_decision(
|
||||||
|
state,
|
||||||
|
user_query=user_query,
|
||||||
|
mode=request_mode,
|
||||||
|
reason=str(routing_metadata.get("reason") or request_mode),
|
||||||
|
selected_roles=list(routing_metadata.get("roles") or []),
|
||||||
|
)
|
||||||
if request_mode == "collaboration":
|
if request_mode == "collaboration":
|
||||||
collaboration_state = await _run_collaboration_flow(state, user_query)
|
collaboration_state = await _run_collaboration_flow(state, user_query)
|
||||||
if collaboration_state.get(
|
if collaboration_state.get(
|
||||||
|
|||||||
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
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
"""高级编排系统 - Phase 10"""
|
"""高级编排系统 - Phase 10"""
|
||||||
|
|
||||||
|
from app.agents.orchestration.budget import build_subtask_budget
|
||||||
|
from app.agents.orchestration.result_merge import merge_task_results
|
||||||
|
from app.agents.orchestration.scheduler import (
|
||||||
|
ParallelExecutionScheduler,
|
||||||
|
build_subtask_specs,
|
||||||
|
ensure_child_links,
|
||||||
|
)
|
||||||
|
from app.agents.orchestration.subagent_runtime import subtask_spec_to_agent_task
|
||||||
from app.agents.team.leader import TeamLeader, TeamTask, TaskStatus
|
from app.agents.team.leader import TeamLeader, TeamTask, TaskStatus
|
||||||
from app.agents.transport.remote import RemoteTransport, StructuredMessage
|
from app.agents.transport.remote import RemoteTransport, StructuredMessage
|
||||||
|
from app.agents.orchestration.task_graph import build_bounded_task_graph, render_task_graph_summary
|
||||||
from app.agents.background.manager import (
|
from app.agents.background.manager import (
|
||||||
BackgroundTaskManager,
|
BackgroundTaskManager,
|
||||||
BackgroundTask,
|
BackgroundTask,
|
||||||
@@ -14,7 +23,15 @@ __all__ = [
|
|||||||
"TaskStatus",
|
"TaskStatus",
|
||||||
"RemoteTransport",
|
"RemoteTransport",
|
||||||
"StructuredMessage",
|
"StructuredMessage",
|
||||||
|
"ParallelExecutionScheduler",
|
||||||
|
"build_bounded_task_graph",
|
||||||
|
"build_subtask_budget",
|
||||||
|
"build_subtask_specs",
|
||||||
"BackgroundTaskManager",
|
"BackgroundTaskManager",
|
||||||
"BackgroundTask",
|
"BackgroundTask",
|
||||||
|
"ensure_child_links",
|
||||||
"get_background_task_manager",
|
"get_background_task_manager",
|
||||||
|
"merge_task_results",
|
||||||
|
"render_task_graph_summary",
|
||||||
|
"subtask_spec_to_agent_task",
|
||||||
]
|
]
|
||||||
|
|||||||
24
backend/app/agents/orchestration/budget.py
Normal file
24
backend/app/agents/orchestration/budget.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.schemas.task import CollaborationBudget
|
||||||
|
|
||||||
|
|
||||||
|
def build_subtask_budget(
|
||||||
|
*,
|
||||||
|
execution_mode: str,
|
||||||
|
max_parallel_tasks: int,
|
||||||
|
max_tool_calls: int = 2,
|
||||||
|
max_iterations: int = 2,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> CollaborationBudget:
|
||||||
|
return CollaborationBudget(
|
||||||
|
mode="collaboration" if execution_mode != "direct" else "direct",
|
||||||
|
max_parallel_tasks=max_parallel_tasks,
|
||||||
|
remaining_parallel_tasks=max_parallel_tasks,
|
||||||
|
max_tool_calls=max_tool_calls,
|
||||||
|
remaining_tool_calls=max_tool_calls,
|
||||||
|
max_iterations=max_iterations,
|
||||||
|
remaining_iterations=max_iterations,
|
||||||
|
escalation_threshold=1,
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
31
backend/app/agents/orchestration/monitor.py
Normal file
31
backend/app/agents/orchestration/monitor.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def build_parallel_runtime_metrics(
|
||||||
|
*,
|
||||||
|
task_graph: dict[str, Any] | None,
|
||||||
|
scheduled_subtasks: list[dict[str, Any]] | None,
|
||||||
|
task_results: list[dict[str, Any]] | None,
|
||||||
|
merge_report: dict[str, Any] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
task_graph = task_graph or {}
|
||||||
|
scheduled_subtasks = list(scheduled_subtasks or [])
|
||||||
|
task_results = list(task_results or [])
|
||||||
|
merge_report = merge_report or {}
|
||||||
|
|
||||||
|
completed = sum(1 for item in task_results if item.get("status") == "completed")
|
||||||
|
failed = sum(1 for item in task_results if item.get("status") == "failed")
|
||||||
|
blocked = sum(1 for item in task_results if item.get("status") == "blocked")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"task_graph_node_count": len(task_graph.get("nodes") or []),
|
||||||
|
"scheduled_subtask_count": len(scheduled_subtasks),
|
||||||
|
"completed_subtask_count": completed,
|
||||||
|
"failed_subtask_count": failed,
|
||||||
|
"blocked_subtask_count": blocked,
|
||||||
|
"merge_status": merge_report.get("status"),
|
||||||
|
"merge_conflict_count": len(merge_report.get("conflict_flags") or []),
|
||||||
|
"fallback_used": bool(merge_report.get("fallback_used") or False),
|
||||||
|
}
|
||||||
69
backend/app/agents/orchestration/result_merge.py
Normal file
69
backend/app/agents/orchestration/result_merge.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.schemas.orchestration import MergeReport
|
||||||
|
from app.agents.verifier import normalize_task_result
|
||||||
|
|
||||||
|
|
||||||
|
def merge_task_results(task_results: list[dict] | list[object]) -> MergeReport:
|
||||||
|
normalized = [normalize_task_result(item) for item in (task_results or [])]
|
||||||
|
completed = [item for item in normalized if item.status == "completed"]
|
||||||
|
failed_or_blocked = [item for item in normalized if item.status in {"failed", "blocked"}]
|
||||||
|
|
||||||
|
evidence_union: list[dict] = []
|
||||||
|
summaries = []
|
||||||
|
for item in normalized:
|
||||||
|
evidence_union.extend(list(item.evidence or []))
|
||||||
|
if item.summary:
|
||||||
|
summaries.append(item.summary.strip())
|
||||||
|
|
||||||
|
unique_summaries = list(dict.fromkeys(summary for summary in summaries if summary))
|
||||||
|
conflict_flags: list[str] = []
|
||||||
|
status = "merged"
|
||||||
|
fallback_used = False
|
||||||
|
|
||||||
|
if failed_or_blocked:
|
||||||
|
status = "fallback"
|
||||||
|
fallback_used = True
|
||||||
|
conflict_flags.append(
|
||||||
|
"failed_or_blocked_tasks:" + ",".join(item.task_id for item in failed_or_blocked)
|
||||||
|
)
|
||||||
|
resolution_strategy = "serial_recovery"
|
||||||
|
resolved_summary = (
|
||||||
|
completed[-1].summary
|
||||||
|
if completed and completed[-1].summary
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
elif len(unique_summaries) > 1 and len(completed) > 1:
|
||||||
|
status = "conflicted"
|
||||||
|
conflict_flags.append("multiple_distinct_completed_summaries")
|
||||||
|
resolution_strategy = "rank_by_evidence_count"
|
||||||
|
ranked = sorted(
|
||||||
|
completed,
|
||||||
|
key=lambda item: (len(item.evidence or []), bool(item.summary)),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
resolved_summary = ranked[0].summary if ranked and ranked[0].summary else None
|
||||||
|
else:
|
||||||
|
resolution_strategy = "evidence_union"
|
||||||
|
resolved_summary = unique_summaries[-1] if unique_summaries else None
|
||||||
|
|
||||||
|
if status == "merged":
|
||||||
|
summary = (
|
||||||
|
unique_summaries[-1]
|
||||||
|
if unique_summaries
|
||||||
|
else f"已收敛 {len(normalized)} 个子任务结果。"
|
||||||
|
)
|
||||||
|
elif status == "conflicted":
|
||||||
|
summary = "并行子任务摘要存在冲突,需要 verifier 或串行收敛。"
|
||||||
|
else:
|
||||||
|
summary = "存在失败或阻塞子任务,需要回退到更保守的收敛路径。"
|
||||||
|
|
||||||
|
return MergeReport(
|
||||||
|
status=status,
|
||||||
|
summary=summary,
|
||||||
|
evidence_union=evidence_union,
|
||||||
|
conflict_flags=conflict_flags,
|
||||||
|
resolution_strategy=resolution_strategy,
|
||||||
|
resolved_summary=resolved_summary,
|
||||||
|
fallback_used=fallback_used,
|
||||||
|
)
|
||||||
93
backend/app/agents/orchestration/scheduler.py
Normal file
93
backend/app/agents/orchestration/scheduler.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.agents.orchestration.budget import build_subtask_budget
|
||||||
|
from app.agents.schemas.orchestration import SubTaskSpec, TaskGraph, TaskNode
|
||||||
|
|
||||||
|
|
||||||
|
class ParallelExecutionScheduler:
|
||||||
|
def plan(self, task_graph: TaskGraph, *, query_text: str) -> list[SubTaskSpec]:
|
||||||
|
ordered_nodes = _topological_nodes(task_graph)
|
||||||
|
specs: list[SubTaskSpec] = []
|
||||||
|
for node in ordered_nodes:
|
||||||
|
budget = build_subtask_budget(
|
||||||
|
execution_mode=node.execution_mode,
|
||||||
|
max_parallel_tasks=max(1, task_graph.max_parallelism),
|
||||||
|
metadata={
|
||||||
|
"task_graph_id": task_graph.graph_id,
|
||||||
|
"depends_on": node.depends_on,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
specs.append(
|
||||||
|
SubTaskSpec(
|
||||||
|
subtask_id=node.node_id,
|
||||||
|
parent_run_id=task_graph.graph_id,
|
||||||
|
title=node.title,
|
||||||
|
role=node.role or "master",
|
||||||
|
goal=node.goal or query_text,
|
||||||
|
context_slice=_build_context_slice(node, query_text),
|
||||||
|
allowed_tools=[],
|
||||||
|
budget_tokens=1200,
|
||||||
|
budget_tool_calls=budget.max_tool_calls or 2,
|
||||||
|
expected_output_schema={
|
||||||
|
"summary": "string",
|
||||||
|
"evidence": "list",
|
||||||
|
"status": "completed|failed|blocked",
|
||||||
|
},
|
||||||
|
expected_evidence=node.expected_evidence,
|
||||||
|
dependencies=node.depends_on,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return specs
|
||||||
|
|
||||||
|
|
||||||
|
def build_subtask_specs(task_graph: TaskGraph, *, query_text: str) -> list[SubTaskSpec]:
|
||||||
|
return ParallelExecutionScheduler().plan(task_graph, query_text=query_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_context_slice(node: TaskNode, query_text: str) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"query": query_text,
|
||||||
|
"role": node.role,
|
||||||
|
"title": node.title,
|
||||||
|
"goal": node.goal,
|
||||||
|
"depends_on": node.depends_on,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _topological_nodes(task_graph: TaskGraph) -> list[TaskNode]:
|
||||||
|
by_id = {node.node_id: node for node in task_graph.nodes}
|
||||||
|
indegree = {node.node_id: 0 for node in task_graph.nodes}
|
||||||
|
edges: dict[str, list[str]] = defaultdict(list)
|
||||||
|
|
||||||
|
for node in task_graph.nodes:
|
||||||
|
for dep in node.depends_on:
|
||||||
|
if dep not in by_id:
|
||||||
|
continue
|
||||||
|
edges[dep].append(node.node_id)
|
||||||
|
indegree[node.node_id] += 1
|
||||||
|
|
||||||
|
ready = deque(node_id for node_id, count in indegree.items() if count == 0)
|
||||||
|
ordered: list[TaskNode] = []
|
||||||
|
|
||||||
|
while ready:
|
||||||
|
node_id = ready.popleft()
|
||||||
|
ordered.append(by_id[node_id])
|
||||||
|
for target in edges.get(node_id, []):
|
||||||
|
indegree[target] -= 1
|
||||||
|
if indegree[target] == 0:
|
||||||
|
ready.append(target)
|
||||||
|
|
||||||
|
if len(ordered) != len(task_graph.nodes):
|
||||||
|
return list(task_graph.nodes)
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_child_links(specs: list[SubTaskSpec]) -> dict[str, list[str]]:
|
||||||
|
graph: dict[str, list[str]] = defaultdict(list)
|
||||||
|
for spec in specs:
|
||||||
|
for dep in spec.dependencies:
|
||||||
|
graph[dep].append(spec.subtask_id)
|
||||||
|
return dict(graph)
|
||||||
17
backend/app/agents/orchestration/subagent_runtime.py
Normal file
17
backend/app/agents/orchestration/subagent_runtime.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.schemas.orchestration import SubTaskSpec
|
||||||
|
from app.agents.schemas.task import AgentTask
|
||||||
|
|
||||||
|
|
||||||
|
def subtask_spec_to_agent_task(spec: SubTaskSpec) -> AgentTask:
|
||||||
|
return AgentTask(
|
||||||
|
task_id=spec.subtask_id,
|
||||||
|
title=spec.title,
|
||||||
|
owner_agent_id=spec.role,
|
||||||
|
role=spec.role,
|
||||||
|
goal=spec.goal,
|
||||||
|
parent_task_id=spec.parent_run_id,
|
||||||
|
child_task_ids=[],
|
||||||
|
expected_evidence=spec.expected_evidence,
|
||||||
|
)
|
||||||
128
backend/app/agents/orchestration/task_graph.py
Normal file
128
backend/app/agents/orchestration/task_graph.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.agents.schemas.orchestration import ParallelWorthiness, TaskGraph, TaskNode
|
||||||
|
|
||||||
|
|
||||||
|
ROLE_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [
|
||||||
|
("librarian", ("查", "检索", "资料", "文档", "知识库", "年报", "forum", "search")),
|
||||||
|
("analyst", ("分析", "判断", "风险", "总结", "对比", "洞察", "结论")),
|
||||||
|
("schedule_planner", ("计划", "安排", "下周", "日程", "提醒", "优先级")),
|
||||||
|
("executor", ("执行", "创建", "更新", "落库", "提交", "发帖")),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_bounded_task_graph(
|
||||||
|
*,
|
||||||
|
query_text: str,
|
||||||
|
parallel_worthiness: ParallelWorthiness,
|
||||||
|
max_nodes: int = 4,
|
||||||
|
) -> TaskGraph | None:
|
||||||
|
roles = _infer_roles(query_text)
|
||||||
|
if not roles:
|
||||||
|
return None
|
||||||
|
|
||||||
|
independent_roles = roles[: min(max_nodes - 1, max(1, parallel_worthiness.estimated_subtasks))]
|
||||||
|
nodes: list[TaskNode] = []
|
||||||
|
|
||||||
|
for index, role in enumerate(independent_roles, start=1):
|
||||||
|
node_id = f"task-{index}-{uuid4().hex[:6]}"
|
||||||
|
nodes.append(
|
||||||
|
TaskNode(
|
||||||
|
node_id=node_id,
|
||||||
|
title=_build_title(role),
|
||||||
|
role=role,
|
||||||
|
goal=_build_goal(role, query_text),
|
||||||
|
depends_on=[],
|
||||||
|
execution_mode=(
|
||||||
|
"parallel"
|
||||||
|
if parallel_worthiness.preferred_mode in {"collaboration", "parallel"}
|
||||||
|
and len(independent_roles) > 1
|
||||||
|
else "serial"
|
||||||
|
),
|
||||||
|
expected_evidence=_build_expected_evidence(role),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(nodes) > 1:
|
||||||
|
merge_id = f"merge-{uuid4().hex[:6]}"
|
||||||
|
nodes.append(
|
||||||
|
TaskNode(
|
||||||
|
node_id=merge_id,
|
||||||
|
title="汇总并收敛最终结论",
|
||||||
|
role="master",
|
||||||
|
goal="汇总前置子任务结果,形成统一可验证的输出。",
|
||||||
|
depends_on=[node.node_id for node in nodes],
|
||||||
|
execution_mode="serial",
|
||||||
|
expected_evidence=[{"type": "merge", "detail": "merged summary and conflict notes"}],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return TaskGraph(
|
||||||
|
nodes=nodes,
|
||||||
|
entry_node_ids=[node.node_id for node in nodes if not node.depends_on],
|
||||||
|
max_parallelism=max(1, len(independent_roles)),
|
||||||
|
rationale=_build_rationale(parallel_worthiness, independent_roles),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_task_graph_summary(task_graph: TaskGraph | None) -> str | None:
|
||||||
|
if task_graph is None or not task_graph.nodes:
|
||||||
|
return None
|
||||||
|
|
||||||
|
lines = ["- 任务图:"]
|
||||||
|
for node in task_graph.nodes[:4]:
|
||||||
|
deps = f" deps={','.join(node.depends_on)}" if node.depends_on else ""
|
||||||
|
lines.append(f" - [{node.execution_mode}] {node.title} ({node.role}){deps}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_roles(query_text: str) -> list[str]:
|
||||||
|
selected: list[str] = []
|
||||||
|
text = query_text or ""
|
||||||
|
for role, keywords in ROLE_KEYWORDS:
|
||||||
|
if any(keyword in text for keyword in keywords):
|
||||||
|
selected.append(role)
|
||||||
|
|
||||||
|
if not selected:
|
||||||
|
return ["analyst"]
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def _build_title(role: str) -> str:
|
||||||
|
mapping = {
|
||||||
|
"librarian": "收集事实与外部/内部证据",
|
||||||
|
"analyst": "形成判断与风险分析",
|
||||||
|
"schedule_planner": "整理计划和优先级",
|
||||||
|
"executor": "执行必要操作并回收结果",
|
||||||
|
}
|
||||||
|
return mapping.get(role, "处理子任务")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_goal(role: str, query_text: str) -> str:
|
||||||
|
mapping = {
|
||||||
|
"librarian": f"围绕请求收集支持结论的事实和资料:{query_text}",
|
||||||
|
"analyst": f"基于当前请求输出结构化判断:{query_text}",
|
||||||
|
"schedule_planner": f"把当前请求收束为计划、安排或优先级:{query_text}",
|
||||||
|
"executor": f"基于请求执行必要动作并返回结果:{query_text}",
|
||||||
|
}
|
||||||
|
return mapping.get(role, query_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_expected_evidence(role: str) -> list[dict[str, str]]:
|
||||||
|
mapping = {
|
||||||
|
"librarian": [{"type": "evidence", "detail": "retrieval findings"}],
|
||||||
|
"analyst": [{"type": "analysis", "detail": "structured judgment"}],
|
||||||
|
"schedule_planner": [{"type": "plan", "detail": "explicit schedule or priorities"}],
|
||||||
|
"executor": [{"type": "execution", "detail": "tool output or mutation result"}],
|
||||||
|
}
|
||||||
|
return mapping.get(role, [{"type": "summary", "detail": "task summary"}])
|
||||||
|
|
||||||
|
|
||||||
|
def _build_rationale(parallel_worthiness: ParallelWorthiness, roles: list[str]) -> str:
|
||||||
|
return (
|
||||||
|
f"preferred_mode={parallel_worthiness.preferred_mode}; "
|
||||||
|
f"score={parallel_worthiness.score:.2f}; "
|
||||||
|
f"roles={','.join(roles)}"
|
||||||
|
)
|
||||||
@@ -1,5 +1,24 @@
|
|||||||
from app.agents.schemas.event import AgentEvent
|
from app.agents.schemas.event import AgentEvent
|
||||||
|
from app.agents.schemas.learning import (
|
||||||
|
LearningDecision,
|
||||||
|
LearningSignal,
|
||||||
|
PatternCandidate,
|
||||||
|
SessionRetrospective,
|
||||||
|
SkillCandidate,
|
||||||
|
)
|
||||||
from app.agents.schemas.message import AgentMessage
|
from app.agents.schemas.message import AgentMessage
|
||||||
|
from app.agents.schemas.orchestration import (
|
||||||
|
ExecutionDecision,
|
||||||
|
MergeReport,
|
||||||
|
ParallelWorthiness,
|
||||||
|
RuntimeRequestContext,
|
||||||
|
SubTaskResult,
|
||||||
|
SubTaskSpec,
|
||||||
|
TaskGraph,
|
||||||
|
TaskNode,
|
||||||
|
VerificationReport,
|
||||||
|
)
|
||||||
|
from app.agents.schemas.skills import SkillActivationRecord, SkillShortlistEntry
|
||||||
from app.agents.schemas.task import (
|
from app.agents.schemas.task import (
|
||||||
AgentTask,
|
AgentTask,
|
||||||
CollaborationBudget,
|
CollaborationBudget,
|
||||||
@@ -14,12 +33,28 @@ from app.agents.schemas.task import (
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"AgentEvent",
|
"AgentEvent",
|
||||||
"AgentMessage",
|
"AgentMessage",
|
||||||
|
"ExecutionDecision",
|
||||||
"AgentTask",
|
"AgentTask",
|
||||||
"CollaborationBudget",
|
"CollaborationBudget",
|
||||||
"InterruptRecord",
|
"InterruptRecord",
|
||||||
|
"LearningDecision",
|
||||||
|
"LearningSignal",
|
||||||
|
"MergeReport",
|
||||||
|
"ParallelWorthiness",
|
||||||
|
"PatternCandidate",
|
||||||
"RecoveryRecord",
|
"RecoveryRecord",
|
||||||
|
"RuntimeRequestContext",
|
||||||
|
"SessionRetrospective",
|
||||||
|
"SkillActivationRecord",
|
||||||
|
"SkillCandidate",
|
||||||
|
"SkillShortlistEntry",
|
||||||
|
"SubTaskResult",
|
||||||
|
"SubTaskSpec",
|
||||||
|
"TaskGraph",
|
||||||
|
"TaskNode",
|
||||||
"TaskLifecycleStatus",
|
"TaskLifecycleStatus",
|
||||||
"TaskResult",
|
"TaskResult",
|
||||||
"TaskResultStatus",
|
"TaskResultStatus",
|
||||||
|
"VerificationReport",
|
||||||
"VerificationStatus",
|
"VerificationStatus",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,10 +7,21 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
|
|
||||||
AgentEventType = Literal[
|
AgentEventType = Literal[
|
||||||
|
"agent.execution.decided",
|
||||||
|
"agent.parallel.assessed",
|
||||||
|
"agent.skill.shortlisted",
|
||||||
|
"agent.task_graph.built",
|
||||||
|
"agent.subtask.started",
|
||||||
|
"agent.subtask.completed",
|
||||||
|
"agent.merge.completed",
|
||||||
"agent.tool.start",
|
"agent.tool.start",
|
||||||
"agent.tool.result",
|
"agent.tool.result",
|
||||||
"agent.verify.started",
|
"agent.verify.started",
|
||||||
"agent.verify.completed",
|
"agent.verify.completed",
|
||||||
|
"agent.retrospective.created",
|
||||||
|
"agent.learning.decision",
|
||||||
|
"agent.skill.lifecycle.changed",
|
||||||
|
"agent.rollback.triggered",
|
||||||
"agent.created",
|
"agent.created",
|
||||||
"agent.spawn.blocked",
|
"agent.spawn.blocked",
|
||||||
"agent.message.sent",
|
"agent.message.sent",
|
||||||
|
|||||||
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)
|
||||||
211
backend/app/agents/schemas/orchestration.py
Normal file
211
backend/app/agents/schemas/orchestration.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Literal
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.agents.schemas.skills import SkillShortlistEntry
|
||||||
|
|
||||||
|
|
||||||
|
ExecutionMode = Literal["direct", "collaboration", "parallel", "delegated"]
|
||||||
|
ParallelPreference = Literal["direct", "collaboration", "parallel"]
|
||||||
|
|
||||||
|
|
||||||
|
class ParallelWorthiness(BaseModel):
|
||||||
|
should_parallelize: bool = False
|
||||||
|
score: float = 0.0
|
||||||
|
estimated_subtasks: int = 1
|
||||||
|
preferred_mode: ParallelPreference = "direct"
|
||||||
|
reasons: list[str] = Field(default_factory=list)
|
||||||
|
risk_flags: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskNode(BaseModel):
|
||||||
|
node_id: str
|
||||||
|
title: str
|
||||||
|
role: str | None = None
|
||||||
|
goal: str | None = None
|
||||||
|
depends_on: list[str] = Field(default_factory=list)
|
||||||
|
execution_mode: Literal["serial", "parallel"] = "serial"
|
||||||
|
expected_evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskGraph(BaseModel):
|
||||||
|
graph_id: str = Field(default_factory=lambda: str(uuid4()))
|
||||||
|
nodes: list[TaskNode] = Field(default_factory=list)
|
||||||
|
entry_node_ids: list[str] = Field(default_factory=list)
|
||||||
|
max_parallelism: int = 1
|
||||||
|
rationale: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SubTaskSpec(BaseModel):
|
||||||
|
subtask_id: str
|
||||||
|
parent_run_id: str
|
||||||
|
title: str
|
||||||
|
role: str
|
||||||
|
goal: str
|
||||||
|
context_slice: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
allowed_tools: list[str] = Field(default_factory=list)
|
||||||
|
budget_tokens: int = 1200
|
||||||
|
budget_tool_calls: int = 2
|
||||||
|
expected_output_schema: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
expected_evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
dependencies: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SubTaskResult(BaseModel):
|
||||||
|
subtask_id: str
|
||||||
|
status: Literal["completed", "failed", "blocked"]
|
||||||
|
summary: str | None = None
|
||||||
|
evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
output: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class MergeReport(BaseModel):
|
||||||
|
merge_id: str = Field(default_factory=lambda: str(uuid4()))
|
||||||
|
status: Literal["merged", "conflicted", "fallback"]
|
||||||
|
summary: str | None = None
|
||||||
|
evidence_union: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
conflict_flags: list[str] = Field(default_factory=list)
|
||||||
|
resolution_strategy: str | None = None
|
||||||
|
resolved_summary: str | None = None
|
||||||
|
fallback_used: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class VerificationReport(BaseModel):
|
||||||
|
status: Literal["passed", "failed", "skipped"]
|
||||||
|
summary: str | None = None
|
||||||
|
evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionDecision(BaseModel):
|
||||||
|
request_id: str = Field(default_factory=lambda: str(uuid4()))
|
||||||
|
mode: ExecutionMode = "direct"
|
||||||
|
reason: str
|
||||||
|
complexity_score: float = 0.0
|
||||||
|
parallel_worthiness_score: float | None = None
|
||||||
|
selected_roles: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeRequestContext(BaseModel):
|
||||||
|
request_id: str = Field(default_factory=lambda: str(uuid4()))
|
||||||
|
session_id: str | None = None
|
||||||
|
user_id: str
|
||||||
|
conversation_id: str | None = None
|
||||||
|
query_text: str | None = None
|
||||||
|
raw_user_query: str | None = None
|
||||||
|
recalled_memories: list[str] = Field(default_factory=list)
|
||||||
|
retrospective_shortlist: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
recalled_retrospectives: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
skill_shortlist: list[SkillShortlistEntry] = Field(default_factory=list)
|
||||||
|
shortlisted_skills: list[str] = Field(default_factory=list)
|
||||||
|
parallel_worthiness: ParallelWorthiness = Field(default_factory=ParallelWorthiness)
|
||||||
|
task_graph: TaskGraph | None = None
|
||||||
|
recommended_runtime_mode: Literal["direct", "collaboration"] = "direct"
|
||||||
|
execution_mode: Literal["direct", "collaboration"] | None = None
|
||||||
|
current_agent_role: str | None = None
|
||||||
|
conversation_state_ref: str | None = None
|
||||||
|
assembly_metrics: dict[str, float] = Field(default_factory=dict)
|
||||||
|
assembled_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
def assess_parallel_worthiness(
|
||||||
|
query_text: str,
|
||||||
|
*,
|
||||||
|
retrospective_count: int = 0,
|
||||||
|
skill_count: int = 0,
|
||||||
|
) -> ParallelWorthiness:
|
||||||
|
normalized = (query_text or "").strip().lower()
|
||||||
|
reasons: list[str] = []
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
multi_step_markers = ("然后", "接着", "同时", "并且", "最后", "汇总", "对比", "分析", "research")
|
||||||
|
artifact_markers = ("文档", "代码", "文件", "数据库", "论坛", "知识库", "计划")
|
||||||
|
|
||||||
|
if any(marker in normalized for marker in multi_step_markers):
|
||||||
|
score += 0.35
|
||||||
|
reasons.append("multi_step_request")
|
||||||
|
|
||||||
|
if sum(1 for marker in artifact_markers if marker in normalized) >= 2:
|
||||||
|
score += 0.25
|
||||||
|
reasons.append("multi_source_context")
|
||||||
|
|
||||||
|
if len(re.findall(r"[,,、;;]", query_text or "")) >= 2:
|
||||||
|
score += 0.15
|
||||||
|
reasons.append("compound_instruction")
|
||||||
|
|
||||||
|
if retrospective_count > 0:
|
||||||
|
score += 0.1
|
||||||
|
reasons.append("historical_support")
|
||||||
|
|
||||||
|
if skill_count > 0:
|
||||||
|
score += 0.1
|
||||||
|
reasons.append("skill_candidates_available")
|
||||||
|
|
||||||
|
score = min(score, 1.0)
|
||||||
|
should_parallelize = score >= 0.55
|
||||||
|
preferred_mode: ParallelPreference = "parallel" if should_parallelize else "direct"
|
||||||
|
if not should_parallelize and score >= 0.3:
|
||||||
|
preferred_mode = "collaboration"
|
||||||
|
|
||||||
|
estimated_subtasks = 1
|
||||||
|
if preferred_mode == "parallel":
|
||||||
|
estimated_subtasks = 3 if score >= 0.8 else 2
|
||||||
|
elif preferred_mode == "collaboration":
|
||||||
|
estimated_subtasks = 2
|
||||||
|
|
||||||
|
return ParallelWorthiness(
|
||||||
|
should_parallelize=should_parallelize,
|
||||||
|
score=round(score, 3),
|
||||||
|
estimated_subtasks=estimated_subtasks,
|
||||||
|
preferred_mode=preferred_mode,
|
||||||
|
reasons=reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_runtime_request_context_summary(context: RuntimeRequestContext) -> str:
|
||||||
|
lines = ["【Runtime Request Context】"]
|
||||||
|
lines.append(f"- 推荐运行模式: {context.recommended_runtime_mode}")
|
||||||
|
lines.append(
|
||||||
|
f"- 并行潜力: score={context.parallel_worthiness.score:.2f}, "
|
||||||
|
f"preferred={context.parallel_worthiness.preferred_mode}, "
|
||||||
|
f"estimated_subtasks={context.parallel_worthiness.estimated_subtasks}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if context.parallel_worthiness.reasons:
|
||||||
|
lines.append(f"- 并行判断依据: {', '.join(context.parallel_worthiness.reasons)}")
|
||||||
|
if context.assembly_metrics:
|
||||||
|
total_ms = context.assembly_metrics.get("total_ms")
|
||||||
|
if total_ms is not None:
|
||||||
|
lines.append(f"- 上下文装配耗时: {total_ms:.1f} ms")
|
||||||
|
|
||||||
|
if context.task_graph and context.task_graph.nodes:
|
||||||
|
lines.append(
|
||||||
|
f"- 任务图: nodes={len(context.task_graph.nodes)}, max_parallelism={context.task_graph.max_parallelism}"
|
||||||
|
)
|
||||||
|
for node in context.task_graph.nodes[:4]:
|
||||||
|
deps = f", deps={len(node.depends_on)}" if node.depends_on else ""
|
||||||
|
lines.append(f" - [{node.execution_mode}] {node.title} ({node.role}{deps})")
|
||||||
|
|
||||||
|
if context.retrospective_shortlist:
|
||||||
|
lines.append("- 历史复盘命中:")
|
||||||
|
for item in context.retrospective_shortlist[:3]:
|
||||||
|
summary = (item.get("summary") or item.get("summary_text") or "").strip()
|
||||||
|
task_type = item.get("task_type") or "unknown"
|
||||||
|
lines.append(f" - [{task_type}] {summary[:160]}")
|
||||||
|
|
||||||
|
if context.skill_shortlist:
|
||||||
|
lines.append("- 技能候选:")
|
||||||
|
for item in context.skill_shortlist[:3]:
|
||||||
|
lines.append(
|
||||||
|
f" - {item.skill_name} ({item.injection_mode}, score={item.score:.2f})"
|
||||||
|
+ (f": {item.rationale}" if item.rationale else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
if context.recalled_memories:
|
||||||
|
lines.append("- 记忆上下文已装配,可在回答中按需引用。")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
38
backend/app/agents/schemas/skills.py
Normal file
38
backend/app/agents/schemas/skills.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
SkillStatus = Literal["candidate", "shadow", "active", "deprecated", "retired"]
|
||||||
|
SkillInjectionMode = Literal["metadata_only", "summary", "full"]
|
||||||
|
|
||||||
|
|
||||||
|
class SkillShortlistEntry(BaseModel):
|
||||||
|
skill_name: str
|
||||||
|
source: str = "runtime"
|
||||||
|
source_id: str | None = None
|
||||||
|
status: SkillStatus = "active"
|
||||||
|
scope: list[str] = Field(default_factory=list)
|
||||||
|
effectiveness: float | None = None
|
||||||
|
score: float = 0.0
|
||||||
|
rationale: str | None = None
|
||||||
|
summary: str | None = None
|
||||||
|
matched_terms: list[str] = Field(default_factory=list)
|
||||||
|
injection_mode: SkillInjectionMode = "metadata_only"
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillActivationRecord(BaseModel):
|
||||||
|
skill_name: str
|
||||||
|
source: str = "runtime"
|
||||||
|
source_id: str | None = None
|
||||||
|
status: SkillStatus = "active"
|
||||||
|
injection_mode: SkillInjectionMode = "metadata_only"
|
||||||
|
matched_terms: list[str] = Field(default_factory=list)
|
||||||
|
rationale: str | None = None
|
||||||
|
activated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
outcome: str | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
@@ -1,16 +1 @@
|
|||||||
"""Skills 注册表 - Phase 9"""
|
"""Skill package."""
|
||||||
|
|
||||||
from app.agents.skills.registry import SkillRegistry, get_skill_registry
|
|
||||||
from app.agents.skills.metadata import SkillMetadata
|
|
||||||
from app.agents.skills.loaders.local_loader import LocalSkillLoader
|
|
||||||
from app.agents.skills.loaders.plugin_loader import PluginSkillLoader
|
|
||||||
from app.agents.skills.mcp_builder import MCPSkillBuilder
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"SkillRegistry",
|
|
||||||
"SkillMetadata",
|
|
||||||
"LocalSkillLoader",
|
|
||||||
"PluginSkillLoader",
|
|
||||||
"MCPSkillBuilder",
|
|
||||||
"get_skill_registry",
|
|
||||||
]
|
|
||||||
|
|||||||
14
backend/app/agents/skills/effectiveness.py
Normal file
14
backend/app/agents/skills/effectiveness.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.models.skill import Skill
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_skill_effectiveness(skill: Skill) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"name": skill.name,
|
||||||
|
"status": skill.status,
|
||||||
|
"effectiveness": skill.effectiveness,
|
||||||
|
"activation_count": skill.activation_count,
|
||||||
|
"candidate_count": getattr(skill, "candidate_count", 0),
|
||||||
|
"last_activated_at": skill.last_activated_at.isoformat() if skill.last_activated_at else None,
|
||||||
|
}
|
||||||
58
backend/app/agents/skills/evaluator.py
Normal file
58
backend/app/agents/skills/evaluator.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from app.agents.schemas.learning import SessionRetrospective, SkillCandidate
|
||||||
|
from app.agents.skills.models import SkillLifecycleDecision
|
||||||
|
from app.services.skill_service import SkillService
|
||||||
|
|
||||||
|
|
||||||
|
class SkillPromotionEvaluator:
|
||||||
|
def __init__(self, db):
|
||||||
|
self.db = db
|
||||||
|
self.skill_service = SkillService(db)
|
||||||
|
|
||||||
|
async def sync_retrospective(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
retrospective: SessionRetrospective,
|
||||||
|
) -> list[SkillLifecycleDecision]:
|
||||||
|
decisions: list[SkillLifecycleDecision] = []
|
||||||
|
|
||||||
|
for candidate in retrospective.skill_candidates:
|
||||||
|
decisions.append(
|
||||||
|
await self.skill_service.upsert_learned_candidate(
|
||||||
|
user_id=user_id,
|
||||||
|
candidate=candidate,
|
||||||
|
primary_agent=retrospective.primary_agent,
|
||||||
|
evidence_refs=candidate.evidence_refs,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
outcome_score = self._derive_outcome_score(retrospective)
|
||||||
|
for skill_name in retrospective.used_skill_names:
|
||||||
|
decision = await self.skill_service.record_activation_feedback(
|
||||||
|
user_id=user_id,
|
||||||
|
skill_name=skill_name,
|
||||||
|
outcome_score=outcome_score,
|
||||||
|
evidence_refs=retrospective.evidence_refs,
|
||||||
|
)
|
||||||
|
if decision is not None:
|
||||||
|
decisions.append(decision)
|
||||||
|
|
||||||
|
return decisions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _derive_outcome_score(retrospective: SessionRetrospective) -> float:
|
||||||
|
if retrospective.verification_status == "passed":
|
||||||
|
return 0.9
|
||||||
|
if retrospective.verification_status == "skipped":
|
||||||
|
return 0.55
|
||||||
|
if retrospective.verification_status == "failed":
|
||||||
|
return 0.15
|
||||||
|
return 0.7 if retrospective.outcome == "completed" else 0.2
|
||||||
|
|
||||||
|
|
||||||
|
def next_review_after(days: int = 7) -> datetime:
|
||||||
|
return datetime.now(UTC) + timedelta(days=days)
|
||||||
32
backend/app/agents/skills/matcher.py
Normal file
32
backend/app/agents/skills/matcher.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def extract_match_terms(text: str | None) -> list[str]:
|
||||||
|
source = (text or "").lower()
|
||||||
|
terms = [token for token in re.findall(r"[a-z0-9_]+", source) if len(token) >= 3]
|
||||||
|
|
||||||
|
for chunk in re.findall(r"[\u4e00-\u9fff]+", text or ""):
|
||||||
|
if len(chunk) >= 2:
|
||||||
|
terms.append(chunk)
|
||||||
|
if len(chunk) > 4:
|
||||||
|
for index in range(len(chunk) - 1):
|
||||||
|
terms.append(chunk[index : index + 2])
|
||||||
|
|
||||||
|
return list(dict.fromkeys(terms))
|
||||||
|
|
||||||
|
|
||||||
|
def score_text_match(query_text: str, *corpus_parts: str | None) -> tuple[float, list[str]]:
|
||||||
|
query_terms = extract_match_terms(query_text)
|
||||||
|
if not query_terms:
|
||||||
|
return 0.0, []
|
||||||
|
|
||||||
|
corpus = " ".join(part for part in corpus_parts if part).lower()
|
||||||
|
matched_terms = [term for term in query_terms if term and term in corpus]
|
||||||
|
if not matched_terms:
|
||||||
|
return 0.0, []
|
||||||
|
|
||||||
|
coverage = len(matched_terms) / max(len(query_terms), 1)
|
||||||
|
density = min(len(matched_terms), 4) / 4
|
||||||
|
return round(min(1.0, coverage * 0.7 + density * 0.3), 3), matched_terms
|
||||||
@@ -20,6 +20,10 @@ class SkillMetadata:
|
|||||||
source_id: str = "" # 来源 ID
|
source_id: str = "" # 来源 ID
|
||||||
enabled: bool = True # 是否启用
|
enabled: bool = True # 是否启用
|
||||||
tools: list[str] = field(default_factory=list) # 关联的工具
|
tools: list[str] = field(default_factory=list) # 关联的工具
|
||||||
|
status: str = "active" # candidate/shadow/active/deprecated/retired
|
||||||
|
scope: list[str] = field(default_factory=list)
|
||||||
|
effectiveness: float | None = None
|
||||||
|
review_after: str | None = None
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -35,6 +39,10 @@ class SkillMetadata:
|
|||||||
"source_id": self.source_id,
|
"source_id": self.source_id,
|
||||||
"enabled": self.enabled,
|
"enabled": self.enabled,
|
||||||
"tools": self.tools,
|
"tools": self.tools,
|
||||||
|
"status": self.status,
|
||||||
|
"scope": self.scope,
|
||||||
|
"effectiveness": self.effectiveness,
|
||||||
|
"review_after": self.review_after,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
29
backend/app/agents/skills/models.py
Normal file
29
backend/app/agents/skills/models.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
SkillLifecycleAction = Literal[
|
||||||
|
"created_candidate",
|
||||||
|
"promoted_to_shadow",
|
||||||
|
"promoted_to_active",
|
||||||
|
"degraded_to_deprecated",
|
||||||
|
"retired",
|
||||||
|
"reactivated",
|
||||||
|
"feedback_recorded",
|
||||||
|
"no_change",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SkillLifecycleDecision(BaseModel):
|
||||||
|
skill_name: str
|
||||||
|
action: SkillLifecycleAction
|
||||||
|
previous_status: str | None = None
|
||||||
|
new_status: str
|
||||||
|
reason: str
|
||||||
|
evidence_refs: list[dict[str, object]] = Field(default_factory=list)
|
||||||
|
confidence: float | None = None
|
||||||
|
review_after: datetime | None = None
|
||||||
27
backend/app/agents/skills/policy.py
Normal file
27
backend/app/agents/skills/policy.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.schemas.skills import SkillInjectionMode, SkillShortlistEntry
|
||||||
|
|
||||||
|
MAX_SUMMARY_CHARS = 120
|
||||||
|
|
||||||
|
|
||||||
|
def choose_injection_mode(score: float, summary_available: bool) -> SkillInjectionMode:
|
||||||
|
if score >= 0.75 and summary_available:
|
||||||
|
return "summary"
|
||||||
|
return "metadata_only"
|
||||||
|
|
||||||
|
|
||||||
|
def render_skill_shortlist_context(entries: list[SkillShortlistEntry]) -> str:
|
||||||
|
if not entries:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lines = ["[Task-Scoped Skills]"]
|
||||||
|
for entry in entries[:3]:
|
||||||
|
detail = entry.summary or "Relevant to the current request."
|
||||||
|
detail = detail[:MAX_SUMMARY_CHARS]
|
||||||
|
lines.append(f"- {entry.skill_name} | mode={entry.injection_mode} | score={entry.score:.2f}")
|
||||||
|
lines.append(f" {detail}")
|
||||||
|
if entry.matched_terms:
|
||||||
|
lines.append(f" matched_terms={', '.join(entry.matched_terms[:6])}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
153
backend/app/agents/skills/retriever.py
Normal file
153
backend/app/agents/skills/retriever.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
from app.agents.schemas.skills import SkillShortlistEntry
|
||||||
|
from app.agents.skills.matcher import score_text_match
|
||||||
|
from app.agents.skills.policy import choose_injection_mode, render_skill_shortlist_context
|
||||||
|
from app.agents.skills.registry import get_skill_registry
|
||||||
|
from app.services.skill_service import SkillService
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeSkillRetriever:
|
||||||
|
def __init__(self, db):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def shortlist(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
query_text: str,
|
||||||
|
memory_context: str | None = None,
|
||||||
|
retrospectives: list[dict] | None = None,
|
||||||
|
include_learned: bool = True,
|
||||||
|
limit: int = 3,
|
||||||
|
) -> list[SkillShortlistEntry]:
|
||||||
|
deduped: "OrderedDict[str, SkillShortlistEntry]" = OrderedDict()
|
||||||
|
retrospective_text = "\n".join(
|
||||||
|
(item.get("summary") or item.get("summary_text") or "")
|
||||||
|
for item in (retrospectives or [])
|
||||||
|
if isinstance(item, dict)
|
||||||
|
)
|
||||||
|
|
||||||
|
service = SkillService(self.db)
|
||||||
|
for skill in await service.list_runtime_candidates(user_id, include_learned=include_learned):
|
||||||
|
score, matched_terms = score_text_match(
|
||||||
|
query_text,
|
||||||
|
skill.name,
|
||||||
|
skill.description,
|
||||||
|
skill.instructions,
|
||||||
|
retrospective_text,
|
||||||
|
memory_context,
|
||||||
|
)
|
||||||
|
if score <= 0:
|
||||||
|
continue
|
||||||
|
entry = SkillShortlistEntry(
|
||||||
|
skill_name=skill.name,
|
||||||
|
source="database",
|
||||||
|
source_id=skill.id,
|
||||||
|
scope=[skill.agent_type, skill.visibility],
|
||||||
|
status=skill.status,
|
||||||
|
effectiveness=skill.effectiveness,
|
||||||
|
score=score,
|
||||||
|
matched_terms=matched_terms,
|
||||||
|
rationale=(
|
||||||
|
"Shadow skill matched current request; keep metadata-only injection."
|
||||||
|
if skill.status == "shadow"
|
||||||
|
else "Matched against DB skill metadata and instructions."
|
||||||
|
),
|
||||||
|
summary=skill.description or (skill.instructions[:160] if skill.instructions else None),
|
||||||
|
injection_mode=(
|
||||||
|
"metadata_only"
|
||||||
|
if skill.status == "shadow"
|
||||||
|
else choose_injection_mode(score, bool(skill.description or skill.instructions))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._upsert(deduped, entry)
|
||||||
|
|
||||||
|
registry = get_skill_registry()
|
||||||
|
if not registry.list_all():
|
||||||
|
try:
|
||||||
|
registry.load_all()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for skill in registry.list_all():
|
||||||
|
score, matched_terms = score_text_match(
|
||||||
|
query_text,
|
||||||
|
skill.name,
|
||||||
|
skill.description,
|
||||||
|
" ".join(skill.tags),
|
||||||
|
" ".join(skill.triggers),
|
||||||
|
skill.content[:400],
|
||||||
|
retrospective_text,
|
||||||
|
memory_context,
|
||||||
|
)
|
||||||
|
if score <= 0:
|
||||||
|
continue
|
||||||
|
entry = SkillShortlistEntry(
|
||||||
|
skill_name=skill.name,
|
||||||
|
source=skill.source,
|
||||||
|
source_id=skill.source_id or skill.id,
|
||||||
|
scope=skill.scope or list(skill.tags),
|
||||||
|
status=skill.status,
|
||||||
|
effectiveness=skill.effectiveness,
|
||||||
|
score=score,
|
||||||
|
matched_terms=matched_terms,
|
||||||
|
rationale="Matched against local or external skill metadata.",
|
||||||
|
summary=skill.description or skill.content[:160],
|
||||||
|
injection_mode=choose_injection_mode(
|
||||||
|
score,
|
||||||
|
bool(skill.description or skill.content),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._upsert(deduped, entry)
|
||||||
|
|
||||||
|
return sorted(deduped.values(), key=lambda item: item.score, reverse=True)[:limit]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _upsert(
|
||||||
|
deduped: "OrderedDict[str, SkillShortlistEntry]",
|
||||||
|
entry: SkillShortlistEntry,
|
||||||
|
) -> None:
|
||||||
|
existing = deduped.get(entry.skill_name)
|
||||||
|
if existing is None or existing.score < entry.score:
|
||||||
|
deduped[entry.skill_name] = entry
|
||||||
|
|
||||||
|
|
||||||
|
def build_shortlisted_skill_context(
|
||||||
|
shortlist: list[dict] | list[SkillShortlistEntry] | None,
|
||||||
|
*,
|
||||||
|
agent_type: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
if not shortlist:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
entries: list[SkillShortlistEntry] = []
|
||||||
|
for item in shortlist:
|
||||||
|
entry = item if isinstance(item, SkillShortlistEntry) else SkillShortlistEntry.model_validate(item)
|
||||||
|
if agent_type and entry.scope and agent_type not in entry.scope:
|
||||||
|
continue
|
||||||
|
entries.append(entry)
|
||||||
|
|
||||||
|
return render_skill_shortlist_context(entries)
|
||||||
|
|
||||||
|
|
||||||
|
async def shortlist_skills_for_request(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
user_query: str,
|
||||||
|
memory_context: str | None = None,
|
||||||
|
retrospectives: list[dict] | None = None,
|
||||||
|
include_learned: bool = True,
|
||||||
|
limit: int = 3,
|
||||||
|
) -> list[SkillShortlistEntry]:
|
||||||
|
return await RuntimeSkillRetriever(db).shortlist(
|
||||||
|
user_id=user_id,
|
||||||
|
query_text=user_query,
|
||||||
|
memory_context=memory_context,
|
||||||
|
retrospectives=retrospectives,
|
||||||
|
include_learned=include_learned,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
@@ -138,6 +138,18 @@ class AgentState(TypedDict):
|
|||||||
memory_context: str | None
|
memory_context: str | None
|
||||||
current_datetime_context: str | None
|
current_datetime_context: str | None
|
||||||
current_datetime_reference: dict[str, str] | None
|
current_datetime_reference: dict[str, str] | None
|
||||||
|
runtime_request_context: dict[str, Any] | None
|
||||||
|
task_graph: dict[str, Any] | None
|
||||||
|
scheduled_subtasks: list[dict[str, Any]]
|
||||||
|
recalled_retrospectives: list[dict[str, Any]]
|
||||||
|
retrospective_shortlist: list[dict[str, Any]]
|
||||||
|
skill_shortlist: list[dict[str, Any]]
|
||||||
|
skill_activation_records: list[dict[str, Any]]
|
||||||
|
execution_decision: dict[str, Any] | None
|
||||||
|
merge_report: dict[str, Any] | None
|
||||||
|
verification_report: dict[str, Any] | None
|
||||||
|
feature_flags: dict[str, bool]
|
||||||
|
observability_report: dict[str, Any] | None
|
||||||
|
|
||||||
turn_context: dict[str, Any] | None
|
turn_context: dict[str, Any] | None
|
||||||
routing_decision: dict[str, Any] | None
|
routing_decision: dict[str, Any] | None
|
||||||
@@ -254,6 +266,18 @@ def initial_state(user_id: str, conversation_id: str) -> AgentState:
|
|||||||
memory_context=None,
|
memory_context=None,
|
||||||
current_datetime_context=None,
|
current_datetime_context=None,
|
||||||
current_datetime_reference=None,
|
current_datetime_reference=None,
|
||||||
|
runtime_request_context=None,
|
||||||
|
task_graph=None,
|
||||||
|
scheduled_subtasks=[],
|
||||||
|
recalled_retrospectives=[],
|
||||||
|
retrospective_shortlist=[],
|
||||||
|
skill_shortlist=[],
|
||||||
|
skill_activation_records=[],
|
||||||
|
execution_decision=None,
|
||||||
|
merge_report=None,
|
||||||
|
verification_report=None,
|
||||||
|
feature_flags={},
|
||||||
|
observability_report=None,
|
||||||
turn_context=None,
|
turn_context=None,
|
||||||
routing_decision=None,
|
routing_decision=None,
|
||||||
continuity_state=None,
|
continuity_state=None,
|
||||||
|
|||||||
@@ -104,6 +104,15 @@ class Settings(BaseSettings):
|
|||||||
WEB_SEARCH_DEFAULT_LIMIT: int = 5
|
WEB_SEARCH_DEFAULT_LIMIT: int = 5
|
||||||
WEB_SEARCH_TIMEOUT_SECONDS: int = 10
|
WEB_SEARCH_TIMEOUT_SECONDS: int = 10
|
||||||
|
|
||||||
|
# === Hermes 风格升级开关 ===
|
||||||
|
ENABLE_RETROSPECTIVE: bool = True
|
||||||
|
ENABLE_SESSION_RETROSPECTIVE_SEARCH: bool = True
|
||||||
|
ENABLE_RUNTIME_SKILL_SHORTLIST: bool = True
|
||||||
|
ENABLE_LEARNING_SIGNALS: bool = True
|
||||||
|
ENABLE_SKILL_PROMOTION: bool = True
|
||||||
|
ENABLE_LEARNED_SKILL_LOADING: bool = True
|
||||||
|
ENABLE_PARALLEL_TASK_GRAPH: bool = True
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
settings.DATABASE_URL = settings.DATABASE_URL.replace("./data", _resolve_path("./data"), 1)
|
settings.DATABASE_URL = settings.DATABASE_URL.replace("./data", _resolve_path("./data"), 1)
|
||||||
|
|||||||
@@ -39,10 +39,12 @@ async def init_db():
|
|||||||
await ensure_message_columns(conn)
|
await ensure_message_columns(conn)
|
||||||
await ensure_conversation_columns(conn)
|
await ensure_conversation_columns(conn)
|
||||||
await ensure_document_columns(conn)
|
await ensure_document_columns(conn)
|
||||||
|
await ensure_memory_columns(conn)
|
||||||
await ensure_user_columns(conn)
|
await ensure_user_columns(conn)
|
||||||
await ensure_forum_columns(conn)
|
await ensure_forum_columns(conn)
|
||||||
await ensure_agent_columns(conn)
|
await ensure_agent_columns(conn)
|
||||||
await ensure_skill_columns(conn)
|
await ensure_skill_columns(conn)
|
||||||
|
await ensure_learning_artifact_tables(conn)
|
||||||
|
|
||||||
|
|
||||||
async def ensure_log_columns(conn):
|
async def ensure_log_columns(conn):
|
||||||
@@ -115,6 +117,28 @@ async def ensure_document_columns(conn):
|
|||||||
await conn.execute(text(ddl))
|
await conn.execute(text(ddl))
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_memory_columns(conn):
|
||||||
|
rows = await _get_table_info(conn, 'user_memories')
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
columns = {row[1] for row in rows}
|
||||||
|
required_columns = {
|
||||||
|
'frequency_count': "ALTER TABLE user_memories ADD COLUMN frequency_count INTEGER DEFAULT 0",
|
||||||
|
'emotion_tags': "ALTER TABLE user_memories ADD COLUMN emotion_tags JSON",
|
||||||
|
'importance_score': "ALTER TABLE user_memories ADD COLUMN importance_score FLOAT DEFAULT 0.5",
|
||||||
|
'importance_level': "ALTER TABLE user_memories ADD COLUMN importance_level VARCHAR(20) DEFAULT 'medium'",
|
||||||
|
'associated_topics': "ALTER TABLE user_memories ADD COLUMN associated_topics JSON",
|
||||||
|
'decay_score': "ALTER TABLE user_memories ADD COLUMN decay_score FLOAT DEFAULT 1.0",
|
||||||
|
'is_archived': "ALTER TABLE user_memories ADD COLUMN is_archived BOOLEAN DEFAULT 0",
|
||||||
|
'last_accessed_at': "ALTER TABLE user_memories ADD COLUMN last_accessed_at DATETIME",
|
||||||
|
'archive_at': "ALTER TABLE user_memories ADD COLUMN archive_at DATETIME",
|
||||||
|
}
|
||||||
|
for column, ddl in required_columns.items():
|
||||||
|
if column not in columns:
|
||||||
|
await conn.execute(text(ddl))
|
||||||
|
|
||||||
|
|
||||||
async def ensure_user_columns(conn):
|
async def ensure_user_columns(conn):
|
||||||
rows = await _get_table_info(conn, 'users')
|
rows = await _get_table_info(conn, 'users')
|
||||||
if not rows:
|
if not rows:
|
||||||
@@ -181,6 +205,14 @@ async def ensure_skill_columns(conn):
|
|||||||
'output_format': "ALTER TABLE skills ADD COLUMN output_format TEXT",
|
'output_format': "ALTER TABLE skills ADD COLUMN output_format TEXT",
|
||||||
'is_builtin': "ALTER TABLE skills ADD COLUMN is_builtin BOOLEAN DEFAULT 0 NOT NULL",
|
'is_builtin': "ALTER TABLE skills ADD COLUMN is_builtin BOOLEAN DEFAULT 0 NOT NULL",
|
||||||
'team_id': "ALTER TABLE skills ADD COLUMN team_id VARCHAR(36)",
|
'team_id': "ALTER TABLE skills ADD COLUMN team_id VARCHAR(36)",
|
||||||
|
'status': "ALTER TABLE skills ADD COLUMN status VARCHAR(20) DEFAULT 'active' NOT NULL",
|
||||||
|
'scope': "ALTER TABLE skills ADD COLUMN scope JSON DEFAULT '[]' NOT NULL",
|
||||||
|
'effectiveness': "ALTER TABLE skills ADD COLUMN effectiveness FLOAT DEFAULT 0.0 NOT NULL",
|
||||||
|
'review_after': "ALTER TABLE skills ADD COLUMN review_after DATETIME",
|
||||||
|
'candidate_count': "ALTER TABLE skills ADD COLUMN candidate_count INTEGER DEFAULT 0 NOT NULL",
|
||||||
|
'candidate_source_hashes': "ALTER TABLE skills ADD COLUMN candidate_source_hashes JSON DEFAULT '[]' NOT NULL",
|
||||||
|
'activation_count': "ALTER TABLE skills ADD COLUMN activation_count INTEGER DEFAULT 0 NOT NULL",
|
||||||
|
'last_activated_at': "ALTER TABLE skills ADD COLUMN last_activated_at DATETIME",
|
||||||
}
|
}
|
||||||
for column, ddl in required_columns.items():
|
for column, ddl in required_columns.items():
|
||||||
if column not in columns:
|
if column not in columns:
|
||||||
@@ -205,6 +237,48 @@ async def ensure_skill_columns(conn):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_learning_artifact_tables(conn):
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS learning_artifacts (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
user_id VARCHAR(36) NOT NULL,
|
||||||
|
conversation_id VARCHAR(36) NOT NULL,
|
||||||
|
retrospective_id VARCHAR(36),
|
||||||
|
artifact_type VARCHAR(32) NOT NULL,
|
||||||
|
artifact_key VARCHAR(128),
|
||||||
|
summary_text TEXT NOT NULL,
|
||||||
|
payload JSON NOT NULL,
|
||||||
|
recorded_at DATETIME NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_learning_artifacts_user_id ON learning_artifacts (user_id)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_learning_artifacts_conversation_id ON learning_artifacts (conversation_id)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_learning_artifacts_retrospective_id ON learning_artifacts (retrospective_id)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_learning_artifacts_artifact_type ON learning_artifacts (artifact_type)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _backfill_usernames(conn):
|
async def _backfill_usernames(conn):
|
||||||
result = await conn.execute(text("SELECT id, email, username FROM users ORDER BY created_at, id"))
|
result = await conn.execute(text("SELECT id, email, username FROM users ORDER BY created_at, id"))
|
||||||
users = result.fetchall()
|
users = result.fetchall()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from app.models.forum import ForumPost, ForumReply
|
|||||||
from app.models.agent import Agent, AgentMessage
|
from app.models.agent import Agent, AgentMessage
|
||||||
from app.models.conversation import Conversation, Message
|
from app.models.conversation import Conversation, Message
|
||||||
from app.models.knowledge_graph import KGNode, KGEdge
|
from app.models.knowledge_graph import KGNode, KGEdge
|
||||||
|
from app.models.learning import LearningArtifactRecord, SessionRetrospectiveRecord
|
||||||
from app.models.memory import MemorySummary, UserMemory
|
from app.models.memory import MemorySummary, UserMemory
|
||||||
from app.models.brain import (
|
from app.models.brain import (
|
||||||
BrainEvent,
|
BrainEvent,
|
||||||
@@ -20,6 +21,7 @@ from app.models.brain import (
|
|||||||
from app.models.todo import DailyTodo, TodoSource
|
from app.models.todo import DailyTodo, TodoSource
|
||||||
from app.models.reminder import Reminder, ReminderStatus
|
from app.models.reminder import Reminder, ReminderStatus
|
||||||
from app.models.goal import Goal, GoalStatus
|
from app.models.goal import Goal, GoalStatus
|
||||||
|
from app.models.skill import Skill
|
||||||
from app.models.log import Log, LogType, LogLevel
|
from app.models.log import Log, LogType, LogLevel
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -38,6 +40,8 @@ __all__ = [
|
|||||||
"Message",
|
"Message",
|
||||||
"KGNode",
|
"KGNode",
|
||||||
"KGEdge",
|
"KGEdge",
|
||||||
|
"LearningArtifactRecord",
|
||||||
|
"SessionRetrospectiveRecord",
|
||||||
"MemorySummary",
|
"MemorySummary",
|
||||||
"UserMemory",
|
"UserMemory",
|
||||||
"BrainEvent",
|
"BrainEvent",
|
||||||
@@ -53,6 +57,7 @@ __all__ = [
|
|||||||
"ReminderStatus",
|
"ReminderStatus",
|
||||||
"Goal",
|
"Goal",
|
||||||
"GoalStatus",
|
"GoalStatus",
|
||||||
|
"Skill",
|
||||||
"Log",
|
"Log",
|
||||||
"LogType",
|
"LogType",
|
||||||
"LogLevel",
|
"LogLevel",
|
||||||
|
|||||||
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)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, String, Text, Boolean, JSON, ForeignKey
|
from sqlalchemy import Column, String, Text, Boolean, JSON, ForeignKey, Float, Integer, DateTime
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from app.models.base import BaseModel
|
from app.models.base import BaseModel
|
||||||
|
|
||||||
@@ -17,6 +17,14 @@ class Skill(BaseModel):
|
|||||||
is_builtin = Column(Boolean, default=False, nullable=False)
|
is_builtin = Column(Boolean, default=False, nullable=False)
|
||||||
team_id = Column(String(36), ForeignKey("users.id"), nullable=True)
|
team_id = Column(String(36), ForeignKey("users.id"), nullable=True)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
|
status = Column(String(20), default="active", nullable=False, index=True) # candidate/shadow/active/deprecated/retired
|
||||||
|
scope = Column(JSON, default=list, nullable=False)
|
||||||
|
effectiveness = Column(Float, default=0.0, nullable=False)
|
||||||
|
review_after = Column(DateTime, nullable=True)
|
||||||
|
candidate_count = Column(Integer, default=0, nullable=False)
|
||||||
|
candidate_source_hashes = Column(JSON, default=list, nullable=False)
|
||||||
|
activation_count = Column(Integer, default=0, nullable=False)
|
||||||
|
last_activated_at = Column(DateTime, nullable=True)
|
||||||
owner_id = Column(String(36), ForeignKey("users.id"), nullable=False)
|
owner_id = Column(String(36), ForeignKey("users.id"), nullable=False)
|
||||||
|
|
||||||
owner = relationship("User", foreign_keys=[owner_id])
|
owner = relationship("User", foreign_keys=[owner_id])
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.agents.learning.store import LearningArtifactStore, SessionRetrospectiveStore
|
||||||
from app.agents.registry import load_builtin_registry_indexes
|
from app.agents.registry import load_builtin_registry_indexes
|
||||||
from app.agents.runtime_metrics import coerce_cost_thresholds, estimate_token_cost, is_cost_budget_warning
|
from app.agents.runtime_metrics import coerce_cost_thresholds, estimate_token_cost, is_cost_budget_warning
|
||||||
from app.models.agent import Agent
|
from app.models.agent import Agent
|
||||||
@@ -37,6 +38,7 @@ from app.schemas.agent import (
|
|||||||
AgentVisibilityVerifierOut,
|
AgentVisibilityVerifierOut,
|
||||||
)
|
)
|
||||||
from app.services.agent_service import _extract_continuity_snapshot
|
from app.services.agent_service import _extract_continuity_snapshot
|
||||||
|
from app.services.runtime_observability import build_runtime_observability_report
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/agents", tags=["Agent"])
|
router = APIRouter(prefix="/api/agents", tags=["Agent"])
|
||||||
|
|
||||||
@@ -662,6 +664,59 @@ async def get_visibility_tools(
|
|||||||
return _build_tool_governance(state, conversation_id=conversation_id)
|
return _build_tool_governance(state, conversation_id=conversation_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/visibility/debug")
|
||||||
|
async def get_visibility_debug(
|
||||||
|
conversation_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
state = await _get_visibility_state(conversation_id, current_user=current_user, db=db)
|
||||||
|
observability = build_runtime_observability_report(
|
||||||
|
state=state,
|
||||||
|
feature_flags=dict(state.get("feature_flags") or {}),
|
||||||
|
)
|
||||||
|
retrospective_store = SessionRetrospectiveStore(db)
|
||||||
|
artifact_store = LearningArtifactStore(db)
|
||||||
|
recent_retrospectives = await retrospective_store.list_recent(
|
||||||
|
user_id=current_user.id,
|
||||||
|
limit=5,
|
||||||
|
)
|
||||||
|
recent_artifacts = await artifact_store.list_recent(
|
||||||
|
user_id=current_user.id,
|
||||||
|
limit=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"observability": observability,
|
||||||
|
"skill_shortlist": list(state.get("skill_shortlist") or []),
|
||||||
|
"retrospective_shortlist": list(state.get("retrospective_shortlist") or []),
|
||||||
|
"merge_report": state.get("merge_report"),
|
||||||
|
"verification_report": state.get("verification_report"),
|
||||||
|
"recent_retrospectives": [
|
||||||
|
{
|
||||||
|
"id": item.id,
|
||||||
|
"task_type": item.task_type,
|
||||||
|
"summary": item.summary_text,
|
||||||
|
"execution_mode": item.execution_mode,
|
||||||
|
"verification_status": item.verification_status,
|
||||||
|
"recorded_at": item.recorded_at.isoformat() if item.recorded_at else None,
|
||||||
|
}
|
||||||
|
for item in recent_retrospectives
|
||||||
|
],
|
||||||
|
"recent_learning_artifacts": [
|
||||||
|
{
|
||||||
|
"id": item.id,
|
||||||
|
"artifact_type": item.artifact_type,
|
||||||
|
"artifact_key": item.artifact_key,
|
||||||
|
"summary": item.summary_text,
|
||||||
|
"recorded_at": item.recorded_at.isoformat() if item.recorded_at else None,
|
||||||
|
}
|
||||||
|
for item in recent_artifacts
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=AgentOut, status_code=201)
|
@router.post("", response_model=AgentOut, status_code=201)
|
||||||
async def create_agent(
|
async def create_agent(
|
||||||
data: AgentCreate,
|
data: AgentCreate,
|
||||||
|
|||||||
@@ -145,6 +145,9 @@ async def chat_stream(
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
yield f"event: error\ndata: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n"
|
yield f"event: error\ndata: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n"
|
||||||
return
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
yield f"event: error\ndata: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
yield f"event: metadata\ndata: {json.dumps({'conversation_id': conv_id, 'message_id': msg_id})}\n\n"
|
yield f"event: metadata\ndata: {json.dumps({'conversation_id': conv_id, 'message_id': msg_id})}\n\n"
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ async def create_skill(
|
|||||||
visibility=data.visibility,
|
visibility=data.visibility,
|
||||||
team_id=data.team_id,
|
team_id=data.team_id,
|
||||||
is_active=data.is_active,
|
is_active=data.is_active,
|
||||||
|
status=data.status,
|
||||||
|
scope=data.scope,
|
||||||
|
effectiveness=data.effectiveness,
|
||||||
|
review_after=data.review_after,
|
||||||
owner_id=current_user.id,
|
owner_id=current_user.id,
|
||||||
)
|
)
|
||||||
db.add(skill)
|
db.add(skill)
|
||||||
@@ -103,6 +107,14 @@ async def update_skill(
|
|||||||
skill.team_id = data.team_id
|
skill.team_id = data.team_id
|
||||||
if data.is_active is not None:
|
if data.is_active is not None:
|
||||||
skill.is_active = data.is_active
|
skill.is_active = data.is_active
|
||||||
|
if data.status is not None:
|
||||||
|
skill.status = data.status
|
||||||
|
if data.scope is not None:
|
||||||
|
skill.scope = data.scope
|
||||||
|
if data.effectiveness is not None:
|
||||||
|
skill.effectiveness = data.effectiveness
|
||||||
|
if data.review_after is not None:
|
||||||
|
skill.review_after = data.review_after
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(skill)
|
await db.refresh(skill)
|
||||||
|
|||||||
@@ -12,4 +12,4 @@ async def get_system_status():
|
|||||||
@router.get('/config')
|
@router.get('/config')
|
||||||
async def get_system_config():
|
async def get_system_config():
|
||||||
"""Get public system configuration."""
|
"""Get public system configuration."""
|
||||||
return SystemService().get_config()
|
return await SystemService().get_config()
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ class SkillCreate(BaseModel):
|
|||||||
visibility: str = "private"
|
visibility: str = "private"
|
||||||
team_id: Optional[str] = None
|
team_id: Optional[str] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
status: str = "active"
|
||||||
|
scope: list[str] = []
|
||||||
|
effectiveness: Optional[float] = None
|
||||||
|
review_after: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class SkillUpdate(BaseModel):
|
class SkillUpdate(BaseModel):
|
||||||
@@ -28,6 +32,10 @@ class SkillUpdate(BaseModel):
|
|||||||
visibility: Optional[str] = None
|
visibility: Optional[str] = None
|
||||||
team_id: Optional[str] = None
|
team_id: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
scope: Optional[list[str]] = None
|
||||||
|
effectiveness: Optional[float] = None
|
||||||
|
review_after: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class SkillOut(BaseModel):
|
class SkillOut(BaseModel):
|
||||||
@@ -43,6 +51,12 @@ class SkillOut(BaseModel):
|
|||||||
is_builtin: bool
|
is_builtin: bool
|
||||||
team_id: Optional[str]
|
team_id: Optional[str]
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
status: str
|
||||||
|
scope: list[str]
|
||||||
|
effectiveness: Optional[float]
|
||||||
|
review_after: Optional[datetime]
|
||||||
|
activation_count: int
|
||||||
|
last_activated_at: Optional[datetime]
|
||||||
owner_id: str
|
owner_id: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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 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()
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,6 +314,22 @@ class FailIfCalledLLM:
|
|||||||
raise AssertionError('LLM should not be called for simple greetings')
|
raise AssertionError('LLM should not be called for simple greetings')
|
||||||
|
|
||||||
|
|
||||||
|
class InternalMarkupRecoveryLLM:
|
||||||
|
def __init__(self, responses: list[str]):
|
||||||
|
self.responses = responses
|
||||||
|
self.calls = 0
|
||||||
|
self._jarvis_provider_capabilities = SimpleNamespace(
|
||||||
|
provider='minimax',
|
||||||
|
supports_native_tools=False,
|
||||||
|
preferred_tool_strategy='json_fallback',
|
||||||
|
)
|
||||||
|
|
||||||
|
async def ainvoke(self, messages):
|
||||||
|
self.calls += 1
|
||||||
|
index = min(self.calls - 1, len(self.responses) - 1)
|
||||||
|
return AIMessage(content=self.responses[index])
|
||||||
|
|
||||||
|
|
||||||
def test_initial_state_sets_structured_continuity_defaults():
|
def test_initial_state_sets_structured_continuity_defaults():
|
||||||
state = initial_state('u1', 'c1')
|
state = initial_state('u1', 'c1')
|
||||||
|
|
||||||
@@ -2047,6 +2063,75 @@ async def test_run_sub_commander_uses_web_search_in_json_fallback(monkeypatch):
|
|||||||
assert result['final_response'] == '我查了外部网页,下面是最新结果摘要。'
|
assert result['final_response'] == '我查了外部网页,下面是最新结果摘要。'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_sub_commander_recovers_from_internal_tool_markup_after_tool_round(monkeypatch):
|
||||||
|
fake_llm = InternalMarkupRecoveryLLM([
|
||||||
|
'{"mode":"tool_call","tool_calls":[{"name":"web_search","arguments":{"query":"武汉 介绍","top_k":2}}]}',
|
||||||
|
'我来让知识管理员为你整理武汉的详细介绍。\n\n分发说明:这个问题需要调用知识库信息,由 librarian(知识管理员)处理最合适。\n<minimax:tool_call>\n<invoke name="librarian">\n<parameter name="info_type">city_introduction</parameter>\n<parameter name="parameters">{"city":"武汉","word_count":2000,"language":"zh-CN"}</parameter>\n</invoke>\n</minimax:tool_call>',
|
||||||
|
'武汉是湖北省省会,位于长江与汉江交汇处,是中部重要的交通、科教和工业中心。',
|
||||||
|
])
|
||||||
|
fake_tool = FakeTool('web_search', 'found 2 web results')
|
||||||
|
|
||||||
|
monkeypatch.setattr('app.agents.graph._get_llm_for_state', lambda state: fake_llm)
|
||||||
|
monkeypatch.setitem(
|
||||||
|
__import__('app.agents.graph', fromlist=['SUB_COMMANDER_TOOLSETS']).SUB_COMMANDER_TOOLSETS,
|
||||||
|
'librarian_retrieval',
|
||||||
|
[fake_tool],
|
||||||
|
)
|
||||||
|
|
||||||
|
state = _base_state('请介绍一下武汉', {'provider': 'openai', 'model': 'MiniMax-M2.7-highspeed', 'base_url': 'https://api.minimaxi.com/v1'})
|
||||||
|
state['current_agent'] = AgentRole.LIBRARIAN
|
||||||
|
state['max_retries'] = 1
|
||||||
|
|
||||||
|
result = await _run_sub_commander(
|
||||||
|
state,
|
||||||
|
AgentRole.LIBRARIAN,
|
||||||
|
'manager prompt',
|
||||||
|
'请介绍一下武汉',
|
||||||
|
use_tools=True,
|
||||||
|
summary_target='knowledge_context',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert fake_llm.calls == 3
|
||||||
|
assert fake_tool.invocations == [{'query': '武汉 介绍', 'top_k': 2}]
|
||||||
|
assert result['fallback_parse_error'] is None
|
||||||
|
assert '<invoke name=' not in result['final_response']
|
||||||
|
assert '分发说明' not in result['final_response']
|
||||||
|
assert result['final_response'] == '武汉是湖北省省会,位于长江与汉江交汇处,是中部重要的交通、科教和工业中心。'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_sub_commander_falls_back_to_tool_summary_when_internal_markup_persists(monkeypatch):
|
||||||
|
fake_llm = InternalMarkupRecoveryLLM([
|
||||||
|
'{"mode":"tool_call","tool_calls":[{"name":"web_search","arguments":{"query":"武汉 介绍","top_k":2}}]}',
|
||||||
|
'分发说明:交给 librarian。\n<minimax:tool_call><invoke name="librarian"></invoke></minimax:tool_call>',
|
||||||
|
])
|
||||||
|
fake_tool = FakeTool('web_search', 'found 2 web results')
|
||||||
|
|
||||||
|
monkeypatch.setattr('app.agents.graph._get_llm_for_state', lambda state: fake_llm)
|
||||||
|
monkeypatch.setitem(
|
||||||
|
__import__('app.agents.graph', fromlist=['SUB_COMMANDER_TOOLSETS']).SUB_COMMANDER_TOOLSETS,
|
||||||
|
'librarian_retrieval',
|
||||||
|
[fake_tool],
|
||||||
|
)
|
||||||
|
|
||||||
|
state = _base_state('请介绍一下武汉', {'provider': 'openai', 'model': 'MiniMax-M2.7-highspeed', 'base_url': 'https://api.minimaxi.com/v1'})
|
||||||
|
state['current_agent'] = AgentRole.LIBRARIAN
|
||||||
|
state['max_retries'] = 0
|
||||||
|
|
||||||
|
result = await _run_sub_commander(
|
||||||
|
state,
|
||||||
|
AgentRole.LIBRARIAN,
|
||||||
|
'manager prompt',
|
||||||
|
'请介绍一下武汉',
|
||||||
|
use_tools=True,
|
||||||
|
summary_target='knowledge_context',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert fake_llm.calls == 2
|
||||||
|
assert result['fallback_parse_error'] == 'internal_tool_markup'
|
||||||
|
assert result['final_response'] == '我已经完成检索,直接给您可用信息:\n\nfound 2 web results'
|
||||||
|
assert '<invoke name=' not in result['final_response']
|
||||||
|
|
||||||
|
|
||||||
async def test_run_sub_commander_supports_multiple_json_fallback_tool_rounds(monkeypatch):
|
async def test_run_sub_commander_supports_multiple_json_fallback_tool_rounds(monkeypatch):
|
||||||
fake_llm = TripleResponseFallbackLLM([
|
fake_llm = TripleResponseFallbackLLM([
|
||||||
'{"mode":"tool_call","tool_calls":[{"name":"web_search","arguments":{"query":"Jarvis 最新模型更新","top_k":2}}]}',
|
'{"mode":"tool_call","tool_calls":[{"name":"web_search","arguments":{"query":"Jarvis 最新模型更新","top_k":2}}]}',
|
||||||
|
|||||||
1013
backend/tests/backend/app/agents/test_learning_runtime.py
Normal file
1013
backend/tests/backend/app/agents/test_learning_runtime.py
Normal file
File diff suppressed because it is too large
Load Diff
138
backend/tests/backend/app/agents/test_result_merge_runtime.py
Normal file
138
backend/tests/backend/app/agents/test_result_merge_runtime.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import app.agents.graph as graph_module
|
||||||
|
from app.agents.orchestration.result_merge import merge_task_results
|
||||||
|
from app.agents.schemas.task import AgentTask
|
||||||
|
from app.agents.state import AgentRole, initial_state
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_task_results_marks_conflict_for_distinct_completed_summaries():
|
||||||
|
report = merge_task_results(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"task_id": "task-1",
|
||||||
|
"status": "completed",
|
||||||
|
"summary": "结论 A",
|
||||||
|
"evidence": [{"type": "source"}],
|
||||||
|
"owner_agent_id": "librarian",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"task_id": "task-2",
|
||||||
|
"status": "completed",
|
||||||
|
"summary": "结论 B",
|
||||||
|
"evidence": [{"type": "analysis"}, {"type": "analysis"}],
|
||||||
|
"owner_agent_id": "analyst",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert report.status == "conflicted"
|
||||||
|
assert "multiple_distinct_completed_summaries" in report.conflict_flags
|
||||||
|
assert report.resolution_strategy == "rank_by_evidence_count"
|
||||||
|
assert report.resolved_summary == "结论 B"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_collaboration_results_persists_merge_and_verification_reports():
|
||||||
|
state = initial_state("u1", "c1")
|
||||||
|
tasks = [
|
||||||
|
AgentTask(
|
||||||
|
task_id="task-1",
|
||||||
|
title="收集证据",
|
||||||
|
role=AgentRole.LIBRARIAN.value,
|
||||||
|
owner_agent_id=AgentRole.LIBRARIAN.value,
|
||||||
|
goal="检索资料",
|
||||||
|
expected_evidence=[{"type": "evidence"}],
|
||||||
|
),
|
||||||
|
AgentTask(
|
||||||
|
task_id="task-2",
|
||||||
|
title="给出分析",
|
||||||
|
role=AgentRole.ANALYST.value,
|
||||||
|
owner_agent_id=AgentRole.ANALYST.value,
|
||||||
|
goal="分析风险",
|
||||||
|
expected_evidence=[{"type": "analysis"}],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
graph_module._verify_collaboration_results(
|
||||||
|
state,
|
||||||
|
tasks,
|
||||||
|
task_results=[
|
||||||
|
{
|
||||||
|
"task_id": "task-1",
|
||||||
|
"status": "completed",
|
||||||
|
"summary": "证据显示风险中等",
|
||||||
|
"evidence": [{"type": "evidence"}],
|
||||||
|
"owner_agent_id": AgentRole.LIBRARIAN.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"task_id": "task-2",
|
||||||
|
"status": "completed",
|
||||||
|
"summary": "证据显示风险中等",
|
||||||
|
"evidence": [{"type": "analysis"}],
|
||||||
|
"owner_agent_id": AgentRole.ANALYST.value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert state["merge_report"] is not None
|
||||||
|
assert state["merge_report"]["status"] == "merged"
|
||||||
|
assert state["verification_report"] is not None
|
||||||
|
assert state["verification_report"]["status"] == "passed"
|
||||||
|
event_types = [item["event_type"] for item in state["event_trace"]]
|
||||||
|
assert "agent.merge.completed" in event_types
|
||||||
|
assert "agent.verify.completed" in event_types
|
||||||
|
|
||||||
|
|
||||||
|
def test_serial_fallback_response_is_used_when_merge_report_requires_fallback():
|
||||||
|
state = initial_state("u1", "c1")
|
||||||
|
tasks = [
|
||||||
|
AgentTask(
|
||||||
|
task_id="task-1",
|
||||||
|
title="收集证据",
|
||||||
|
role=AgentRole.LIBRARIAN.value,
|
||||||
|
owner_agent_id=AgentRole.LIBRARIAN.value,
|
||||||
|
goal="检索资料",
|
||||||
|
expected_evidence=[{"type": "evidence"}],
|
||||||
|
),
|
||||||
|
AgentTask(
|
||||||
|
task_id="task-2",
|
||||||
|
title="给出分析",
|
||||||
|
role=AgentRole.ANALYST.value,
|
||||||
|
owner_agent_id=AgentRole.ANALYST.value,
|
||||||
|
goal="分析风险",
|
||||||
|
expected_evidence=[{"type": "analysis"}],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
state["task_results"] = [
|
||||||
|
{
|
||||||
|
"task_id": "task-1",
|
||||||
|
"status": "completed",
|
||||||
|
"summary": "已确认可用证据",
|
||||||
|
"evidence": [{"type": "evidence"}],
|
||||||
|
"owner_agent_id": AgentRole.LIBRARIAN.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"task_id": "task-2",
|
||||||
|
"status": "failed",
|
||||||
|
"summary": "分析失败",
|
||||||
|
"evidence": [{"type": "analysis"}],
|
||||||
|
"owner_agent_id": AgentRole.ANALYST.value,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
state["final_response"] = "原始协作汇总"
|
||||||
|
|
||||||
|
graph_module._verify_collaboration_results(state, tasks, state["task_results"])
|
||||||
|
if state["verification_status"] == "failed" and state["merge_report"]["fallback_used"]:
|
||||||
|
state["final_response"] = graph_module._build_serial_fallback_response(
|
||||||
|
"先查资料再分析",
|
||||||
|
state["task_results"],
|
||||||
|
state["merge_report"],
|
||||||
|
)
|
||||||
|
graph_module._append_event_trace(
|
||||||
|
state,
|
||||||
|
"agent.rollback.triggered",
|
||||||
|
payload={"layer": "collaboration_runtime", "reason": "merge_fallback_used"},
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "切回保守收敛路径" in state["final_response"]
|
||||||
|
event_types = [item["event_type"] for item in state["event_trace"]]
|
||||||
|
assert "agent.rollback.triggered" in event_types
|
||||||
170
backend/tests/backend/app/agents/test_runtime_context.py
Normal file
170
backend/tests/backend/app/agents/test_runtime_context.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
from langchain_core.messages import HumanMessage
|
||||||
|
|
||||||
|
import app.agents.graph as graph_module
|
||||||
|
from app.agents.graph import master_node
|
||||||
|
from app.agents.learning.retrospector import build_session_retrospective
|
||||||
|
from app.agents.schemas.orchestration import (
|
||||||
|
RuntimeRequestContext,
|
||||||
|
assess_parallel_worthiness,
|
||||||
|
render_runtime_request_context_summary,
|
||||||
|
)
|
||||||
|
from app.agents.schemas.skills import SkillShortlistEntry
|
||||||
|
from app.agents.state import initial_state
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_request_context_summary_renders_parallel_and_shortlists():
|
||||||
|
worthiness = assess_parallel_worthiness(
|
||||||
|
"先分析需求,再查资料,同时整理成计划",
|
||||||
|
retrospective_count=1,
|
||||||
|
skill_count=2,
|
||||||
|
)
|
||||||
|
context = RuntimeRequestContext(
|
||||||
|
user_id="u1",
|
||||||
|
session_id="c1",
|
||||||
|
query_text="先分析需求,再查资料,同时整理成计划",
|
||||||
|
recalled_memories=["最近偏好结构化输出"],
|
||||||
|
retrospective_shortlist=[
|
||||||
|
{
|
||||||
|
"task_type": "analysis",
|
||||||
|
"summary": "上次先检索再分析,结果更稳。",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
shortlisted_skills=["weekly-planning"],
|
||||||
|
skill_shortlist=[
|
||||||
|
SkillShortlistEntry(
|
||||||
|
skill_name="weekly-planning",
|
||||||
|
score=0.82,
|
||||||
|
rationale="命中计划关键词",
|
||||||
|
injection_mode="summary",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
parallel_worthiness=worthiness,
|
||||||
|
recommended_runtime_mode="collaboration",
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = render_runtime_request_context_summary(context)
|
||||||
|
|
||||||
|
assert "Runtime Request Context" in summary
|
||||||
|
assert "collaboration" in summary
|
||||||
|
assert "weekly-planning" in summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_session_retrospective_captures_skill_and_history_context():
|
||||||
|
retrospective = build_session_retrospective(
|
||||||
|
request_id="resp-1",
|
||||||
|
session_id="conv-1",
|
||||||
|
user_query="帮我分析并安排下周任务",
|
||||||
|
state={
|
||||||
|
"execution_mode": "collaboration",
|
||||||
|
"current_agent": "analyst",
|
||||||
|
"verification_status": "passed",
|
||||||
|
"verification_summary": "ok",
|
||||||
|
"final_response": "已经给出建议",
|
||||||
|
"skill_shortlist": [{"skill_name": "weekly-planning"}],
|
||||||
|
"event_trace": [{"event_type": "agent.execution.decided", "agent_id": "master"}],
|
||||||
|
"verification_evidence": [{"type": "verification"}],
|
||||||
|
"completed_tasks": [{"task_id": "t1", "title": "收集信息", "status": "completed"}],
|
||||||
|
"retrospective_shortlist": [{"summary": "上次周计划拆解有效"}],
|
||||||
|
"parallel_worthiness": {"score": 0.6},
|
||||||
|
},
|
||||||
|
runtime_context={
|
||||||
|
"user_id": "u1",
|
||||||
|
"recommended_runtime_mode": "collaboration",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert retrospective.user_id == "u1"
|
||||||
|
assert retrospective.execution_mode == "collaboration"
|
||||||
|
assert retrospective.used_skill_names == ["weekly-planning"]
|
||||||
|
assert retrospective.context_snapshot["retrospective_shortlist_count"] == 1
|
||||||
|
assert retrospective.outcome == "completed"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_master_node_records_execution_decision_and_skill_shortlist_event():
|
||||||
|
state = initial_state("u1", "c1")
|
||||||
|
state["messages"] = [HumanMessage(content="帮我查一下资料并分析重点")]
|
||||||
|
state["skill_shortlist"] = [
|
||||||
|
{
|
||||||
|
"skill_name": "research-synthesis",
|
||||||
|
"score": 0.73,
|
||||||
|
"injection_mode": "summary",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
state["runtime_request_context"] = {
|
||||||
|
"request_id": "req-1",
|
||||||
|
"recommended_runtime_mode": "direct",
|
||||||
|
"parallel_worthiness": {
|
||||||
|
"preferred_mode": "direct",
|
||||||
|
"score": 0.2,
|
||||||
|
"estimated_subtasks": 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
state["task_graph"] = {
|
||||||
|
"graph_id": "graph-1",
|
||||||
|
"nodes": [{"node_id": "task-1", "title": "收集证据", "role": "librarian"}],
|
||||||
|
"entry_node_ids": ["task-1"],
|
||||||
|
"max_parallelism": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await master_node(state)
|
||||||
|
|
||||||
|
assert result["execution_decision"] is not None
|
||||||
|
event_types = [item["event_type"] for item in result["event_trace"]]
|
||||||
|
assert "agent.parallel.assessed" in event_types
|
||||||
|
assert "agent.skill.shortlisted" in event_types
|
||||||
|
assert "agent.task_graph.built" in event_types
|
||||||
|
assert "agent.execution.decided" in event_types
|
||||||
|
|
||||||
|
|
||||||
|
async def test_master_node_records_rollback_event_when_parallel_task_graph_flag_is_disabled():
|
||||||
|
async def fake_collaboration_flow(state, _user_query):
|
||||||
|
state["execution_mode"] = "collaboration"
|
||||||
|
state["final_response"] = "collaboration skipped in test"
|
||||||
|
return state
|
||||||
|
|
||||||
|
graph_module._run_collaboration_flow = fake_collaboration_flow
|
||||||
|
state = initial_state("u1", "c1")
|
||||||
|
state["messages"] = [HumanMessage(content="先查资料再分析风险再安排计划")]
|
||||||
|
state["feature_flags"] = {"ENABLE_PARALLEL_TASK_GRAPH": False}
|
||||||
|
state["parallel_worthiness"] = {
|
||||||
|
"preferred_mode": "parallel",
|
||||||
|
"score": 0.8,
|
||||||
|
"estimated_subtasks": 3,
|
||||||
|
}
|
||||||
|
state["runtime_request_context"] = {
|
||||||
|
"request_id": "req-2",
|
||||||
|
"user_id": "u1",
|
||||||
|
"session_id": "c1",
|
||||||
|
"recommended_runtime_mode": "collaboration",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await master_node(state)
|
||||||
|
|
||||||
|
event_types = [item["event_type"] for item in result["event_trace"]]
|
||||||
|
assert "agent.rollback.triggered" in event_types
|
||||||
|
|
||||||
|
|
||||||
|
async def test_master_node_direct_mode_baseline_still_returns_simple_response():
|
||||||
|
state = initial_state("u1", "c1")
|
||||||
|
state["messages"] = [HumanMessage(content="你好")]
|
||||||
|
|
||||||
|
result = await master_node(state)
|
||||||
|
|
||||||
|
assert result["execution_mode"] == "direct"
|
||||||
|
assert result["final_response"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_master_node_collaboration_mode_baseline_still_respects_complex_request(monkeypatch):
|
||||||
|
async def fake_collaboration_flow(state, _user_query):
|
||||||
|
state["execution_mode"] = "collaboration"
|
||||||
|
state["final_response"] = "collaboration baseline ok"
|
||||||
|
return state
|
||||||
|
|
||||||
|
graph_module._run_collaboration_flow = fake_collaboration_flow
|
||||||
|
state = initial_state("u1", "c1")
|
||||||
|
state["messages"] = [HumanMessage(content="先查资料,再分析风险,再安排计划")]
|
||||||
|
|
||||||
|
result = await master_node(state)
|
||||||
|
|
||||||
|
assert result["execution_mode"] == "collaboration"
|
||||||
|
assert result["final_response"] == "collaboration baseline ok"
|
||||||
66
backend/tests/backend/app/agents/test_scheduler_runtime.py
Normal file
66
backend/tests/backend/app/agents/test_scheduler_runtime.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from langchain_core.messages import HumanMessage
|
||||||
|
|
||||||
|
import app.agents.graph as graph_module
|
||||||
|
from app.agents.orchestration.scheduler import build_subtask_specs
|
||||||
|
from app.agents.orchestration.task_graph import build_bounded_task_graph
|
||||||
|
from app.agents.schemas.orchestration import TaskGraph, assess_parallel_worthiness
|
||||||
|
from app.agents.state import initial_state
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_subtask_specs_keeps_dependencies_and_contract_fields():
|
||||||
|
worthiness = assess_parallel_worthiness(
|
||||||
|
"先查资料、再分析风险、再安排下周计划",
|
||||||
|
retrospective_count=2,
|
||||||
|
skill_count=1,
|
||||||
|
)
|
||||||
|
task_graph = build_bounded_task_graph(
|
||||||
|
query_text="先查资料、再分析风险、再安排下周计划",
|
||||||
|
parallel_worthiness=worthiness,
|
||||||
|
)
|
||||||
|
|
||||||
|
specs = build_subtask_specs(task_graph, query_text="先查资料、再分析风险、再安排下周计划")
|
||||||
|
|
||||||
|
assert specs
|
||||||
|
assert all(spec.parent_run_id == task_graph.graph_id for spec in specs)
|
||||||
|
assert all(isinstance(spec.context_slice, dict) for spec in specs)
|
||||||
|
assert all(spec.expected_output_schema for spec in specs)
|
||||||
|
assert any(spec.dependencies for spec in specs)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_collaboration_flow_uses_task_graph_plan_and_records_subtask_events(monkeypatch):
|
||||||
|
async def fake_run_sub_commander(
|
||||||
|
state,
|
||||||
|
assigned_role,
|
||||||
|
_system_prompt,
|
||||||
|
task_goal,
|
||||||
|
**_kwargs,
|
||||||
|
):
|
||||||
|
state["final_response"] = f"{assigned_role.value} handled: {task_goal}"
|
||||||
|
return state
|
||||||
|
|
||||||
|
monkeypatch.setattr(graph_module, "_run_sub_commander", fake_run_sub_commander)
|
||||||
|
|
||||||
|
state = initial_state("u1", "c1")
|
||||||
|
state["messages"] = [HumanMessage(content="先查资料、再分析风险、再安排下周计划")]
|
||||||
|
state["current_datetime_context"] = "CURRENT_TIME: 2026-03-28T12:00:00+08:00"
|
||||||
|
state["task_graph"] = TaskGraph.model_validate(
|
||||||
|
build_bounded_task_graph(
|
||||||
|
query_text="先查资料、再分析风险、再安排下周计划",
|
||||||
|
parallel_worthiness=assess_parallel_worthiness(
|
||||||
|
"先查资料、再分析风险、再安排下周计划",
|
||||||
|
retrospective_count=2,
|
||||||
|
skill_count=1,
|
||||||
|
),
|
||||||
|
).model_dump(mode="json")
|
||||||
|
).model_dump(mode="json")
|
||||||
|
|
||||||
|
result = await graph_module._run_collaboration_flow(
|
||||||
|
state,
|
||||||
|
"先查资料、再分析风险、再安排下周计划",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["scheduled_subtasks"]
|
||||||
|
event_types = [item["event_type"] for item in result["event_trace"]]
|
||||||
|
assert "agent.subtask.started" in event_types
|
||||||
|
assert "agent.subtask.completed" in event_types
|
||||||
|
assert result["task_results"]
|
||||||
59
backend/tests/backend/app/agents/test_task_graph_runtime.py
Normal file
59
backend/tests/backend/app/agents/test_task_graph_runtime.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from app.agents.orchestration.task_graph import build_bounded_task_graph
|
||||||
|
from app.agents.schemas.orchestration import RuntimeRequestContext, assess_parallel_worthiness, render_runtime_request_context_summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_bounded_task_graph_creates_independent_nodes_and_merge_node():
|
||||||
|
worthiness = assess_parallel_worthiness(
|
||||||
|
"先查资料、再分析风险、再安排下周计划",
|
||||||
|
retrospective_count=2,
|
||||||
|
skill_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
graph = build_bounded_task_graph(
|
||||||
|
query_text="先查资料、再分析风险、再安排下周计划",
|
||||||
|
parallel_worthiness=worthiness,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert graph is not None
|
||||||
|
assert len(graph.nodes) >= 2
|
||||||
|
assert graph.entry_node_ids
|
||||||
|
assert any(node.execution_mode == "parallel" for node in graph.nodes[:-1])
|
||||||
|
assert graph.nodes[-1].role == "master"
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_request_context_summary_renders_task_graph():
|
||||||
|
worthiness = assess_parallel_worthiness(
|
||||||
|
"先查资料、再分析风险、再安排下周计划",
|
||||||
|
retrospective_count=1,
|
||||||
|
skill_count=1,
|
||||||
|
)
|
||||||
|
task_graph = build_bounded_task_graph(
|
||||||
|
query_text="先查资料、再分析风险、再安排下周计划",
|
||||||
|
parallel_worthiness=worthiness,
|
||||||
|
)
|
||||||
|
context = RuntimeRequestContext(
|
||||||
|
user_id="u1",
|
||||||
|
session_id="c1",
|
||||||
|
query_text="先查资料、再分析风险、再安排下周计划",
|
||||||
|
parallel_worthiness=worthiness,
|
||||||
|
task_graph=task_graph,
|
||||||
|
recommended_runtime_mode="collaboration",
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = render_runtime_request_context_summary(context)
|
||||||
|
|
||||||
|
assert "任务图" in summary
|
||||||
|
assert "max_parallelism" in summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_request_context_summary_renders_assembly_metrics():
|
||||||
|
context = RuntimeRequestContext(
|
||||||
|
user_id="u1",
|
||||||
|
session_id="c1",
|
||||||
|
query_text="帮我分析一下资料",
|
||||||
|
assembly_metrics={"total_ms": 12.3},
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = render_runtime_request_context_summary(context)
|
||||||
|
|
||||||
|
assert "上下文装配耗时" in summary
|
||||||
@@ -503,7 +503,9 @@ async def test_visibility_tools_returns_governance_metadata_and_usage_counts(vis
|
|||||||
payload = response.json()
|
payload = response.json()
|
||||||
assert payload['total_tools'] >= 1
|
assert payload['total_tools'] >= 1
|
||||||
assert payload['used_tools'] >= 1
|
assert payload['used_tools'] >= 1
|
||||||
search_tool = next(item for item in payload['items'] if item['tool_name'] == 'search_web')
|
search_tool = next(
|
||||||
|
item for item in payload['items'] if item['tool_name'] in {'search_web', 'web_search'}
|
||||||
|
)
|
||||||
assert search_tool['permission_class'] == 'external'
|
assert search_tool['permission_class'] == 'external'
|
||||||
assert search_tool['side_effect_scope'] == 'network'
|
assert search_tool['side_effect_scope'] == 'network'
|
||||||
assert search_tool['usage_count'] == 1
|
assert search_tool['usage_count'] == 1
|
||||||
@@ -516,6 +518,26 @@ async def test_visibility_tools_returns_governance_metadata_and_usage_counts(vis
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visibility_debug_returns_observability_and_learning_views(visibility_env):
|
||||||
|
app, ids = visibility_env
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(
|
||||||
|
'/api/agents/visibility/debug',
|
||||||
|
params={'conversation_id': ids['conversation_id']},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload['conversation_id'] == ids['conversation_id']
|
||||||
|
assert 'observability' in payload
|
||||||
|
assert 'skill_shortlist' in payload
|
||||||
|
assert 'retrospective_shortlist' in payload
|
||||||
|
assert 'recent_retrospectives' in payload
|
||||||
|
assert 'recent_learning_artifacts' in payload
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_visibility_events_reject_invalid_datetime(visibility_env):
|
async def test_visibility_events_reject_invalid_datetime(visibility_env):
|
||||||
app, ids = visibility_env
|
app, ids = visibility_env
|
||||||
|
|||||||
@@ -73,3 +73,25 @@ async def test_list_conversations_succeeds_when_agent_state_column_was_missing(c
|
|||||||
assert len(payload) == 1
|
assert len(payload) == 1
|
||||||
assert payload[0]['title'] == 'Existing conversation'
|
assert payload[0]['title'] == 'Existing conversation'
|
||||||
assert payload[0]['message_count'] == 3
|
assert payload[0]['message_count'] == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_stream_emits_error_event_when_agent_service_fails_before_stream_starts(
|
||||||
|
conversation_env,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
async def fail_chat(*args, **kwargs):
|
||||||
|
raise RuntimeError('stream boot failed')
|
||||||
|
|
||||||
|
monkeypatch.setattr('app.routers.conversation.AgentService.chat', fail_chat)
|
||||||
|
|
||||||
|
transport = ASGITransport(app=conversation_env)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.post(
|
||||||
|
'/api/conversations/chat/stream',
|
||||||
|
json={'message': 'hello'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'event: error' in response.text
|
||||||
|
assert 'stream boot failed' in response.text
|
||||||
|
|||||||
115
backend/tests/backend/app/test_database_schema_bootstrap.py
Normal file
115
backend/tests/backend/app/test_database_schema_bootstrap.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
from app.database import ensure_learning_artifact_tables, ensure_memory_columns, ensure_skill_columns
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_ensure_memory_columns_adds_importance_tracking_fields_for_existing_user_memories_table(tmp_path):
|
||||||
|
db_path = tmp_path / 'test_user_memories.db'
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(
|
||||||
|
'''
|
||||||
|
CREATE TABLE user_memories (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(36) NOT NULL,
|
||||||
|
memory_type VARCHAR(50) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
importance INTEGER,
|
||||||
|
is_recalled BOOLEAN,
|
||||||
|
recall_count INTEGER,
|
||||||
|
source_conversation_id VARCHAR(36),
|
||||||
|
extracted_at DATETIME,
|
||||||
|
last_recalled_at DATETIME,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
)
|
||||||
|
'''
|
||||||
|
))
|
||||||
|
result = await conn.execute(text("PRAGMA table_info(user_memories)"))
|
||||||
|
columns_before = {row[1] for row in result.fetchall()}
|
||||||
|
assert 'frequency_count' not in columns_before
|
||||||
|
assert 'importance_score' not in columns_before
|
||||||
|
assert 'decay_score' not in columns_before
|
||||||
|
|
||||||
|
await ensure_memory_columns(conn)
|
||||||
|
|
||||||
|
result = await conn.execute(text("PRAGMA table_info(user_memories)"))
|
||||||
|
columns_after = {row[1] for row in result.fetchall()}
|
||||||
|
assert 'frequency_count' in columns_after
|
||||||
|
assert 'emotion_tags' in columns_after
|
||||||
|
assert 'importance_score' in columns_after
|
||||||
|
assert 'importance_level' in columns_after
|
||||||
|
assert 'associated_topics' in columns_after
|
||||||
|
assert 'decay_score' in columns_after
|
||||||
|
assert 'is_archived' in columns_after
|
||||||
|
assert 'last_accessed_at' in columns_after
|
||||||
|
assert 'archive_at' in columns_after
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_ensure_skill_columns_adds_lifecycle_fields_for_existing_skills_table(tmp_path):
|
||||||
|
db_path = tmp_path / 'test_skills.db'
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(
|
||||||
|
'''
|
||||||
|
CREATE TABLE skills (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
instructions TEXT NOT NULL,
|
||||||
|
agent_type VARCHAR(50) NOT NULL,
|
||||||
|
visibility VARCHAR(20),
|
||||||
|
is_active BOOLEAN,
|
||||||
|
owner_id VARCHAR(36),
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
)
|
||||||
|
'''
|
||||||
|
))
|
||||||
|
result = await conn.execute(text("PRAGMA table_info(skills)"))
|
||||||
|
columns_before = {row[1] for row in result.fetchall()}
|
||||||
|
assert 'status' not in columns_before
|
||||||
|
assert 'effectiveness' not in columns_before
|
||||||
|
|
||||||
|
await ensure_skill_columns(conn)
|
||||||
|
|
||||||
|
result = await conn.execute(text("PRAGMA table_info(skills)"))
|
||||||
|
columns_after = {row[1] for row in result.fetchall()}
|
||||||
|
assert 'status' in columns_after
|
||||||
|
assert 'scope' in columns_after
|
||||||
|
assert 'effectiveness' in columns_after
|
||||||
|
assert 'review_after' in columns_after
|
||||||
|
assert 'activation_count' in columns_after
|
||||||
|
assert 'last_activated_at' in columns_after
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_ensure_learning_artifact_tables_creates_table_and_indexes(tmp_path):
|
||||||
|
db_path = tmp_path / 'test_learning_artifacts.db'
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await ensure_learning_artifact_tables(conn)
|
||||||
|
result = await conn.execute(text("PRAGMA table_info(learning_artifacts)"))
|
||||||
|
columns = {row[1] for row in result.fetchall()}
|
||||||
|
assert 'artifact_type' in columns
|
||||||
|
assert 'artifact_key' in columns
|
||||||
|
assert 'summary_text' in columns
|
||||||
|
assert 'payload' in columns
|
||||||
|
|
||||||
|
indexes = await conn.execute(text("PRAGMA index_list(learning_artifacts)"))
|
||||||
|
index_names = {row[1] for row in indexes.fetchall()}
|
||||||
|
assert 'ix_learning_artifacts_user_id' in index_names
|
||||||
|
assert 'ix_learning_artifacts_artifact_type' in index_names
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
@@ -54,6 +54,9 @@ async def skill_env(tmp_path, monkeypatch):
|
|||||||
required_context=[],
|
required_context=[],
|
||||||
visibility='private',
|
visibility='private',
|
||||||
is_active=True,
|
is_active=True,
|
||||||
|
status='active',
|
||||||
|
scope=['schedule_planner'],
|
||||||
|
effectiveness=0.88,
|
||||||
owner_id=user.id,
|
owner_id=user.id,
|
||||||
),
|
),
|
||||||
Skill(
|
Skill(
|
||||||
@@ -65,6 +68,9 @@ async def skill_env(tmp_path, monkeypatch):
|
|||||||
required_context=[],
|
required_context=[],
|
||||||
visibility='private',
|
visibility='private',
|
||||||
is_active=True,
|
is_active=True,
|
||||||
|
status='shadow',
|
||||||
|
scope=['executor'],
|
||||||
|
effectiveness=0.41,
|
||||||
owner_id=user.id,
|
owner_id=user.id,
|
||||||
),
|
),
|
||||||
Skill(
|
Skill(
|
||||||
@@ -76,6 +82,8 @@ async def skill_env(tmp_path, monkeypatch):
|
|||||||
required_context=[],
|
required_context=[],
|
||||||
visibility='private',
|
visibility='private',
|
||||||
is_active=True,
|
is_active=True,
|
||||||
|
status='active',
|
||||||
|
scope=['schedule_planner'],
|
||||||
owner_id=other_user.id,
|
owner_id=other_user.id,
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
@@ -188,3 +196,9 @@ async def test_list_skills_without_agent_type_returns_current_user_skills(skill_
|
|||||||
assert all(isinstance(item['updated_at'], str) for item in payload)
|
assert all(isinstance(item['updated_at'], str) for item in payload)
|
||||||
assert all('is_builtin' in item for item in payload)
|
assert all('is_builtin' in item for item in payload)
|
||||||
assert all(item['is_builtin'] is False for item in payload)
|
assert all(item['is_builtin'] is False for item in payload)
|
||||||
|
assert all('status' in item for item in payload)
|
||||||
|
assert all('scope' in item for item in payload)
|
||||||
|
assert any(item['status'] == 'shadow' for item in payload)
|
||||||
|
executor = next(item for item in payload if item['name'] == 'Executor skill')
|
||||||
|
assert executor['scope'] == ['executor']
|
||||||
|
assert executor['effectiveness'] == 0.41
|
||||||
|
|||||||
130
backend/tests/backend/app/test_system_router.py
Normal file
130
backend/tests/backend/app/test_system_router.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_config_returns_location_and_weather(monkeypatch):
|
||||||
|
async def fake_get_config(self):
|
||||||
|
return {
|
||||||
|
'location': 'wuhan',
|
||||||
|
'weather_code': 3,
|
||||||
|
'weather_summary': 'Overcast 22°C',
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr('app.routers.system.SystemService.get_config', fake_get_config)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get('/api/system/config')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
'location': 'wuhan',
|
||||||
|
'weather_code': 3,
|
||||||
|
'weather_summary': 'Overcast 22°C',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_config_gracefully_returns_unavailable_weather(monkeypatch):
|
||||||
|
async def fake_get_config(self):
|
||||||
|
return {
|
||||||
|
'location': 'wuhan',
|
||||||
|
'weather_code': None,
|
||||||
|
'weather_summary': 'Weather unavailable',
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr('app.routers.system.SystemService.get_config', fake_get_config)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get('/api/system/config')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
'location': 'wuhan',
|
||||||
|
'weather_code': None,
|
||||||
|
'weather_summary': 'Weather unavailable',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWeatherResponse:
|
||||||
|
def __init__(self, payload: dict, status_code: int = 200):
|
||||||
|
self._payload = payload
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
if self.status_code >= 400:
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
'request failed',
|
||||||
|
request=httpx.Request('GET', 'https://wttr.in/wuhan?format=j1'),
|
||||||
|
response=httpx.Response(self.status_code, request=httpx.Request('GET', 'https://wttr.in/wuhan?format=j1')),
|
||||||
|
)
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAsyncClient:
|
||||||
|
def __init__(self, *, response=None, error=None, **kwargs):
|
||||||
|
self._response = response
|
||||||
|
self._error = error
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get(self, url, *, params=None):
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
return self._response
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_service_get_config_fetches_weather(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
'app.services.system_service.httpx.AsyncClient',
|
||||||
|
lambda **kwargs: FakeAsyncClient(
|
||||||
|
response=FakeWeatherResponse({'current_condition': [{'weatherCode': '61', 'temp_C': '18'}]}),
|
||||||
|
**kwargs,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.services.system_service import SystemService
|
||||||
|
|
||||||
|
service = SystemService()
|
||||||
|
monkeypatch.setattr(service._settings, 'LOCATION', 'wuhan')
|
||||||
|
|
||||||
|
payload = await service.get_config()
|
||||||
|
|
||||||
|
assert payload == {
|
||||||
|
'location': 'wuhan',
|
||||||
|
'weather_code': 61,
|
||||||
|
'weather_summary': 'Rain 18°C',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_service_get_config_handles_weather_failure(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
'app.services.system_service.httpx.AsyncClient',
|
||||||
|
lambda **kwargs: FakeAsyncClient(error=httpx.TimeoutException('timed out'), **kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.services.system_service import SystemService
|
||||||
|
|
||||||
|
service = SystemService()
|
||||||
|
monkeypatch.setattr(service._settings, 'LOCATION', 'wuhan')
|
||||||
|
|
||||||
|
payload = await service.get_config()
|
||||||
|
|
||||||
|
assert payload == {
|
||||||
|
'location': 'wuhan',
|
||||||
|
'weather_code': None,
|
||||||
|
'weather_summary': 'Weather unavailable',
|
||||||
|
}
|
||||||
BIN
data/jarvis.db
BIN
data/jarvis.db
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
VITE_API_URL=http://127.0.0.1:3337
|
|
||||||
@@ -101,7 +101,8 @@ export const conversationApi = {
|
|||||||
handlers: ChatStreamHandlers = {},
|
handlers: ChatStreamHandlers = {},
|
||||||
) {
|
) {
|
||||||
const token = localStorage.getItem('access_token')
|
const token = localStorage.getItem('access_token')
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/conversations/chat/stream`, {
|
const baseURL = import.meta.env.DEV ? '' : import.meta.env.VITE_API_URL
|
||||||
|
const response = await fetch(`${baseURL}/api/conversations/chat/stream`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -13,17 +13,53 @@ export function useClientTime() {
|
|||||||
const weatherSummary = ref('Weather unavailable')
|
const weatherSummary = ref('Weather unavailable')
|
||||||
const weatherCode = ref<number | null>(null)
|
const weatherCode = ref<number | null>(null)
|
||||||
let clientTimeTimer: ReturnType<typeof setInterval> | null = null
|
let clientTimeTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
const WEATHER_CACHE_KEY = 'jarvis:clientWeather'
|
||||||
|
|
||||||
// Load location from backend config
|
function loadWeatherCache() {
|
||||||
async function loadLocation() {
|
if (typeof window === 'undefined') return
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(WEATHER_CACHE_KEY)
|
||||||
|
if (!raw) return
|
||||||
|
const parsed = JSON.parse(raw) as { city?: string; weather_summary?: string; weather_code?: number | null; cached_at?: number }
|
||||||
|
if (typeof parsed.city === 'string' && parsed.city.trim()) city.value = parsed.city
|
||||||
|
if (typeof parsed.weather_summary === 'string' && parsed.weather_summary.trim()) weatherSummary.value = parsed.weather_summary
|
||||||
|
if (typeof parsed.weather_code === 'number') weatherCode.value = parsed.weather_code
|
||||||
|
} catch {
|
||||||
|
// ignore cache parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveWeatherCache(payload: { city: string; weather_summary: string; weather_code: number | null }) {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(WEATHER_CACHE_KEY, JSON.stringify({ ...payload, cached_at: Date.now() }))
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read cache synchronously during setup to avoid initial render flicker (icon showing as X/na).
|
||||||
|
loadWeatherCache()
|
||||||
|
|
||||||
|
async function loadSystemConfig() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/system/config')
|
const response = await fetch('/api/system/config')
|
||||||
if (response.ok) {
|
if (!response.ok) throw new Error('system config request failed')
|
||||||
const config = await response.json()
|
const config = await response.json()
|
||||||
city.value = config.location || 'Location'
|
city.value = typeof config.location === 'string' && config.location.trim() ? config.location : 'Location'
|
||||||
}
|
weatherCode.value = typeof config.weather_code === 'number' ? config.weather_code : null
|
||||||
|
weatherSummary.value = typeof config.weather_summary === 'string' && config.weather_summary.trim()
|
||||||
|
? config.weather_summary
|
||||||
|
: 'Weather unavailable'
|
||||||
|
saveWeatherCache({ city: city.value, weather_summary: weatherSummary.value, weather_code: weatherCode.value })
|
||||||
} catch {
|
} catch {
|
||||||
city.value = import.meta.env.VITE_LOCATION || 'Location'
|
// If we already have cached weather on screen, keep it to avoid UI flicker (icon showing as "X"/na).
|
||||||
|
const hasExistingWeather = weatherCode.value !== null || (weatherSummary.value && weatherSummary.value !== 'Weather unavailable')
|
||||||
|
if (!hasExistingWeather) {
|
||||||
|
city.value = 'Location'
|
||||||
|
weatherCode.value = null
|
||||||
|
weatherSummary.value = 'Weather unavailable'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +76,27 @@ export function useClientTime() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function weatherCodeLabel(code: number | null | undefined) {
|
function weatherCodeLabel(code: number | null | undefined) {
|
||||||
|
// Backend may return wttr.in condition codes (e.g. 113/116/119...), normalize to user-friendly labels.
|
||||||
|
if (typeof code === 'number' && code > 99) {
|
||||||
|
if (code === 113) return 'Clear'
|
||||||
|
if (code === 116) return 'Partly Cloudy'
|
||||||
|
if (code === 119 || code === 122) return 'Overcast'
|
||||||
|
if (code === 143) return 'Fog'
|
||||||
|
if ([200, 386, 389].includes(code)) return 'Thunderstorm'
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
176, 263, 266, 281, 293, 296, 299, 302, 305, 308,
|
||||||
|
311, 314, 317, 350, 353, 356, 359, 362,
|
||||||
|
].includes(code)
|
||||||
|
) return 'Rain'
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
179, 182, 185, 227, 230, 323, 326, 329, 332, 335,
|
||||||
|
338, 368, 371, 374, 377, 392, 395,
|
||||||
|
].includes(code)
|
||||||
|
) return 'Snow'
|
||||||
|
return 'Weather'
|
||||||
|
}
|
||||||
if (code === 0) return 'Clear'
|
if (code === 0) return 'Clear'
|
||||||
if (code === 1 || code === 2) return 'Partly Cloudy'
|
if (code === 1 || code === 2) return 'Partly Cloudy'
|
||||||
if (code === 3) return 'Overcast'
|
if (code === 3) return 'Overcast'
|
||||||
@@ -48,11 +105,34 @@ export function useClientTime() {
|
|||||||
if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code ?? -1)) return 'Rain'
|
if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code ?? -1)) return 'Rain'
|
||||||
if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return 'Snow'
|
if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return 'Snow'
|
||||||
if ([95, 96, 99].includes(code ?? -1)) return 'Thunderstorm'
|
if ([95, 96, 99].includes(code ?? -1)) return 'Thunderstorm'
|
||||||
return 'Weather'
|
return 'Weather unavailable'
|
||||||
}
|
}
|
||||||
|
|
||||||
const weatherIcon = computed(() => {
|
const weatherIcon = computed(() => {
|
||||||
const code = weatherCode.value
|
const code = weatherCode.value
|
||||||
|
if (code === null || code === undefined) return ''
|
||||||
|
// Support wttr.in weather codes (commonly 113/116/119/122/143/...).
|
||||||
|
if (typeof code === 'number' && code > 99) {
|
||||||
|
if (code === 113) return 'wi-day-sunny'
|
||||||
|
if (code === 116) return 'wi-day-cloudy'
|
||||||
|
if (code === 119) return 'wi-cloudy'
|
||||||
|
if (code === 122) return 'wi-cloudy'
|
||||||
|
if (code === 143) return 'wi-fog'
|
||||||
|
if ([200, 386, 389].includes(code)) return 'wi-thunderstorm'
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
176, 263, 266, 281, 293, 296, 299, 302, 305, 308,
|
||||||
|
311, 314, 317, 350, 353, 356, 359, 362,
|
||||||
|
].includes(code)
|
||||||
|
) return 'wi-rain'
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
179, 182, 185, 227, 230, 323, 326, 329, 332, 335,
|
||||||
|
338, 368, 371, 374, 377, 392, 395,
|
||||||
|
].includes(code)
|
||||||
|
) return 'wi-snow'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
if (code === 0) return 'wi-day-sunny'
|
if (code === 0) return 'wi-day-sunny'
|
||||||
if (code === 1) return 'wi-day-cloudy'
|
if (code === 1) return 'wi-day-cloudy'
|
||||||
if (code === 2) return 'wi-day-cloudy-gusts'
|
if (code === 2) return 'wi-day-cloudy-gusts'
|
||||||
@@ -65,56 +145,13 @@ export function useClientTime() {
|
|||||||
if ([66, 67, 81, 82].includes(code ?? -1)) return 'wi-rain'
|
if ([66, 67, 81, 82].includes(code ?? -1)) return 'wi-rain'
|
||||||
if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return 'wi-snow'
|
if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return 'wi-snow'
|
||||||
if ([95, 96, 99].includes(code ?? -1)) return 'wi-thunderstorm'
|
if ([95, 96, 99].includes(code ?? -1)) return 'wi-thunderstorm'
|
||||||
return 'wi-day-sunny'
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadWeather(latitude: number, longitude: number) {
|
onMounted(async () => {
|
||||||
try {
|
|
||||||
// Fetch weather data
|
|
||||||
const weatherResp = await fetch(
|
|
||||||
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m,weather_code&timezone=auto`,
|
|
||||||
)
|
|
||||||
if (!weatherResp.ok) throw new Error('weather request failed')
|
|
||||||
const weatherData = await weatherResp.json()
|
|
||||||
const current = weatherData.current ?? {}
|
|
||||||
weatherCode.value = typeof current.weather_code === 'number' ? current.weather_code : null
|
|
||||||
const temp = typeof current.temperature_2m === 'number' ? `${Math.round(current.temperature_2m)}°C` : '--'
|
|
||||||
weatherSummary.value = `${weatherCodeLabel(current.weather_code)} ${temp}`
|
|
||||||
|
|
||||||
// Only fetch city name if not already set by config
|
|
||||||
if (city.value === 'Location') {
|
|
||||||
try {
|
|
||||||
const geoResp = await fetch(
|
|
||||||
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=10`,
|
|
||||||
)
|
|
||||||
if (geoResp.ok) {
|
|
||||||
const geoData = await geoResp.json()
|
|
||||||
city.value = geoData.address?.city ?? geoData.address?.town ?? geoData.address?.county ?? geoData.display_name?.split(',')[0] ?? 'Location'
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
city.value = 'Location'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
weatherCode.value = null
|
|
||||||
weatherSummary.value = 'Weather unavailable'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
updateClientTime()
|
updateClientTime()
|
||||||
clientTimeTimer = setInterval(updateClientTime, 1000)
|
clientTimeTimer = setInterval(updateClientTime, 1000)
|
||||||
void loadLocation()
|
await loadSystemConfig()
|
||||||
if (!navigator.geolocation) {
|
|
||||||
weatherCode.value = null
|
|
||||||
weatherSummary.value = 'Weather unavailable'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
|
||||||
(position) => { void loadWeather(position.coords.latitude, position.coords.longitude) },
|
|
||||||
() => { weatherCode.value = null; weatherSummary.value = 'Weather unavailable' },
|
|
||||||
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 },
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -122,7 +159,15 @@ export function useClientTime() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clientTime, city, weatherSummary, weatherCode, weatherIcon,
|
clientTime,
|
||||||
updateClientTime, formatClientDate, formatClientClock, weatherCodeLabel, loadWeather
|
city,
|
||||||
|
weatherSummary,
|
||||||
|
weatherCode,
|
||||||
|
weatherIcon,
|
||||||
|
updateClientTime,
|
||||||
|
formatClientDate,
|
||||||
|
formatClientClock,
|
||||||
|
weatherCodeLabel,
|
||||||
|
loadSystemConfig,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { computed, onMounted, ref, watch, toRef } from 'vue'
|
import { computed, onMounted, ref, watch, type Ref } from 'vue'
|
||||||
import { CornerDownLeft, Database, Sparkles, Sun, ListTodo } from 'lucide-vue-next'
|
import { CornerDownLeft, Database, Sparkles, Sun, ListTodo } from 'lucide-vue-next'
|
||||||
import { scheduleCenterApi, type ScheduleCenterDateResponse, type ScheduleCenterDaySummary } from '@/api/scheduleCenter'
|
import { scheduleCenterApi, type ScheduleCenterDateResponse, type ScheduleCenterDaySummary } from '@/api/scheduleCenter'
|
||||||
import type { Conversation } from '@/api/conversation'
|
import type { Conversation } from '@/api/conversation'
|
||||||
@@ -18,20 +18,24 @@ export const sidebarCollapsedModules = [
|
|||||||
{ id: 'review', label: '复盘', icon: CornerDownLeft },
|
{ id: 'review', label: '复盘', icon: CornerDownLeft },
|
||||||
]
|
]
|
||||||
|
|
||||||
function formatDateKey(date: Date) {
|
export function formatDateKey(date: Date) {
|
||||||
const year = date.getUTCFullYear()
|
const year = date.getFullYear()
|
||||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
const day = String(date.getUTCDate()).padStart(2, '0')
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
return `${year}-${month}-${day}`
|
return `${year}-${month}-${day}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMonthKey(date: Date) {
|
function formatMonthKey(date: Date) {
|
||||||
const year = date.getUTCFullYear()
|
const year = date.getFullYear()
|
||||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
return `${year}-${month}`
|
return `${year}-${month}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn: () => void, conversationsRef: Conversation[] = []) {
|
export function useSidebarPlan(
|
||||||
|
clientTimeRef: { value: Date },
|
||||||
|
loadDailyDigestFn: () => void,
|
||||||
|
conversationsRef: Ref<Conversation[]> | Conversation[] = [],
|
||||||
|
) {
|
||||||
const todayPlanDetail = ref<ScheduleCenterDateResponse | null>(null)
|
const todayPlanDetail = ref<ScheduleCenterDateResponse | null>(null)
|
||||||
const monthPlanDays = ref<ScheduleCenterDaySummary[]>([])
|
const monthPlanDays = ref<ScheduleCenterDaySummary[]>([])
|
||||||
const selectedDate = ref<string | null>(null)
|
const selectedDate = ref<string | null>(null)
|
||||||
@@ -41,9 +45,7 @@ export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn
|
|||||||
const map = new Map<string, boolean>()
|
const map = new Map<string, boolean>()
|
||||||
const conversations = Array.isArray(conversationsRef) ? conversationsRef : (conversationsRef.value ?? [])
|
const conversations = Array.isArray(conversationsRef) ? conversationsRef : (conversationsRef.value ?? [])
|
||||||
conversations.forEach((conv) => {
|
conversations.forEach((conv) => {
|
||||||
const date = new Date(conv.updated_at)
|
map.set(formatDateKey(new Date(conv.updated_at)), true)
|
||||||
const dateKey = formatDateKey(date)
|
|
||||||
map.set(dateKey, true)
|
|
||||||
})
|
})
|
||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
@@ -52,22 +54,22 @@ export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn
|
|||||||
const monthPlanSummaryMap = computed(() => new Map(monthPlanDays.value.map((item) => [item.date, item])))
|
const monthPlanSummaryMap = computed(() => new Map(monthPlanDays.value.map((item) => [item.date, item])))
|
||||||
|
|
||||||
const calendarCells = computed(() => {
|
const calendarCells = computed(() => {
|
||||||
const year = clientTimeRef.value.getUTCFullYear()
|
const year = clientTimeRef.value.getFullYear()
|
||||||
const month = clientTimeRef.value.getUTCMonth()
|
const month = clientTimeRef.value.getMonth()
|
||||||
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
|
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||||
const firstDayOffset = (new Date(Date.UTC(year, month, 1)).getUTCDay() + 6) % 7
|
const firstDayOffset = (new Date(year, month, 1).getDay() + 6) % 7
|
||||||
const today = clientTimeRef.value.getUTCDate()
|
const todayKey = todayDateKey.value
|
||||||
const cells: Array<{ key: string; value: number | null; active: boolean; busy: boolean; selected: boolean; hasConversation: boolean }> = []
|
const cells: Array<{ key: string; value: number | null; active: boolean; busy: boolean; selected: boolean; hasConversation: boolean }> = []
|
||||||
for (let index = 0; index < firstDayOffset; index += 1) {
|
for (let index = 0; index < firstDayOffset; index += 1) {
|
||||||
cells.push({ key: `blank-start-${index}`, value: null, active: false, busy: false, selected: false, hasConversation: false })
|
cells.push({ key: `blank-start-${index}`, value: null, active: false, busy: false, selected: false, hasConversation: false })
|
||||||
}
|
}
|
||||||
for (let day = 1; day <= daysInMonth; day += 1) {
|
for (let day = 1; day <= daysInMonth; day += 1) {
|
||||||
const monthDate = new Date(Date.UTC(year, month, day))
|
const monthDate = new Date(year, month, day)
|
||||||
const dateKey = formatDateKey(monthDate)
|
const dateKey = formatDateKey(monthDate)
|
||||||
const summary = monthPlanSummaryMap.value.get(dateKey)
|
const summary = monthPlanSummaryMap.value.get(dateKey)
|
||||||
const busy = Boolean(summary && (summary.todo_total + summary.task_due_total + summary.goal_total + summary.reminder_total) > 0)
|
const busy = Boolean(summary && (summary.todo_total + summary.task_due_total + summary.goal_total + summary.reminder_total) > 0)
|
||||||
const hasConv = conversationDateMap.value.get(dateKey) || false
|
const hasConv = conversationDateMap.value.get(dateKey) || false
|
||||||
cells.push({ key: dateKey, value: day, active: day === today, busy, selected: dateKey === selectedDate.value, hasConversation: hasConv })
|
cells.push({ key: dateKey, value: day, active: dateKey === todayKey, busy, selected: dateKey === selectedDate.value, hasConversation: hasConv })
|
||||||
}
|
}
|
||||||
while (cells.length % 7 !== 0) {
|
while (cells.length % 7 !== 0) {
|
||||||
cells.push({ key: `blank-end-${cells.length}`, value: null, active: false, busy: false, selected: false, hasConversation: false })
|
cells.push({ key: `blank-end-${cells.length}`, value: null, active: false, busy: false, selected: false, hasConversation: false })
|
||||||
@@ -75,8 +77,8 @@ export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn
|
|||||||
return cells
|
return cells
|
||||||
})
|
})
|
||||||
|
|
||||||
const calendarYear = computed(() => clientTimeRef.value.getUTCFullYear())
|
const calendarYear = computed(() => clientTimeRef.value.getFullYear())
|
||||||
const calendarMonth = computed(() => clientTimeRef.value.getUTCMonth() + 1)
|
const calendarMonth = computed(() => clientTimeRef.value.getMonth() + 1)
|
||||||
|
|
||||||
const todayPlanCounters = computed(() => {
|
const todayPlanCounters = computed(() => {
|
||||||
const detail = todayPlanDetail.value
|
const detail = todayPlanDetail.value
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, toRef } from 'vue'
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Send,
|
Send,
|
||||||
@@ -25,7 +25,7 @@ import { useChatView } from '@/pages/chat/composables/useChatView'
|
|||||||
import { useKnowledgeView } from '@/pages/knowledge/composables/useKnowledgeView'
|
import { useKnowledgeView } from '@/pages/knowledge/composables/useKnowledgeView'
|
||||||
import { useDailyDigest } from '@/pages/chat/composables/useDailyDigest'
|
import { useDailyDigest } from '@/pages/chat/composables/useDailyDigest'
|
||||||
import { useClientTime, formatNetworkRate } from '@/pages/chat/composables/useClientTime'
|
import { useClientTime, formatNetworkRate } from '@/pages/chat/composables/useClientTime'
|
||||||
import { useSidebarPlan } from '@/pages/chat/composables/useSidebarPlan'
|
import { useSidebarPlan, formatDateKey } from '@/pages/chat/composables/useSidebarPlan'
|
||||||
|
|
||||||
// --- Chat view (core messaging logic) ---
|
// --- Chat view (core messaging logic) ---
|
||||||
const {
|
const {
|
||||||
@@ -79,7 +79,7 @@ const {
|
|||||||
sidebarFocusItems, sidebarReviewAchievements, sidebarReviewReflections,
|
sidebarFocusItems, sidebarReviewAchievements, sidebarReviewReflections,
|
||||||
sidebarFeedItems, topbarFeedItems, sidebarCollapsedModules,
|
sidebarFeedItems, topbarFeedItems, sidebarCollapsedModules,
|
||||||
selectedDate, selectCalendarDate
|
selectedDate, selectCalendarDate
|
||||||
} = useSidebarPlan(clientTime, loadDailyDigest, store.conversations)
|
} = useSidebarPlan(clientTime, loadDailyDigest, toRef(store, 'conversations'))
|
||||||
|
|
||||||
// --- Local UI state ---
|
// --- Local UI state ---
|
||||||
const sidebarCollapsed = ref(false)
|
const sidebarCollapsed = ref(false)
|
||||||
@@ -118,27 +118,16 @@ function handleOpenPreview(doc: any) { previewDoc.value = doc }
|
|||||||
function handleCalendarDateSelect(dateKey: string) {
|
function handleCalendarDateSelect(dateKey: string) {
|
||||||
selectCalendarDate(dateKey)
|
selectCalendarDate(dateKey)
|
||||||
|
|
||||||
// Reload conversations to get latest data
|
|
||||||
loadConversations().then(() => {
|
loadConversations().then(() => {
|
||||||
// Find conversation that matches the selected date (by updated_at)
|
const conversations = store.conversations
|
||||||
// Use UTC to avoid timezone issues
|
const conversation = conversations.find((conv: { id: string; updated_at: string }) => formatDateKey(new Date(conv.updated_at)) === dateKey)
|
||||||
const [year, month, day] = dateKey.split('-').map(Number)
|
|
||||||
const targetDateStart = new Date(Date.UTC(year, month - 1, day))
|
|
||||||
const targetDateEnd = new Date(Date.UTC(year, month - 1, day + 1))
|
|
||||||
|
|
||||||
// Find conversation that falls on the selected date
|
|
||||||
const conversations = store.conversations.value ?? store.conversations
|
|
||||||
const conversation = conversations.find((conv) => {
|
|
||||||
const convDate = new Date(conv.updated_at)
|
|
||||||
return convDate >= targetDateStart && convDate < targetDateEnd
|
|
||||||
})
|
|
||||||
|
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
selectConversation(conversation.id)
|
selectConversation(conversation.id)
|
||||||
} else {
|
return
|
||||||
// No conversation for this date, create a new one
|
|
||||||
newConversation()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newConversation()
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
console.error('[Calendar] Error loading conversations:', err)
|
console.error('[Calendar] Error loading conversations:', err)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,44 @@
|
|||||||
import { defineConfig, loadEnv } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import fs from 'fs'
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, __dirname, '')
|
const env = loadEnv(mode, __dirname, '')
|
||||||
|
|
||||||
|
function parseDotenvFile(filePath: string) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) return {}
|
||||||
|
const text = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
for (const rawLine of text.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim()
|
||||||
|
if (!line || line.startsWith('#')) continue
|
||||||
|
const eq = line.indexOf('=')
|
||||||
|
if (eq <= 0) continue
|
||||||
|
const key = line.slice(0, eq).trim()
|
||||||
|
let value = line.slice(eq + 1).trim()
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vite only loads env files under `frontend/` by default.
|
||||||
|
// Many Jarvis setups keep HOST/PORT in repo root `.env`, so we read it explicitly as a fallback.
|
||||||
|
const rootEnvPath = path.resolve(__dirname, '../.env')
|
||||||
|
const rootEnv = parseDotenvFile(rootEnvPath)
|
||||||
|
const rootHost = (rootEnv.HOST || '127.0.0.1').trim()
|
||||||
|
const rootPort = (rootEnv.PORT || '').trim()
|
||||||
|
const rootApi = (rootEnv.VITE_API_URL || (rootPort ? `http://${rootHost}:${rootPort}` : '')).trim()
|
||||||
|
|
||||||
|
const apiTarget = env.VITE_API_URL || rootApi || 'http://localhost:8000'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
resolve: {
|
resolve: {
|
||||||
@@ -15,7 +49,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: env.VITE_API_URL,
|
target: apiTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user