Compare commits
31 Commits
472528e708
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 145c43f09c | |||
| 847d9f96db | |||
| 7f5b133fad | |||
| 21c869db62 | |||
| 1ca8855751 | |||
| d8f8b0c177 | |||
| 7e6eb6a7b3 | |||
| c70e7e7253 | |||
| 39a9058de1 | |||
| ac49c13965 | |||
| 3e39b40a50 | |||
| 8c7cf0732b | |||
| aa12c92a5a | |||
| 51e38e039b | |||
| e637c8ca2f | |||
| 52fb619084 | |||
| dc9051debc | |||
| 74fdfc2652 | |||
| 36c93a764f | |||
| 72a60c698a | |||
| 4ef7549efe | |||
| de08165e07 | |||
| 4702cc8ed2 | |||
| 62bf414ff2 | |||
| 536c541a5b | |||
| 7aef898bf5 | |||
| 721ddbeef9 | |||
| 3bff9b3b93 | |||
| 3cf8762b96 | |||
| 712d9e1652 | |||
| ff042cd932 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -40,6 +40,9 @@ logs/
|
||||
.claude/
|
||||
.worktrees/
|
||||
|
||||
# Demo (excluded from version control)
|
||||
demo/
|
||||
|
||||
# Lock files (use in development, commit in production)
|
||||
# uv.lock - 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,
|
||||
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.runtime_metrics import (
|
||||
coerce_cost_thresholds,
|
||||
@@ -36,6 +39,14 @@ from app.agents.runtime_metrics import (
|
||||
)
|
||||
from app.agents.schemas.event import AgentEvent
|
||||
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 (
|
||||
AgentTask,
|
||||
CollaborationBudget,
|
||||
@@ -44,6 +55,7 @@ from app.agents.schemas.task import (
|
||||
TaskResult,
|
||||
)
|
||||
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.tools import SUB_COMMANDER_TOOLSETS
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
pending_action = state.get("pending_action") 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)
|
||||
|
||||
|
||||
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(
|
||||
state: AgentState, system_prompt: str, role: AgentRole, sub_commander: str
|
||||
) -> list[BaseMessage]:
|
||||
@@ -1214,6 +1341,19 @@ def _build_system_messages(
|
||||
if 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)
|
||||
if continuity_summary:
|
||||
messages.append(SystemMessage(content=continuity_summary))
|
||||
@@ -1226,6 +1366,10 @@ def _build_system_messages(
|
||||
if 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 = {
|
||||
AgentRole.SCHEDULE_PLANNER: state.get("schedule_context_summary"),
|
||||
AgentRole.LIBRARIAN: state.get("knowledge_context"),
|
||||
@@ -1237,7 +1381,11 @@ def _build_system_messages(
|
||||
|
||||
role_skill_key = ROLE_SKILL_CONTEXT.get(role)
|
||||
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:
|
||||
messages.append(SystemMessage(content=skill_context))
|
||||
|
||||
@@ -1322,6 +1470,29 @@ def _build_collaboration_tasks(user_query: str) -> list[AgentTask]:
|
||||
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:
|
||||
if state.get("execution_mode") != "collaboration":
|
||||
return None
|
||||
@@ -2076,7 +2247,36 @@ async def _run_sub_commander(
|
||||
)
|
||||
_record_response_usage(state, response)
|
||||
response_text = _stringify_message_content(response.content)
|
||||
response_text_stripped = response_text.strip()
|
||||
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"):
|
||||
state["fallback_parse_error"] = None
|
||||
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)
|
||||
|
||||
|
||||
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(
|
||||
state: AgentState,
|
||||
tasks: list[AgentTask],
|
||||
@@ -2411,6 +2640,14 @@ def _verify_collaboration_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:
|
||||
summary = f"协作结果不完整,缺少任务结果: {', '.join(missing_task_ids)}"
|
||||
verdict = verify_task_result(
|
||||
@@ -2426,20 +2663,51 @@ def _verify_collaboration_results(
|
||||
verdict = verify_task_result(
|
||||
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:
|
||||
summary = f"协作模式已完成 {len(normalized_results)}/{len(tasks)} 个子任务,并为每个子任务回收了结果与 evidence。"
|
||||
summary = (
|
||||
merge_report.summary
|
||||
or f"协作模式已完成 {len(normalized_results)}/{len(tasks)} 个子任务,并为每个子任务回收了结果与 evidence。"
|
||||
)
|
||||
verdict = verify_task_result(
|
||||
status="passed", summary=summary, evidence=verification_evidence
|
||||
)
|
||||
|
||||
updated_state = apply_verification_verdict(state, verdict)
|
||||
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:
|
||||
_set_phase(state, "phase_2_controlled_collaboration", 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:
|
||||
state["execution_mode"] = "direct"
|
||||
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",
|
||||
payload=budget_snapshot,
|
||||
)
|
||||
state["scheduled_subtasks"] = scheduled_subtasks
|
||||
state["active_tasks"] = [task.model_dump(mode="json") for task in tasks]
|
||||
_record_checkpoint(
|
||||
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")
|
||||
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(
|
||||
state,
|
||||
"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],
|
||||
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)
|
||||
|
||||
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",
|
||||
)
|
||||
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(
|
||||
state,
|
||||
"collaboration.completed",
|
||||
@@ -2679,23 +2994,99 @@ async def master_node(state: AgentState) -> AgentState:
|
||||
return state
|
||||
|
||||
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)
|
||||
clarification_route = _route_from_clarification_context(state, user_query)
|
||||
if structured_continuity_route is not None:
|
||||
state["execution_mode"] = "direct"
|
||||
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:
|
||||
state["execution_mode"] = "direct"
|
||||
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(
|
||||
state.get("messages", [])
|
||||
):
|
||||
state["execution_mode"] = "direct"
|
||||
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:
|
||||
request_mode, routing_metadata = _select_request_mode(user_query)
|
||||
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":
|
||||
collaboration_state = await _run_collaboration_flow(state, user_query)
|
||||
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"""
|
||||
|
||||
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.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 (
|
||||
BackgroundTaskManager,
|
||||
BackgroundTask,
|
||||
@@ -14,7 +23,15 @@ __all__ = [
|
||||
"TaskStatus",
|
||||
"RemoteTransport",
|
||||
"StructuredMessage",
|
||||
"ParallelExecutionScheduler",
|
||||
"build_bounded_task_graph",
|
||||
"build_subtask_budget",
|
||||
"build_subtask_specs",
|
||||
"BackgroundTaskManager",
|
||||
"BackgroundTask",
|
||||
"ensure_child_links",
|
||||
"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.learning import (
|
||||
LearningDecision,
|
||||
LearningSignal,
|
||||
PatternCandidate,
|
||||
SessionRetrospective,
|
||||
SkillCandidate,
|
||||
)
|
||||
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 (
|
||||
AgentTask,
|
||||
CollaborationBudget,
|
||||
@@ -14,12 +33,28 @@ from app.agents.schemas.task import (
|
||||
__all__ = [
|
||||
"AgentEvent",
|
||||
"AgentMessage",
|
||||
"ExecutionDecision",
|
||||
"AgentTask",
|
||||
"CollaborationBudget",
|
||||
"InterruptRecord",
|
||||
"LearningDecision",
|
||||
"LearningSignal",
|
||||
"MergeReport",
|
||||
"ParallelWorthiness",
|
||||
"PatternCandidate",
|
||||
"RecoveryRecord",
|
||||
"RuntimeRequestContext",
|
||||
"SessionRetrospective",
|
||||
"SkillActivationRecord",
|
||||
"SkillCandidate",
|
||||
"SkillShortlistEntry",
|
||||
"SubTaskResult",
|
||||
"SubTaskSpec",
|
||||
"TaskGraph",
|
||||
"TaskNode",
|
||||
"TaskLifecycleStatus",
|
||||
"TaskResult",
|
||||
"TaskResultStatus",
|
||||
"VerificationReport",
|
||||
"VerificationStatus",
|
||||
]
|
||||
|
||||
@@ -7,10 +7,21 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
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.result",
|
||||
"agent.verify.started",
|
||||
"agent.verify.completed",
|
||||
"agent.retrospective.created",
|
||||
"agent.learning.decision",
|
||||
"agent.skill.lifecycle.changed",
|
||||
"agent.rollback.triggered",
|
||||
"agent.created",
|
||||
"agent.spawn.blocked",
|
||||
"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"""
|
||||
|
||||
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",
|
||||
]
|
||||
"""Skill package."""
|
||||
|
||||
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
|
||||
enabled: bool = True # 是否启用
|
||||
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]:
|
||||
return {
|
||||
@@ -35,6 +39,10 @@ class SkillMetadata:
|
||||
"source_id": self.source_id,
|
||||
"enabled": self.enabled,
|
||||
"tools": self.tools,
|
||||
"status": self.status,
|
||||
"scope": self.scope,
|
||||
"effectiveness": self.effectiveness,
|
||||
"review_after": self.review_after,
|
||||
}
|
||||
|
||||
@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
|
||||
current_datetime_context: 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
|
||||
routing_decision: dict[str, Any] | None
|
||||
@@ -254,6 +266,18 @@ def initial_state(user_id: str, conversation_id: str) -> AgentState:
|
||||
memory_context=None,
|
||||
current_datetime_context=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,
|
||||
routing_decision=None,
|
||||
continuity_state=None,
|
||||
|
||||
@@ -61,6 +61,9 @@ class Settings(BaseSettings):
|
||||
DAILY_PLAN_TIME: str = "00:00"
|
||||
FORUM_SCAN_INTERVAL_MINUTES: int = 30
|
||||
|
||||
# === 位置配置 ===
|
||||
LOCATION: str = "Location"
|
||||
|
||||
# === CORS ===
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||
|
||||
@@ -101,6 +104,15 @@ class Settings(BaseSettings):
|
||||
WEB_SEARCH_DEFAULT_LIMIT: int = 5
|
||||
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.DATABASE_URL = settings.DATABASE_URL.replace("./data", _resolve_path("./data"), 1)
|
||||
|
||||
@@ -35,14 +35,206 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await ensure_task_columns(conn)
|
||||
await ensure_log_columns(conn)
|
||||
await ensure_message_columns(conn)
|
||||
await ensure_conversation_columns(conn)
|
||||
await ensure_document_columns(conn)
|
||||
await ensure_memory_columns(conn)
|
||||
await ensure_user_columns(conn)
|
||||
await ensure_forum_columns(conn)
|
||||
await ensure_agent_columns(conn)
|
||||
await ensure_skill_columns(conn)
|
||||
await ensure_learning_artifact_tables(conn)
|
||||
|
||||
|
||||
async def ensure_task_columns(conn):
|
||||
rows = await _get_table_info(conn, 'tasks')
|
||||
if not rows:
|
||||
return
|
||||
|
||||
columns = {row[1] for row in rows}
|
||||
required_columns = {
|
||||
'source': "ALTER TABLE tasks ADD COLUMN source VARCHAR(32) DEFAULT 'manual' NOT NULL",
|
||||
'conversation_id': "ALTER TABLE tasks ADD COLUMN conversation_id VARCHAR(36)",
|
||||
'quadrant': "ALTER TABLE tasks ADD COLUMN quadrant VARCHAR(64)",
|
||||
'assignee_type': "ALTER TABLE tasks ADD COLUMN assignee_type VARCHAR(32)",
|
||||
'assignee_id': "ALTER TABLE tasks ADD COLUMN assignee_id VARCHAR(255)",
|
||||
'dispatch_status': "ALTER TABLE tasks ADD COLUMN dispatch_status VARCHAR(32) DEFAULT 'idle' NOT NULL",
|
||||
'dispatch_run_id': "ALTER TABLE tasks ADD COLUMN dispatch_run_id VARCHAR(64)",
|
||||
'result_summary': "ALTER TABLE tasks ADD COLUMN result_summary TEXT",
|
||||
'started_at': "ALTER TABLE tasks ADD COLUMN started_at DATETIME",
|
||||
'last_synced_at': "ALTER TABLE tasks ADD COLUMN last_synced_at DATETIME",
|
||||
}
|
||||
for column, ddl in required_columns.items():
|
||||
if column not in columns:
|
||||
await conn.execute(text(ddl))
|
||||
|
||||
indexes = {
|
||||
'ix_tasks_due_date': "CREATE INDEX IF NOT EXISTS ix_tasks_due_date ON tasks (due_date)",
|
||||
'ix_tasks_source': "CREATE INDEX IF NOT EXISTS ix_tasks_source ON tasks (source)",
|
||||
'ix_tasks_conversation_id': "CREATE INDEX IF NOT EXISTS ix_tasks_conversation_id ON tasks (conversation_id)",
|
||||
'ix_tasks_quadrant': "CREATE INDEX IF NOT EXISTS ix_tasks_quadrant ON tasks (quadrant)",
|
||||
'ix_tasks_assignee_type': "CREATE INDEX IF NOT EXISTS ix_tasks_assignee_type ON tasks (assignee_type)",
|
||||
'ix_tasks_assignee_id': "CREATE INDEX IF NOT EXISTS ix_tasks_assignee_id ON tasks (assignee_id)",
|
||||
'ix_tasks_dispatch_status': "CREATE INDEX IF NOT EXISTS ix_tasks_dispatch_status ON tasks (dispatch_status)",
|
||||
'ix_tasks_dispatch_run_id': "CREATE INDEX IF NOT EXISTS ix_tasks_dispatch_run_id ON tasks (dispatch_run_id)",
|
||||
}
|
||||
for ddl in indexes.values():
|
||||
await conn.execute(text(ddl))
|
||||
|
||||
history_rows = await _get_table_info(conn, 'task_histories')
|
||||
if history_rows:
|
||||
history_columns = {row[1] for row in history_rows}
|
||||
if 'subtask_id' not in history_columns:
|
||||
await conn.execute(text("ALTER TABLE task_histories ADD COLUMN subtask_id VARCHAR(36)"))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_task_histories_subtask_id ON task_histories (subtask_id)"))
|
||||
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_subtasks (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
task_id VARCHAR(36) NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'todo',
|
||||
order_index INTEGER NOT NULL DEFAULT 0,
|
||||
assignee_type VARCHAR(32),
|
||||
assignee_id VARCHAR(255),
|
||||
dispatch_status VARCHAR(32) NOT NULL DEFAULT 'idle',
|
||||
dispatch_run_id VARCHAR(64),
|
||||
completed_at DATETIME,
|
||||
FOREIGN KEY(task_id) REFERENCES tasks (id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
subtask_rows = await _get_table_info(conn, 'task_subtasks')
|
||||
subtask_columns = {row[1] for row in subtask_rows}
|
||||
if 'result_summary' not in subtask_columns:
|
||||
await conn.execute(text("ALTER TABLE task_subtasks ADD COLUMN result_summary TEXT"))
|
||||
subtask_indexes = {
|
||||
'ix_task_subtasks_task_id': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_task_id ON task_subtasks (task_id)",
|
||||
'ix_task_subtasks_status': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_status ON task_subtasks (status)",
|
||||
'ix_task_subtasks_order_index': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_order_index ON task_subtasks (order_index)",
|
||||
'ix_task_subtasks_assignee_type': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_assignee_type ON task_subtasks (assignee_type)",
|
||||
'ix_task_subtasks_assignee_id': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_assignee_id ON task_subtasks (assignee_id)",
|
||||
'ix_task_subtasks_dispatch_status': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_dispatch_status ON task_subtasks (dispatch_status)",
|
||||
'ix_task_subtasks_dispatch_run_id': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_dispatch_run_id ON task_subtasks (dispatch_run_id)",
|
||||
}
|
||||
for ddl in subtask_indexes.values():
|
||||
await conn.execute(text(ddl))
|
||||
|
||||
# Normalize legacy/invalid enum-like values to prevent ORM Enum decoding failures.
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET source = 'manual'
|
||||
WHERE source IS NULL
|
||||
OR TRIM(source) = ''
|
||||
OR source NOT IN ('manual','chat','schedule_center','today_status','commander')
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status = 'todo'
|
||||
WHERE status IS NULL
|
||||
OR TRIM(status) = ''
|
||||
OR status NOT IN ('todo','in_progress','done','cancelled')
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET priority = 'medium'
|
||||
WHERE priority IS NULL
|
||||
OR TRIM(priority) = ''
|
||||
OR priority NOT IN ('low','medium','high','urgent')
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET quadrant = NULL
|
||||
WHERE quadrant IS NOT NULL
|
||||
AND (TRIM(quadrant) = '' OR quadrant NOT IN (
|
||||
'urgent-important',
|
||||
'not-urgent-important',
|
||||
'urgent-not-important',
|
||||
'not-urgent-not-important'
|
||||
))
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET assignee_type = NULL
|
||||
WHERE assignee_type IS NOT NULL
|
||||
AND (TRIM(assignee_type) = '' OR assignee_type NOT IN (
|
||||
'user','commander','agent','planner','executor','knowledge','analyst','coder','researcher'
|
||||
))
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET dispatch_status = 'idle'
|
||||
WHERE dispatch_status IS NULL
|
||||
OR TRIM(dispatch_status) = ''
|
||||
OR dispatch_status NOT IN ('idle','queued','running','completed','failed')
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE task_subtasks
|
||||
SET status = 'todo'
|
||||
WHERE status IS NULL
|
||||
OR TRIM(status) = ''
|
||||
OR status NOT IN ('todo','in_progress','done','cancelled')
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE task_subtasks
|
||||
SET assignee_type = NULL
|
||||
WHERE assignee_type IS NOT NULL
|
||||
AND (TRIM(assignee_type) = '' OR assignee_type NOT IN (
|
||||
'user','commander','agent','planner','executor','knowledge','analyst','coder','researcher'
|
||||
))
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE task_subtasks
|
||||
SET dispatch_status = 'idle'
|
||||
WHERE dispatch_status IS NULL
|
||||
OR TRIM(dispatch_status) = ''
|
||||
OR dispatch_status NOT IN ('idle','queued','running','completed','failed')
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def ensure_log_columns(conn):
|
||||
@@ -115,6 +307,28 @@ async def ensure_document_columns(conn):
|
||||
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):
|
||||
rows = await _get_table_info(conn, 'users')
|
||||
if not rows:
|
||||
@@ -181,6 +395,14 @@ async def ensure_skill_columns(conn):
|
||||
'output_format': "ALTER TABLE skills ADD COLUMN output_format TEXT",
|
||||
'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)",
|
||||
'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():
|
||||
if column not in columns:
|
||||
@@ -205,6 +427,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):
|
||||
result = await conn.execute(text("SELECT id, email, username FROM users ORDER BY created_at, id"))
|
||||
users = result.fetchall()
|
||||
|
||||
@@ -29,6 +29,9 @@ from app.routers import (
|
||||
agent_skills_router,
|
||||
agent_sessions_router,
|
||||
terminal_router,
|
||||
tools_router,
|
||||
remote_mount_router,
|
||||
office_router,
|
||||
)
|
||||
from app.routers.scheduler import router as scheduler_router
|
||||
from app.services.scheduler_service import start_scheduler, stop_scheduler, get_scheduler_status
|
||||
@@ -129,6 +132,9 @@ app.include_router(marketplace_router)
|
||||
app.include_router(agent_skills_router)
|
||||
app.include_router(agent_sessions_router)
|
||||
app.include_router(terminal_router)
|
||||
app.include_router(tools_router)
|
||||
app.include_router(remote_mount_router)
|
||||
app.include_router(office_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -2,11 +2,22 @@ from app.models.base import Base
|
||||
from app.models.user import User
|
||||
from app.models.folder import Folder
|
||||
from app.models.document import Document, DocumentChunk
|
||||
from app.models.task import Task, TaskHistory
|
||||
from app.models.task import (
|
||||
Task,
|
||||
TaskAssigneeType,
|
||||
TaskDispatchStatus,
|
||||
TaskHistory,
|
||||
TaskPriority,
|
||||
TaskQuadrant,
|
||||
TaskSource,
|
||||
TaskStatus,
|
||||
TaskSubTask,
|
||||
)
|
||||
from app.models.forum import ForumPost, ForumReply
|
||||
from app.models.agent import Agent, AgentMessage
|
||||
from app.models.conversation import Conversation, Message
|
||||
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.brain import (
|
||||
BrainEvent,
|
||||
@@ -20,7 +31,9 @@ from app.models.brain import (
|
||||
from app.models.todo import DailyTodo, TodoSource
|
||||
from app.models.reminder import Reminder, ReminderStatus
|
||||
from app.models.goal import Goal, GoalStatus
|
||||
from app.models.skill import Skill
|
||||
from app.models.log import Log, LogType, LogLevel
|
||||
from app.models.remote_mount import RemoteMount, RemoteSyncItem
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -29,7 +42,14 @@ __all__ = [
|
||||
"Document",
|
||||
"DocumentChunk",
|
||||
"Task",
|
||||
"TaskSubTask",
|
||||
"TaskHistory",
|
||||
"TaskStatus",
|
||||
"TaskPriority",
|
||||
"TaskSource",
|
||||
"TaskQuadrant",
|
||||
"TaskAssigneeType",
|
||||
"TaskDispatchStatus",
|
||||
"ForumPost",
|
||||
"ForumReply",
|
||||
"Agent",
|
||||
@@ -38,6 +58,8 @@ __all__ = [
|
||||
"Message",
|
||||
"KGNode",
|
||||
"KGEdge",
|
||||
"LearningArtifactRecord",
|
||||
"SessionRetrospectiveRecord",
|
||||
"MemorySummary",
|
||||
"UserMemory",
|
||||
"BrainEvent",
|
||||
@@ -53,7 +75,10 @@ __all__ = [
|
||||
"ReminderStatus",
|
||||
"Goal",
|
||||
"GoalStatus",
|
||||
"Skill",
|
||||
"Log",
|
||||
"LogType",
|
||||
"LogLevel",
|
||||
"RemoteMount",
|
||||
"RemoteSyncItem",
|
||||
]
|
||||
|
||||
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)
|
||||
34
backend/app/models/remote_mount.py
Normal file
34
backend/app/models/remote_mount.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, String, Text, UniqueConstraint
|
||||
|
||||
from app.models.base import BaseModel
|
||||
|
||||
|
||||
class RemoteMount(BaseModel):
|
||||
__tablename__ = "remote_mounts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "name", name="uq_remote_mount_user_name"),
|
||||
)
|
||||
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
mount_type = Column(String(32), nullable=False, default="webdav")
|
||||
base_url = Column(String(1000), nullable=False)
|
||||
username = Column(String(255), nullable=True)
|
||||
password_encrypted = Column(Text, nullable=True)
|
||||
root_path = Column(String(1000), nullable=False, default="/")
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
last_sync_at = Column(String(64), nullable=True)
|
||||
|
||||
|
||||
class RemoteSyncItem(BaseModel):
|
||||
__tablename__ = "remote_sync_items"
|
||||
|
||||
mount_id = Column(String(36), ForeignKey("remote_mounts.id"), nullable=False, index=True)
|
||||
remote_path = Column(String(2000), nullable=False)
|
||||
remote_etag = Column(String(512), nullable=True)
|
||||
remote_modified_at = Column(String(128), nullable=True)
|
||||
local_folder_id = Column(String(36), ForeignKey("folders.id"), nullable=True)
|
||||
local_document_id = Column(String(36), ForeignKey("documents.id"), nullable=True)
|
||||
sync_status = Column(String(32), nullable=False, default="synced")
|
||||
last_error = Column(Text, nullable=True)
|
||||
last_synced_at = Column(String(64), nullable=True)
|
||||
@@ -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 app.models.base import BaseModel
|
||||
|
||||
@@ -17,6 +17,14 @@ class Skill(BaseModel):
|
||||
is_builtin = Column(Boolean, default=False, nullable=False)
|
||||
team_id = Column(String(36), ForeignKey("users.id"), nullable=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 = relationship("User", foreign_keys=[owner_id])
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from sqlalchemy import Column, String, Text, Integer, ForeignKey, DateTime, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from enum import Enum as PyEnum
|
||||
|
||||
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.models.base import BaseModel
|
||||
|
||||
|
||||
@@ -19,26 +20,144 @@ class TaskPriority(str, PyEnum):
|
||||
URGENT = "urgent"
|
||||
|
||||
|
||||
class TaskSource(str, PyEnum):
|
||||
MANUAL = "manual"
|
||||
CHAT = "chat"
|
||||
SCHEDULE_CENTER = "schedule_center"
|
||||
TODAY_STATUS = "today_status"
|
||||
COMMANDER = "commander"
|
||||
|
||||
|
||||
class TaskQuadrant(str, PyEnum):
|
||||
URGENT_IMPORTANT = "urgent-important"
|
||||
NOT_URGENT_IMPORTANT = "not-urgent-important"
|
||||
URGENT_NOT_IMPORTANT = "urgent-not-important"
|
||||
NOT_URGENT_NOT_IMPORTANT = "not-urgent-not-important"
|
||||
|
||||
|
||||
class TaskAssigneeType(str, PyEnum):
|
||||
USER = "user"
|
||||
COMMANDER = "commander"
|
||||
AGENT = "agent"
|
||||
PLANNER = "planner"
|
||||
EXECUTOR = "executor"
|
||||
KNOWLEDGE = "knowledge"
|
||||
ANALYST = "analyst"
|
||||
CODER = "coder"
|
||||
RESEARCHER = "researcher"
|
||||
|
||||
|
||||
class TaskDispatchStatus(str, PyEnum):
|
||||
IDLE = "idle"
|
||||
QUEUED = "queued"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
DispatchStatus = TaskDispatchStatus
|
||||
|
||||
|
||||
DispatchStatus = TaskDispatchStatus
|
||||
|
||||
|
||||
class TaskHistoryAction(str, PyEnum):
|
||||
CREATED = "created"
|
||||
CREATED_FROM_CHAT = "created_from_chat"
|
||||
UPDATED = "updated"
|
||||
STATUS_CHANGED = "status_changed"
|
||||
ASSIGNED = "assigned"
|
||||
DELETED = "deleted"
|
||||
SUBTASK_CREATED = "subtask_created"
|
||||
SUBTASK_UPDATED = "subtask_updated"
|
||||
SUBTASK_DELETED = "subtask_deleted"
|
||||
SUBTASK_REORDERED = "subtask_reordered"
|
||||
DISPATCHED_TO_COMMANDER = "dispatched_to_commander"
|
||||
DISPATCH_STATUS_CHANGED = "dispatch_status_changed"
|
||||
|
||||
|
||||
def enum_values(enum_cls: type[PyEnum]) -> list[str]:
|
||||
return [item.value for item in enum_cls]
|
||||
|
||||
|
||||
TASK_STATUS_ENUM = Enum(TaskStatus, values_callable=enum_values)
|
||||
TASK_PRIORITY_ENUM = Enum(TaskPriority, values_callable=enum_values)
|
||||
TASK_SOURCE_ENUM = Enum(TaskSource, values_callable=enum_values)
|
||||
TASK_QUADRANT_ENUM = Enum(TaskQuadrant, values_callable=enum_values)
|
||||
TASK_ASSIGNEE_TYPE_ENUM = Enum(TaskAssigneeType, values_callable=enum_values)
|
||||
TASK_DISPATCH_STATUS_ENUM = Enum(TaskDispatchStatus, values_callable=enum_values)
|
||||
|
||||
|
||||
class Task(BaseModel):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||
title = Column(String(500), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
status = Column(Enum(TaskStatus), default=TaskStatus.TODO, nullable=False, index=True)
|
||||
priority = Column(Enum(TaskPriority), default=TaskPriority.MEDIUM, nullable=False)
|
||||
due_date = Column(DateTime, nullable=True)
|
||||
status = Column(TASK_STATUS_ENUM, default=TaskStatus.TODO, nullable=False, index=True)
|
||||
priority = Column(TASK_PRIORITY_ENUM, default=TaskPriority.MEDIUM, nullable=False)
|
||||
due_date = Column(DateTime, nullable=True, index=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
tags = Column(String(1000), nullable=True) # JSON 数组
|
||||
tags = Column(String(1000), nullable=True) # JSON array
|
||||
source = Column(TASK_SOURCE_ENUM, default=TaskSource.MANUAL, nullable=False, index=True)
|
||||
conversation_id = Column(String(36), nullable=True, index=True)
|
||||
quadrant = Column(TASK_QUADRANT_ENUM, nullable=True, index=True)
|
||||
assignee_type = Column(TASK_ASSIGNEE_TYPE_ENUM, nullable=True, index=True)
|
||||
assignee_id = Column(String(255), nullable=True, index=True)
|
||||
dispatch_status = Column(
|
||||
TASK_DISPATCH_STATUS_ENUM,
|
||||
default=TaskDispatchStatus.IDLE,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
dispatch_run_id = Column(String(64), nullable=True, index=True)
|
||||
result_summary = Column(Text, nullable=True)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
last_synced_at = Column(DateTime, nullable=True)
|
||||
|
||||
history = relationship("TaskHistory", back_populates="task", cascade="all, delete-orphan")
|
||||
subtasks = relationship(
|
||||
"TaskSubTask",
|
||||
back_populates="task",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="TaskSubTask.order_index.asc()",
|
||||
)
|
||||
history = relationship(
|
||||
"TaskHistory",
|
||||
back_populates="task",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="TaskHistory.created_at.desc()",
|
||||
)
|
||||
|
||||
|
||||
class TaskSubTask(BaseModel):
|
||||
__tablename__ = "task_subtasks"
|
||||
|
||||
task_id = Column(String(36), ForeignKey("tasks.id"), nullable=False, index=True)
|
||||
title = Column(String(500), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
status = Column(TASK_STATUS_ENUM, default=TaskStatus.TODO, nullable=False, index=True)
|
||||
order_index = Column(Integer, default=0, nullable=False, index=True)
|
||||
assignee_type = Column(TASK_ASSIGNEE_TYPE_ENUM, nullable=True, index=True)
|
||||
assignee_id = Column(String(255), nullable=True, index=True)
|
||||
dispatch_status = Column(
|
||||
TASK_DISPATCH_STATUS_ENUM,
|
||||
default=TaskDispatchStatus.IDLE,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
dispatch_run_id = Column(String(64), nullable=True, index=True)
|
||||
result_summary = Column(Text, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
task = relationship("Task", back_populates="subtasks")
|
||||
|
||||
|
||||
class TaskHistory(BaseModel):
|
||||
__tablename__ = "task_histories"
|
||||
|
||||
task_id = Column(String(36), ForeignKey("tasks.id"), nullable=False, index=True)
|
||||
action = Column(String(100), nullable=False) # created, status_changed, updated, deleted
|
||||
subtask_id = Column(String(36), ForeignKey("task_subtasks.id"), nullable=True, index=True)
|
||||
action = Column(String(100), nullable=False)
|
||||
old_value = Column(Text, nullable=True)
|
||||
new_value = Column(Text, nullable=True)
|
||||
|
||||
|
||||
@@ -21,3 +21,6 @@ from app.routers.plugins import _marketplace_router as marketplace_router
|
||||
from app.routers.agent_skills import router as agent_skills_router
|
||||
from app.routers.agent_sessions import router as agent_sessions_router
|
||||
from app.routers.terminal import router as terminal_router
|
||||
from app.routers.tools import router as tools_router
|
||||
from app.routers.remote_mount import router as remote_mount_router
|
||||
from app.routers.office import router as office_router
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.runtime_metrics import coerce_cost_thresholds, estimate_token_cost, is_cost_budget_warning
|
||||
from app.models.agent import Agent
|
||||
@@ -37,6 +38,7 @@ from app.schemas.agent import (
|
||||
AgentVisibilityVerifierOut,
|
||||
)
|
||||
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"])
|
||||
|
||||
@@ -662,6 +664,59 @@ async def get_visibility_tools(
|
||||
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)
|
||||
async def create_agent(
|
||||
data: AgentCreate,
|
||||
|
||||
@@ -100,6 +100,7 @@ async def chat(
|
||||
conversation_id=data.conversation_id,
|
||||
file_ids=data.file_ids,
|
||||
model_name=data.model_name,
|
||||
runtime=data.runtime,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
@@ -115,7 +116,7 @@ async def chat(
|
||||
conversation_id=conv_id,
|
||||
message_id=msg_id,
|
||||
content=content,
|
||||
agent_name="jarvis",
|
||||
agent_name=data.runtime or "jarvis",
|
||||
model_name=model_name,
|
||||
)
|
||||
|
||||
@@ -141,10 +142,14 @@ async def chat_stream(
|
||||
conversation_id=data.conversation_id,
|
||||
file_ids=data.file_ids,
|
||||
model_name=data.model_name,
|
||||
runtime=data.runtime,
|
||||
)
|
||||
except ValueError as exc:
|
||||
yield f"event: error\ndata: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n"
|
||||
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"
|
||||
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from typing import List
|
||||
import shutil
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.folder import Folder
|
||||
from app.models.user import User
|
||||
from app.schemas.folder import FolderCreate, FolderUpdate, FolderOut, FolderTreeOut
|
||||
from app.routers.auth import get_current_user
|
||||
from app.schemas.folder import FolderCreate, FolderOut, FolderTreeOut, FolderUpdate
|
||||
from app.services.document_service import DocumentService
|
||||
|
||||
router = APIRouter(prefix="/api/folders", tags=["文件夹"])
|
||||
|
||||
|
||||
def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[FolderTreeOut]:
|
||||
"""递归构建文件夹树"""
|
||||
tree = []
|
||||
for folder in folders:
|
||||
if folder.parent_id == parent_id:
|
||||
@@ -20,30 +23,29 @@ def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[Fold
|
||||
id=folder.id,
|
||||
name=folder.name,
|
||||
parent_id=folder.parent_id,
|
||||
children=children
|
||||
children=children,
|
||||
))
|
||||
return tree
|
||||
|
||||
|
||||
@router.get("", response_model=List[FolderTreeOut])
|
||||
async def get_folders(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取用户的完整文件夹树"""
|
||||
result = await db.execute(
|
||||
select(Folder).where(Folder.user_id == current_user.id)
|
||||
)
|
||||
folders = result.scalars().all()
|
||||
return build_folder_tree(list(folders))
|
||||
|
||||
|
||||
@router.post("", response_model=FolderOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_folder(
|
||||
folder_data: FolderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""创建文件夹"""
|
||||
# 验证父文件夹存在且属于当前用户
|
||||
if folder_data.parent_id:
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
@@ -53,13 +55,12 @@ async def create_folder(
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="父文件夹不存在")
|
||||
|
||||
# 检查同名文件夹
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
and_(
|
||||
Folder.user_id == current_user.id,
|
||||
Folder.parent_id == folder_data.parent_id,
|
||||
Folder.name == folder_data.name
|
||||
Folder.name == folder_data.name,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -69,21 +70,24 @@ async def create_folder(
|
||||
folder = Folder(
|
||||
user_id=current_user.id,
|
||||
name=folder_data.name,
|
||||
parent_id=folder_data.parent_id
|
||||
parent_id=folder_data.parent_id,
|
||||
)
|
||||
db.add(folder)
|
||||
await db.commit()
|
||||
await db.refresh(folder)
|
||||
|
||||
document_service = DocumentService(db, current_user.id)
|
||||
await document_service.ensure_folder_directory(current_user.id, folder.id)
|
||||
return folder
|
||||
|
||||
|
||||
@router.put("/{folder_id}", response_model=FolderOut)
|
||||
async def rename_folder(
|
||||
folder_id: str,
|
||||
folder_data: FolderUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""重命名文件夹"""
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
and_(Folder.id == folder_id, Folder.user_id == current_user.id)
|
||||
@@ -93,18 +97,22 @@ async def rename_folder(
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||
|
||||
old_name = folder.name
|
||||
folder.name = folder_data.name
|
||||
|
||||
document_service = DocumentService(db, current_user.id)
|
||||
await document_service.rename_folder_directory(current_user.id, folder.id, old_name, folder_data.name)
|
||||
await db.commit()
|
||||
await db.refresh(folder)
|
||||
return folder
|
||||
|
||||
|
||||
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_folder(
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""删除文件夹(级联删除文档)"""
|
||||
from app.models.document import Document
|
||||
from app.services.knowledge_service import KnowledgeService
|
||||
|
||||
@@ -117,15 +125,16 @@ async def delete_folder(
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||
|
||||
document_service = DocumentService(db, current_user.id)
|
||||
folder_path = await document_service._get_storage_directory(current_user.id, folder_id)
|
||||
|
||||
async def delete_recursive(fid: str):
|
||||
# 删除子文件夹(先递归)
|
||||
children = await db.execute(
|
||||
select(Folder).where(Folder.parent_id == fid)
|
||||
)
|
||||
for child in children.scalars():
|
||||
await delete_recursive(child.id)
|
||||
|
||||
# 删除文档
|
||||
docs = await db.execute(
|
||||
select(Document).where(Document.folder_id == fid)
|
||||
)
|
||||
@@ -134,10 +143,12 @@ async def delete_folder(
|
||||
await knowledge_service.delete_from_vectorstore(current_user.id, doc.id)
|
||||
await db.delete(doc)
|
||||
|
||||
# 删除文件夹本身
|
||||
folder_to_delete = await db.get(Folder, fid)
|
||||
if folder_to_delete:
|
||||
await db.delete(folder_to_delete)
|
||||
|
||||
await delete_recursive(folder_id)
|
||||
await db.commit()
|
||||
|
||||
if folder_path.exists():
|
||||
shutil.rmtree(folder_path, ignore_errors=True)
|
||||
|
||||
179
backend/app/routers/office.py
Normal file
179
backend/app/routers/office.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Office Status API - Star Office style visualization for Jarvis agents."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Literal
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/api/office", tags=["office"])
|
||||
|
||||
# ============================================================================
|
||||
# State Definitions (mapped to spaceship areas)
|
||||
# ============================================================================
|
||||
# idle → Rest Bay (breakroom)
|
||||
# writing/researching/executing → Command Console (writing)
|
||||
# syncing → Server Room (syncing)
|
||||
# error → Repair Bay (error)
|
||||
|
||||
SHIP_AREAS = {
|
||||
"breakroom": {"x": 200, "y": 300}, # Rest Bay - bottom left
|
||||
"writing": {"x": 640, "y": 200}, # Command Console - center top
|
||||
"server": {"x": 640, "y": 400}, # Server Room - center bottom
|
||||
"error": {"x": 1000, "y": 300}, # Repair Bay - right side
|
||||
}
|
||||
|
||||
STATES = {
|
||||
"idle": {"name": "待命", "area": "breakroom"},
|
||||
"writing": {"name": "执行中", "area": "writing"},
|
||||
"researching": {"name": "研究中", "area": "writing"},
|
||||
"executing": {"name": "执行中", "area": "writing"},
|
||||
"syncing": {"name": "同步中", "area": "server"},
|
||||
"error": {"name": "故障中", "area": "error"},
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Data Models
|
||||
# ============================================================================
|
||||
class AgentState(BaseModel):
|
||||
agent_id: str
|
||||
name: str
|
||||
state: Literal["idle", "writing", "researching", "executing", "syncing", "error"]
|
||||
detail: str | None = None
|
||||
area: str | None = None
|
||||
is_main: bool = False
|
||||
auth_status: str = "approved" # approved, pending, rejected, offline
|
||||
|
||||
|
||||
class SetStateRequest(BaseModel):
|
||||
state: str
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
class OfficeStatus(BaseModel):
|
||||
state: str
|
||||
detail: str | None = None
|
||||
agent_name: str
|
||||
timestamp: str
|
||||
|
||||
|
||||
class OfficeMemo(BaseModel):
|
||||
success: bool
|
||||
date: str
|
||||
memo: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# In-Memory State (in production, this would come from Jarvis's agent state)
|
||||
# ============================================================================
|
||||
_current_state: dict = {
|
||||
"agent_id": "jarvis-main",
|
||||
"name": "JARVIS",
|
||||
"state": "idle",
|
||||
"detail": "战舰启动中...",
|
||||
"area": "breakroom",
|
||||
"is_main": True,
|
||||
"auth_status": "approved",
|
||||
}
|
||||
|
||||
|
||||
def normalize_state(state: str | None) -> str:
|
||||
"""Normalize various state names to our canonical states."""
|
||||
if not state:
|
||||
return "idle"
|
||||
state = state.lower().strip()
|
||||
if state in ("working", "run", "running"):
|
||||
return "writing"
|
||||
if state in ("sync", "syncing"):
|
||||
return "syncing"
|
||||
if state in ("research", "researching"):
|
||||
return "researching"
|
||||
if state in ("execute", "executing"):
|
||||
return "executing"
|
||||
if state == "error":
|
||||
return "error"
|
||||
return "idle"
|
||||
|
||||
|
||||
def get_state_info(state: str) -> dict:
|
||||
"""Get state info including area mapping."""
|
||||
return STATES.get(state, STATES["idle"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# API Endpoints
|
||||
# ============================================================================
|
||||
@router.get("/status", response_model=OfficeStatus)
|
||||
async def get_status():
|
||||
"""Get current agent status."""
|
||||
state_info = get_state_info(_current_state["state"])
|
||||
return OfficeStatus(
|
||||
state=_current_state["state"],
|
||||
detail=_current_state.get("detail"),
|
||||
agent_name=_current_state["name"],
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/yesterday-memo", response_model=OfficeMemo)
|
||||
async def get_yesterday_memo():
|
||||
"""Return a lightweight public memo for the Star Office viewer."""
|
||||
target_date = (datetime.now() - timedelta(days=1)).date().isoformat()
|
||||
detail = (_current_state.get("detail") or "No detailed log was recorded.").strip()
|
||||
memo = (
|
||||
"Yesterday summary\n"
|
||||
f"- Last known state: {_current_state['state']}\n"
|
||||
f"- Detail: {detail}\n"
|
||||
"- Next step: open the command surface and continue from the current work thread."
|
||||
)
|
||||
return OfficeMemo(success=True, date=target_date, memo=memo)
|
||||
|
||||
|
||||
@router.post("/set_state")
|
||||
async def set_state(req: SetStateRequest):
|
||||
"""Set the current agent state."""
|
||||
normalized = normalize_state(req.state)
|
||||
state_info = get_state_info(normalized)
|
||||
|
||||
_current_state["state"] = normalized
|
||||
_current_state["detail"] = req.detail or ""
|
||||
_current_state["area"] = state_info["area"]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"state": normalized,
|
||||
"area": state_info["area"],
|
||||
"detail": _current_state["detail"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/agents")
|
||||
async def get_agents():
|
||||
"""Get all agents in the office (for multi-agent support)."""
|
||||
# For now, return just the main agent
|
||||
# In full implementation, this would query Jarvis's agent registry
|
||||
state_info = get_state_info(_current_state["state"])
|
||||
return [
|
||||
{
|
||||
"agentId": _current_state["agent_id"],
|
||||
"name": _current_state["name"],
|
||||
"state": _current_state["state"],
|
||||
"detail": _current_state.get("detail", ""),
|
||||
"area": state_info["area"],
|
||||
"isMain": _current_state.get("is_main", True),
|
||||
"authStatus": _current_state.get("auth_status", "approved"),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@router.get("/areas")
|
||||
async def get_areas():
|
||||
"""Get all spaceship areas with coordinates."""
|
||||
return SHIP_AREAS
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
"""Health check."""
|
||||
return {"status": "ok", "service": "office"}
|
||||
130
backend/app/routers/remote_mount.py
Normal file
130
backend/app/routers/remote_mount.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.remote_mount import RemoteMount
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
from app.schemas.remote_mount import (
|
||||
RemoteMountCreate,
|
||||
RemoteMountOut,
|
||||
RemoteMountTreeOut,
|
||||
RemoteNodeOut,
|
||||
RemoteSyncRequest,
|
||||
RemoteSyncResultOut,
|
||||
)
|
||||
from app.services.remote_sync_service import RemoteSyncService
|
||||
from app.services.secret_service import encrypt_secret
|
||||
from app.services.webdav_service import WebDavNode, WebDavService
|
||||
|
||||
router = APIRouter(prefix="/api/remote-mounts", tags=["远程挂载"])
|
||||
|
||||
|
||||
def _to_node_out(node: WebDavNode) -> RemoteNodeOut:
|
||||
return RemoteNodeOut(
|
||||
path=node.path,
|
||||
name=node.name,
|
||||
is_dir=node.is_dir,
|
||||
size=node.size,
|
||||
modified_at=node.modified_at,
|
||||
etag=node.etag,
|
||||
children=[_to_node_out(child) for child in node.children],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[RemoteMountOut])
|
||||
async def list_remote_mounts(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(RemoteMount).where(RemoteMount.user_id == current_user.id).order_by(RemoteMount.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("", response_model=RemoteMountOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_remote_mount(
|
||||
payload: RemoteMountCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
existing = await db.execute(
|
||||
select(RemoteMount).where(and_(RemoteMount.user_id == current_user.id, RemoteMount.name == payload.name))
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="同名远程挂载已存在")
|
||||
|
||||
mount = RemoteMount(
|
||||
user_id=current_user.id,
|
||||
name=payload.name,
|
||||
mount_type="webdav",
|
||||
base_url=str(payload.base_url),
|
||||
username=payload.username,
|
||||
password_encrypted=encrypt_secret(payload.password),
|
||||
root_path=payload.root_path,
|
||||
is_active=True,
|
||||
)
|
||||
try:
|
||||
await WebDavService(mount).list_directory(payload.root_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=400, detail=f"WebDAV 连接失败: {exc}") from exc
|
||||
|
||||
db.add(mount)
|
||||
await db.commit()
|
||||
await db.refresh(mount)
|
||||
return mount
|
||||
|
||||
|
||||
async def _get_user_mount(db: AsyncSession, user_id: str, mount_id: str) -> RemoteMount:
|
||||
result = await db.execute(
|
||||
select(RemoteMount).where(and_(RemoteMount.id == mount_id, RemoteMount.user_id == user_id))
|
||||
)
|
||||
mount = result.scalar_one_or_none()
|
||||
if mount is None:
|
||||
raise HTTPException(status_code=404, detail="远程挂载不存在")
|
||||
return mount
|
||||
|
||||
|
||||
@router.get("/{mount_id}/tree", response_model=RemoteMountTreeOut)
|
||||
async def get_remote_tree(
|
||||
mount_id: str,
|
||||
path: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
mount = await _get_user_mount(db, current_user.id, mount_id)
|
||||
try:
|
||||
nodes = await WebDavService(mount).list_tree(path or mount.root_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=400, detail=f"远程目录读取失败: {exc}") from exc
|
||||
|
||||
return RemoteMountTreeOut(
|
||||
mount_id=mount.id,
|
||||
root_path=path or mount.root_path,
|
||||
nodes=[_to_node_out(node) for node in nodes],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{mount_id}/sync", response_model=RemoteSyncResultOut)
|
||||
async def sync_remote_mount(
|
||||
mount_id: str,
|
||||
payload: RemoteSyncRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
mount = await _get_user_mount(db, current_user.id, mount_id)
|
||||
try:
|
||||
result = await RemoteSyncService(db, current_user.id).sync_remote_path(
|
||||
mount,
|
||||
payload.remote_path,
|
||||
payload.local_folder_id,
|
||||
payload.mode,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=500, detail=f"远程同步失败: {exc}") from exc
|
||||
|
||||
return RemoteSyncResultOut(**result)
|
||||
@@ -1,25 +1,62 @@
|
||||
from calendar import monthrange
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.goal import Goal
|
||||
from app.models.reminder import Reminder
|
||||
from app.models.task import Task, TaskPriority
|
||||
from app.models.task import Task, TaskDispatchStatus, TaskPriority, TaskQuadrant, TaskStatus
|
||||
from app.models.todo import DailyTodo
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
from app.schemas.schedule_center import (
|
||||
ScheduleCenterCommanderSummaryOut,
|
||||
ScheduleCenterDateOut,
|
||||
ScheduleCenterDaySummary,
|
||||
ScheduleCenterFocusTaskOut,
|
||||
ScheduleCenterMonthOut,
|
||||
ScheduleCenterQuadrantOut,
|
||||
ScheduleCenterQuadrantTaskOut,
|
||||
)
|
||||
from app.schemas.task import build_task_out
|
||||
|
||||
router = APIRouter(prefix="/api/schedule-center", tags=["调度中心"])
|
||||
|
||||
QUADRANT_META: dict[TaskQuadrant, dict[str, str]] = {
|
||||
TaskQuadrant.URGENT_IMPORTANT: {
|
||||
"title": "重要且紧急",
|
||||
"subtitle": "CRITICAL",
|
||||
"color": "#ff4757",
|
||||
"glow_color": "rgba(255, 71, 87, 0.4)",
|
||||
"icon": "◈",
|
||||
},
|
||||
TaskQuadrant.NOT_URGENT_IMPORTANT: {
|
||||
"title": "重要不紧急",
|
||||
"subtitle": "PLANNED",
|
||||
"color": "#ffd93d",
|
||||
"glow_color": "rgba(255, 217, 61, 0.4)",
|
||||
"icon": "◇",
|
||||
},
|
||||
TaskQuadrant.URGENT_NOT_IMPORTANT: {
|
||||
"title": "紧急不重要",
|
||||
"subtitle": "DELEGATE",
|
||||
"color": "#00d4ff",
|
||||
"glow_color": "rgba(0, 212, 255, 0.4)",
|
||||
"icon": "◉",
|
||||
},
|
||||
TaskQuadrant.NOT_URGENT_NOT_IMPORTANT: {
|
||||
"title": "不重要不紧急",
|
||||
"subtitle": "ELIMINATE",
|
||||
"color": "#6bcf7f",
|
||||
"glow_color": "rgba(107, 207, 127, 0.4)",
|
||||
"icon": "○",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_summary(
|
||||
target_date: str,
|
||||
@@ -39,6 +76,146 @@ def _build_summary(
|
||||
)
|
||||
|
||||
|
||||
def _coerce_enum(value, enum_cls, default=None):
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, enum_cls):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
raw = value.strip()
|
||||
if not raw:
|
||||
return default
|
||||
for item in enum_cls:
|
||||
if raw == item.value or raw.lower() == item.value:
|
||||
return item
|
||||
if raw.upper() == item.name:
|
||||
return item
|
||||
return default
|
||||
|
||||
|
||||
def _derive_quadrant(task: Task) -> TaskQuadrant:
|
||||
quadrant = _coerce_enum(task.quadrant, TaskQuadrant, None)
|
||||
if quadrant is not None:
|
||||
return quadrant
|
||||
|
||||
priority = _coerce_enum(task.priority, TaskPriority, TaskPriority.MEDIUM)
|
||||
status = _coerce_enum(task.status, TaskStatus, TaskStatus.TODO)
|
||||
|
||||
if priority in {TaskPriority.HIGH, TaskPriority.URGENT}:
|
||||
return TaskQuadrant.URGENT_IMPORTANT
|
||||
if status == TaskStatus.IN_PROGRESS:
|
||||
return TaskQuadrant.NOT_URGENT_IMPORTANT
|
||||
if priority == TaskPriority.MEDIUM:
|
||||
return TaskQuadrant.URGENT_NOT_IMPORTANT
|
||||
return TaskQuadrant.NOT_URGENT_NOT_IMPORTANT
|
||||
|
||||
|
||||
def _enum_value(value) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if hasattr(value, "value"):
|
||||
return str(value.value)
|
||||
if isinstance(value, str):
|
||||
raw = value.strip()
|
||||
return raw or None
|
||||
return str(value)
|
||||
|
||||
|
||||
def _build_focus_tasks(tasks: list[Task]) -> list[ScheduleCenterFocusTaskOut]:
|
||||
priority_rank = {
|
||||
TaskPriority.URGENT: 0,
|
||||
TaskPriority.HIGH: 1,
|
||||
TaskPriority.MEDIUM: 2,
|
||||
TaskPriority.LOW: 3,
|
||||
}
|
||||
status_rank = {
|
||||
TaskStatus.IN_PROGRESS: 0,
|
||||
TaskStatus.TODO: 1,
|
||||
TaskStatus.DONE: 2,
|
||||
TaskStatus.CANCELLED: 3,
|
||||
}
|
||||
ordered = sorted(
|
||||
tasks,
|
||||
key=lambda item: (
|
||||
status_rank.get(_coerce_enum(item.status, TaskStatus, TaskStatus.TODO), 99),
|
||||
priority_rank.get(_coerce_enum(item.priority, TaskPriority, TaskPriority.MEDIUM), 99),
|
||||
item.created_at,
|
||||
),
|
||||
)
|
||||
return [
|
||||
ScheduleCenterFocusTaskOut(
|
||||
id=item.id,
|
||||
title=item.title,
|
||||
status=_coerce_enum(item.status, TaskStatus, TaskStatus.TODO),
|
||||
priority=_coerce_enum(item.priority, TaskPriority, TaskPriority.MEDIUM),
|
||||
quadrant=_derive_quadrant(item),
|
||||
assignee_type=_enum_value(item.assignee_type),
|
||||
assignee_id=item.assignee_id,
|
||||
dispatch_status=_coerce_enum(item.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE),
|
||||
due_date=item.due_date,
|
||||
)
|
||||
for item in ordered[:6]
|
||||
]
|
||||
|
||||
|
||||
def _build_quadrants(tasks: list[Task]) -> list[ScheduleCenterQuadrantOut]:
|
||||
buckets: dict[TaskQuadrant, list[ScheduleCenterQuadrantTaskOut]] = {
|
||||
quadrant: [] for quadrant in QUADRANT_META
|
||||
}
|
||||
for task in tasks:
|
||||
quadrant = _derive_quadrant(task)
|
||||
buckets[quadrant].append(
|
||||
ScheduleCenterQuadrantTaskOut(
|
||||
id=task.id,
|
||||
title=task.title,
|
||||
status=_coerce_enum(task.status, TaskStatus, TaskStatus.TODO),
|
||||
priority=_coerce_enum(task.priority, TaskPriority, TaskPriority.MEDIUM),
|
||||
dispatch_status=_coerce_enum(task.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE),
|
||||
assignee_type=_enum_value(task.assignee_type),
|
||||
assignee_id=task.assignee_id,
|
||||
)
|
||||
)
|
||||
|
||||
return [
|
||||
ScheduleCenterQuadrantOut(
|
||||
id=quadrant,
|
||||
title=meta["title"],
|
||||
subtitle=meta["subtitle"],
|
||||
color=meta["color"],
|
||||
glow_color=meta["glow_color"],
|
||||
icon=meta["icon"],
|
||||
tasks=buckets[quadrant],
|
||||
)
|
||||
for quadrant, meta in QUADRANT_META.items()
|
||||
]
|
||||
|
||||
|
||||
def _build_commander_summary(tasks: list[Task]) -> ScheduleCenterCommanderSummaryOut:
|
||||
counts = ScheduleCenterCommanderSummaryOut()
|
||||
for task in tasks:
|
||||
states = [task.dispatch_status, *(subtask.dispatch_status for subtask in task.subtasks)]
|
||||
for state in states:
|
||||
normalized = _coerce_enum(state, TaskDispatchStatus, TaskDispatchStatus.IDLE)
|
||||
if normalized == TaskDispatchStatus.IDLE:
|
||||
continue
|
||||
counts.total += 1
|
||||
if normalized == TaskDispatchStatus.QUEUED:
|
||||
counts.queued += 1
|
||||
elif normalized == TaskDispatchStatus.RUNNING:
|
||||
counts.running += 1
|
||||
elif normalized == TaskDispatchStatus.COMPLETED:
|
||||
counts.completed += 1
|
||||
elif normalized == TaskDispatchStatus.FAILED:
|
||||
counts.failed += 1
|
||||
if counts.running > 0:
|
||||
counts.overall_status = "running"
|
||||
elif counts.queued > 0:
|
||||
counts.overall_status = "queued"
|
||||
elif counts.failed > 0 and counts.completed == 0:
|
||||
counts.overall_status = "failed"
|
||||
return counts
|
||||
|
||||
|
||||
@router.get("/month", response_model=ScheduleCenterMonthOut)
|
||||
async def get_month_schedule(
|
||||
year: int = Query(..., ge=2000, le=2100),
|
||||
@@ -53,27 +230,43 @@ async def get_month_schedule(
|
||||
start_dt = datetime.combine(month_start, datetime.min.time())
|
||||
end_dt = datetime.combine(month_start.replace(day=days_in_month), datetime.max.time())
|
||||
|
||||
todos = (await db.execute(
|
||||
select(DailyTodo).where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date >= start_key, DailyTodo.todo_date <= end_key)
|
||||
)).scalars().all()
|
||||
tasks = (await db.execute(
|
||||
todos = (
|
||||
await db.execute(
|
||||
select(DailyTodo).where(
|
||||
DailyTodo.user_id == current_user.id,
|
||||
DailyTodo.todo_date >= start_key,
|
||||
DailyTodo.todo_date <= end_key,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
tasks = (
|
||||
await db.execute(
|
||||
select(Task).where(
|
||||
Task.user_id == current_user.id,
|
||||
Task.due_date.is_not(None),
|
||||
Task.due_date >= start_dt,
|
||||
Task.due_date <= end_dt,
|
||||
)
|
||||
)).scalars().all()
|
||||
reminders = (await db.execute(
|
||||
)
|
||||
).scalars().all()
|
||||
reminders = (
|
||||
await db.execute(
|
||||
select(Reminder).where(
|
||||
Reminder.user_id == current_user.id,
|
||||
Reminder.reminder_at >= start_dt,
|
||||
Reminder.reminder_at <= end_dt,
|
||||
)
|
||||
)).scalars().all()
|
||||
goals = (await db.execute(
|
||||
select(Goal).where(Goal.user_id == current_user.id, Goal.goal_date >= start_key, Goal.goal_date <= end_key)
|
||||
)).scalars().all()
|
||||
)
|
||||
).scalars().all()
|
||||
goals = (
|
||||
await db.execute(
|
||||
select(Goal).where(
|
||||
Goal.user_id == current_user.id,
|
||||
Goal.goal_date >= start_key,
|
||||
Goal.goal_date <= end_key,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
todo_map: dict[str, list[DailyTodo]] = {}
|
||||
for item in todos:
|
||||
@@ -96,18 +289,20 @@ async def get_month_schedule(
|
||||
days = []
|
||||
for day in range(1, days_in_month + 1):
|
||||
date_key = month_start.replace(day=day).isoformat()
|
||||
days.append(_build_summary(
|
||||
days.append(
|
||||
_build_summary(
|
||||
date_key,
|
||||
todo_map.get(date_key, []),
|
||||
task_map.get(date_key, []),
|
||||
reminder_map.get(date_key, []),
|
||||
goal_map.get(date_key, []),
|
||||
))
|
||||
)
|
||||
)
|
||||
|
||||
return ScheduleCenterMonthOut(month=f"{year:04d}-{month:02d}", days=days)
|
||||
|
||||
|
||||
@router.get("/date", response_model=ScheduleCenterDateOut)
|
||||
@router.get("/date", response_model=ScheduleCenterDateOut, response_model_exclude_none=True)
|
||||
async def get_date_schedule(
|
||||
date_str: date = Query(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -118,22 +313,28 @@ async def get_date_schedule(
|
||||
end_dt = datetime.combine(target_date, datetime.max.time())
|
||||
date_key = target_date.isoformat()
|
||||
|
||||
todos = (await db.execute(
|
||||
todos = (
|
||||
await db.execute(
|
||||
select(DailyTodo)
|
||||
.where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date == date_key)
|
||||
.order_by(DailyTodo.created_at.desc())
|
||||
)).scalars().all()
|
||||
tasks = (await db.execute(
|
||||
)
|
||||
).scalars().all()
|
||||
tasks = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.options(selectinload(Task.subtasks), selectinload(Task.history))
|
||||
.where(
|
||||
Task.user_id == current_user.id,
|
||||
Task.due_date.is_not(None),
|
||||
Task.due_date >= start_dt,
|
||||
Task.due_date <= end_dt,
|
||||
)
|
||||
.order_by(Task.created_at.desc())
|
||||
)).scalars().all()
|
||||
reminders = (await db.execute(
|
||||
.order_by(Task.priority.desc(), Task.created_at.desc())
|
||||
)
|
||||
).scalars().unique().all()
|
||||
reminders = (
|
||||
await db.execute(
|
||||
select(Reminder)
|
||||
.where(
|
||||
Reminder.user_id == current_user.id,
|
||||
@@ -141,20 +342,26 @@ async def get_date_schedule(
|
||||
Reminder.reminder_at <= end_dt,
|
||||
)
|
||||
.order_by(Reminder.reminder_at.asc(), Reminder.created_at.asc())
|
||||
)).scalars().all()
|
||||
goals = (await db.execute(
|
||||
)
|
||||
).scalars().all()
|
||||
goals = (
|
||||
await db.execute(
|
||||
select(Goal)
|
||||
.where(Goal.user_id == current_user.id, Goal.goal_date == date_key)
|
||||
.order_by(Goal.created_at.desc())
|
||||
)).scalars().all()
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
summary = _build_summary(date_key, todos, tasks, reminders, goals)
|
||||
return ScheduleCenterDateOut(
|
||||
date=date_key,
|
||||
todos=todos,
|
||||
tasks=tasks,
|
||||
tasks=[build_task_out(task) for task in tasks],
|
||||
reminders=reminders,
|
||||
goals=goals,
|
||||
summary=summary,
|
||||
focus_tasks=_build_focus_tasks(tasks),
|
||||
quadrants=_build_quadrants(tasks),
|
||||
commander_summary=_build_commander_summary(tasks),
|
||||
generated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
@@ -29,6 +29,10 @@ async def create_skill(
|
||||
visibility=data.visibility,
|
||||
team_id=data.team_id,
|
||||
is_active=data.is_active,
|
||||
status=data.status,
|
||||
scope=data.scope,
|
||||
effectiveness=data.effectiveness,
|
||||
review_after=data.review_after,
|
||||
owner_id=current_user.id,
|
||||
)
|
||||
db.add(skill)
|
||||
@@ -103,6 +107,14 @@ async def update_skill(
|
||||
skill.team_id = data.team_id
|
||||
if data.is_active is not None:
|
||||
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.refresh(skill)
|
||||
|
||||
@@ -7,3 +7,9 @@ router = APIRouter(prefix='/api/system', tags=['system'])
|
||||
@router.get('/status')
|
||||
async def get_system_status():
|
||||
return SystemService().get_status()
|
||||
|
||||
|
||||
@router.get('/config')
|
||||
async def get_system_config():
|
||||
"""Get public system configuration."""
|
||||
return await SystemService().get_config()
|
||||
|
||||
@@ -1,15 +1,116 @@
|
||||
import json
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.task import (
|
||||
Task,
|
||||
TaskAssigneeType,
|
||||
TaskDispatchStatus,
|
||||
TaskQuadrant,
|
||||
TaskSource,
|
||||
TaskStatus,
|
||||
TaskSubTask,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
from app.schemas.task import TaskCreate, TaskUpdate, TaskOut
|
||||
from app.schemas.task import (
|
||||
TaskCreate,
|
||||
TaskDetailOut,
|
||||
TaskDispatchRequest,
|
||||
TaskDispatchResponse,
|
||||
TaskHistoryOut,
|
||||
TaskOut,
|
||||
TaskSubTaskCreate,
|
||||
TaskSubTaskOut,
|
||||
TaskSubTaskReorderRequest,
|
||||
TaskSubTaskUpdate,
|
||||
TaskUpdate,
|
||||
build_task_detail_out,
|
||||
)
|
||||
from app.services.task_dispatch import append_task_history, load_task_with_details, queue_task_dispatch
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["看板"])
|
||||
router = APIRouter(prefix="/api/tasks", tags=["Tasks"])
|
||||
|
||||
|
||||
def _encode_tags(tags: list[str] | None) -> str | None:
|
||||
if not tags:
|
||||
return None
|
||||
return json.dumps(tags, ensure_ascii=False)
|
||||
|
||||
|
||||
def _decode_tags(value: str | None) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
try:
|
||||
payload = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return [value]
|
||||
if isinstance(payload, list):
|
||||
return [str(item) for item in payload]
|
||||
return [str(payload)]
|
||||
|
||||
|
||||
def _subtask_to_out(subtask: TaskSubTask) -> TaskSubTaskOut:
|
||||
return TaskSubTaskOut.model_validate(subtask)
|
||||
|
||||
|
||||
def _history_to_out(history) -> TaskHistoryOut:
|
||||
return TaskHistoryOut.model_validate(history)
|
||||
|
||||
|
||||
def _task_to_out(task: Task) -> TaskOut:
|
||||
return TaskOut(
|
||||
id=task.id,
|
||||
title=task.title,
|
||||
description=task.description,
|
||||
status=task.status,
|
||||
priority=task.priority,
|
||||
due_date=task.due_date,
|
||||
completed_at=task.completed_at,
|
||||
tags=_decode_tags(task.tags),
|
||||
source=task.source or TaskSource.MANUAL,
|
||||
conversation_id=task.conversation_id,
|
||||
quadrant=task.quadrant,
|
||||
assignee_type=task.assignee_type,
|
||||
assignee_id=task.assignee_id,
|
||||
dispatch_status=task.dispatch_status or TaskDispatchStatus.IDLE,
|
||||
dispatch_run_id=task.dispatch_run_id,
|
||||
result_summary=task.result_summary,
|
||||
started_at=task.started_at,
|
||||
last_synced_at=task.last_synced_at,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _task_detail_to_out(task: Task) -> TaskDetailOut:
|
||||
return build_task_detail_out(task)
|
||||
|
||||
|
||||
async def _get_task_or_404(db: AsyncSession, *, task_id: str, user_id: str) -> Task:
|
||||
task = await load_task_with_details(db, task_id=task_id, user_id=user_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return task
|
||||
|
||||
|
||||
def _sync_task_completion(task: Task) -> None:
|
||||
if task.status == TaskStatus.DONE:
|
||||
task.completed_at = task.completed_at or datetime.now(UTC)
|
||||
elif task.status != TaskStatus.CANCELLED:
|
||||
task.completed_at = None
|
||||
|
||||
|
||||
def _sync_subtask_completion(subtask: TaskSubTask) -> None:
|
||||
if subtask.status == TaskStatus.DONE:
|
||||
subtask.completed_at = subtask.completed_at or datetime.now(UTC)
|
||||
elif subtask.status != TaskStatus.CANCELLED:
|
||||
subtask.completed_at = None
|
||||
|
||||
|
||||
@router.get("", response_model=list[TaskOut])
|
||||
@@ -18,12 +119,28 @@ async def list_tasks(
|
||||
due_date: date | None = Query(default=None),
|
||||
date_from: date | None = Query(default=None),
|
||||
date_to: date | None = Query(default=None),
|
||||
quadrant: TaskQuadrant | None = None,
|
||||
assignee_type: TaskAssigneeType | None = None,
|
||||
dispatch_status: TaskDispatchStatus | None = None,
|
||||
conversation_id: str | None = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(Task).where(Task.user_id == current_user.id)
|
||||
query = (
|
||||
select(Task)
|
||||
.options(selectinload(Task.subtasks), selectinload(Task.history))
|
||||
.where(Task.user_id == current_user.id)
|
||||
)
|
||||
if status:
|
||||
query = query.where(Task.status == status)
|
||||
if quadrant:
|
||||
query = query.where(Task.quadrant == quadrant)
|
||||
if assignee_type:
|
||||
query = query.where(Task.assignee_type == assignee_type)
|
||||
if dispatch_status:
|
||||
query = query.where(Task.dispatch_status == dispatch_status)
|
||||
if conversation_id:
|
||||
query = query.where(Task.conversation_id == conversation_id)
|
||||
if due_date:
|
||||
start = datetime.combine(due_date, datetime.min.time())
|
||||
end = datetime.combine(due_date, datetime.max.time())
|
||||
@@ -32,65 +149,109 @@ async def list_tasks(
|
||||
start = datetime.combine(date_from, datetime.min.time()) if date_from else None
|
||||
end = datetime.combine(date_to, datetime.max.time()) if date_to else None
|
||||
if start and end and start > end:
|
||||
raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期")
|
||||
raise HTTPException(status_code=400, detail="date_from cannot be later than date_to")
|
||||
if start is not None:
|
||||
query = query.where(Task.due_date.is_not(None), Task.due_date >= start)
|
||||
if end is not None:
|
||||
query = query.where(Task.due_date.is_not(None), Task.due_date <= end)
|
||||
query = query.order_by(desc(Task.created_at))
|
||||
|
||||
query = query.order_by(desc(Task.updated_at), desc(Task.created_at))
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
tasks = result.scalars().unique().all()
|
||||
return [_task_to_out(task) for task in tasks]
|
||||
|
||||
|
||||
@router.post("", response_model=TaskOut, status_code=201)
|
||||
@router.post("", response_model=TaskDetailOut, status_code=201)
|
||||
async def create_task(
|
||||
data: TaskCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
import json
|
||||
task = Task(
|
||||
user_id=current_user.id,
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
priority=data.priority,
|
||||
due_date=data.due_date,
|
||||
tags=json.dumps(data.tags) if data.tags else None,
|
||||
tags=_encode_tags(data.tags),
|
||||
source=data.source,
|
||||
conversation_id=data.conversation_id,
|
||||
quadrant=data.quadrant,
|
||||
assignee_type=data.assignee_type,
|
||||
assignee_id=data.assignee_id,
|
||||
status=data.status,
|
||||
)
|
||||
_sync_task_completion(task)
|
||||
if data.source == TaskSource.CHAT:
|
||||
append_task_history(task, action="created_from_chat", new_value=task.title)
|
||||
append_task_history(task, action="created", new_value=task.title)
|
||||
for index, subtask_data in enumerate(data.subtasks):
|
||||
subtask = TaskSubTask(
|
||||
title=subtask_data.title,
|
||||
description=subtask_data.description,
|
||||
status=subtask_data.status,
|
||||
order_index=index if subtask_data.order_index is None else subtask_data.order_index,
|
||||
assignee_type=subtask_data.assignee_type,
|
||||
assignee_id=subtask_data.assignee_id,
|
||||
)
|
||||
_sync_subtask_completion(subtask)
|
||||
task.subtasks.append(subtask)
|
||||
append_task_history(task, action="subtask_created", new_value=subtask.title)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
return task
|
||||
|
||||
task = await _get_task_or_404(db, task_id=task.id, user_id=current_user.id)
|
||||
if data.dispatch_to_commander:
|
||||
await queue_task_dispatch(task, db=db)
|
||||
task = await _get_task_or_404(db, task_id=task.id, user_id=current_user.id)
|
||||
return _task_detail_to_out(task)
|
||||
|
||||
|
||||
@router.patch("/{task_id}", response_model=TaskOut)
|
||||
@router.get("/{task_id}", response_model=TaskDetailOut)
|
||||
async def get_task(
|
||||
task_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||
return _task_detail_to_out(task)
|
||||
|
||||
|
||||
@router.patch("/{task_id}", response_model=TaskDetailOut)
|
||||
async def update_task(
|
||||
task_id: str,
|
||||
data: TaskUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
import json
|
||||
result = await db.execute(
|
||||
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||
payload = data.model_dump(exclude_none=True)
|
||||
previous_assignee = (task.assignee_type, task.assignee_id)
|
||||
|
||||
for field, value in data.model_dump(exclude_none=True).items():
|
||||
for field, value in payload.items():
|
||||
previous = getattr(task, field)
|
||||
if field == "tags":
|
||||
setattr(task, field, json.dumps(value))
|
||||
elif field == "status" and value == TaskStatus.DONE:
|
||||
task.completed_at = datetime.now(UTC)
|
||||
setattr(task, field, value)
|
||||
elif field == "status":
|
||||
task.completed_at = None
|
||||
task.tags = _encode_tags(value)
|
||||
append_task_history(task, action="updated", old_value=_decode_tags(previous), new_value=value)
|
||||
continue
|
||||
setattr(task, field, value)
|
||||
if field == "status":
|
||||
_sync_task_completion(task)
|
||||
append_task_history(task, action="status_changed", old_value=previous, new_value=value)
|
||||
elif previous != value:
|
||||
append_task_history(task, action="updated", old_value=previous, new_value=value)
|
||||
|
||||
if ("assignee_type" in payload or "assignee_id" in payload) and previous_assignee != (task.assignee_type, task.assignee_id):
|
||||
append_task_history(
|
||||
task,
|
||||
action="assigned",
|
||||
old_value=f"{previous_assignee[0]}:{previous_assignee[1]}",
|
||||
new_value=f"{task.assignee_type}:{task.assignee_id}",
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
return task
|
||||
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||
return _task_detail_to_out(task)
|
||||
|
||||
|
||||
@router.delete("/{task_id}", status_code=204)
|
||||
@@ -99,11 +260,171 @@ async def delete_task(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||
await db.delete(task)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/{task_id}/subtasks", status_code=201)
|
||||
async def create_subtask(
|
||||
task_id: str,
|
||||
data: TaskSubTaskCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||
max_order = max((item.order_index for item in task.subtasks), default=-1)
|
||||
subtask = TaskSubTask(
|
||||
task_id=task.id,
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
status=data.status,
|
||||
order_index=max_order + 1 if data.order_index is None else data.order_index,
|
||||
assignee_type=data.assignee_type,
|
||||
assignee_id=data.assignee_id,
|
||||
)
|
||||
_sync_subtask_completion(subtask)
|
||||
task.subtasks.append(subtask)
|
||||
append_task_history(task, action="subtask_created", new_value=data.title)
|
||||
await db.commit()
|
||||
db.expire_all()
|
||||
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||
detail = _task_detail_to_out(task)
|
||||
created_subtask = max(
|
||||
(item for item in detail.subtasks if item.title == data.title),
|
||||
key=lambda item: (item.order_index, item.created_at),
|
||||
default=None,
|
||||
)
|
||||
if created_subtask is None:
|
||||
raise HTTPException(status_code=500, detail="Created subtask could not be loaded")
|
||||
return {
|
||||
**created_subtask.model_dump(),
|
||||
"task": detail.model_dump(),
|
||||
"subtasks": [item.model_dump() for item in detail.subtasks],
|
||||
"history": [item.model_dump() for item in detail.history],
|
||||
"dispatch": detail.dispatch.model_dump(),
|
||||
"dispatch_summary": detail.dispatch_summary.model_dump(),
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{task_id}/subtasks/{subtask_id}", response_model=TaskDetailOut)
|
||||
async def update_subtask(
|
||||
task_id: str,
|
||||
subtask_id: str,
|
||||
data: TaskSubTaskUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||
subtask = next((item for item in task.subtasks if item.id == subtask_id), None)
|
||||
if subtask is None:
|
||||
raise HTTPException(status_code=404, detail="Subtask not found")
|
||||
|
||||
payload = data.model_dump(exclude_none=True)
|
||||
for field, value in payload.items():
|
||||
previous = getattr(subtask, field)
|
||||
setattr(subtask, field, value)
|
||||
if field == "status":
|
||||
_sync_subtask_completion(subtask)
|
||||
if previous != value:
|
||||
append_task_history(
|
||||
task,
|
||||
action="updated" if field != "status" else "status_changed",
|
||||
old_value=f"{subtask.id}:{field}:{previous}",
|
||||
new_value=f"{subtask.id}:{field}:{value}",
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
db.expire_all()
|
||||
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||
return _task_detail_to_out(task)
|
||||
|
||||
|
||||
@router.delete("/{task_id}/subtasks/{subtask_id}", response_model=TaskDetailOut)
|
||||
async def delete_subtask(
|
||||
task_id: str,
|
||||
subtask_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||
subtask = next((item for item in task.subtasks if item.id == subtask_id), None)
|
||||
if subtask is None:
|
||||
raise HTTPException(status_code=404, detail="Subtask not found")
|
||||
|
||||
append_task_history(task, action="updated", old_value="subtask_deleted", new_value=subtask.title)
|
||||
await db.delete(subtask)
|
||||
await db.commit()
|
||||
db.expire_all()
|
||||
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||
return _task_detail_to_out(task)
|
||||
|
||||
|
||||
@router.post("/{task_id}/subtasks/reorder", response_model=TaskDetailOut)
|
||||
async def reorder_subtasks(
|
||||
task_id: str,
|
||||
data: TaskSubTaskReorderRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||
lookup = {item.id: item for item in task.subtasks}
|
||||
for item in data.items:
|
||||
subtask = lookup.get(item.id)
|
||||
if subtask is None:
|
||||
raise HTTPException(status_code=404, detail=f"Subtask not found: {item.id}")
|
||||
subtask.order_index = item.order_index
|
||||
|
||||
append_task_history(
|
||||
task,
|
||||
action="subtask_reordered",
|
||||
new_value=",".join(f"{item.id}:{item.order_index}" for item in data.items),
|
||||
)
|
||||
await db.commit()
|
||||
db.expire_all()
|
||||
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||
return _task_detail_to_out(task)
|
||||
|
||||
|
||||
@router.post("/{task_id}/dispatch", response_model=TaskDispatchResponse)
|
||||
async def dispatch_task(
|
||||
task_id: str,
|
||||
data: TaskDispatchRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if data.target != "commander":
|
||||
raise HTTPException(status_code=400, detail="Only commander dispatch is supported")
|
||||
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||
_, payload = await queue_task_dispatch(task, db=db)
|
||||
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||
return TaskDispatchResponse(
|
||||
status=task.dispatch_status,
|
||||
run_id=task.dispatch_run_id,
|
||||
task=_task_detail_to_out(task),
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{task_id}/subtasks/{subtask_id}/dispatch", response_model=TaskDispatchResponse)
|
||||
async def dispatch_subtask(
|
||||
task_id: str,
|
||||
subtask_id: str,
|
||||
data: TaskDispatchRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if data.target != "commander":
|
||||
raise HTTPException(status_code=400, detail="Only commander dispatch is supported")
|
||||
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||
subtask = next((item for item in task.subtasks if item.id == subtask_id), None)
|
||||
if subtask is None:
|
||||
raise HTTPException(status_code=404, detail="Subtask not found")
|
||||
_, payload = await queue_task_dispatch(task, db=db, subtask=subtask)
|
||||
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||
return TaskDispatchResponse(
|
||||
status=subtask.dispatch_status,
|
||||
run_id=subtask.dispatch_run_id,
|
||||
task=_task_detail_to_out(task),
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
348
backend/app/routers/tools.py
Normal file
348
backend/app/routers/tools.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""Tools API Router
|
||||
|
||||
聚合两套工具体系的元数据:
|
||||
1. 注册层 (app/tools/) - YAML manifest 定义
|
||||
2. Agent 层 (app/agents/tools/) - @tool 装饰器定义
|
||||
"""
|
||||
|
||||
import re
|
||||
import importlib
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.tools import (
|
||||
ToolsResponse,
|
||||
ToolCategory,
|
||||
ToolSubgroup,
|
||||
ToolInfo,
|
||||
ToolCommand,
|
||||
ToolStats,
|
||||
ToolSummary,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/tools", tags=["Tools"])
|
||||
|
||||
# ============================================================
|
||||
# 辅助函数
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _parse_command_from_docstring(docstring: str) -> dict:
|
||||
"""从函数的 docstring 解析参数信息"""
|
||||
params = {"type": "object", "properties": {}, "required": []}
|
||||
if not docstring:
|
||||
return params
|
||||
|
||||
# 简单解析 Args: 段落
|
||||
args_match = re.search(
|
||||
r"Args:\s*(.*?)(?=\n\s*(?:Returns?|Raises?)|$", docstring, re.DOTALL | re.IGNORECASE
|
||||
)
|
||||
if args_match:
|
||||
args_section = args_match.group(1)
|
||||
# 匹配形如 "arg_name (type): description" 的行
|
||||
for line in args_section.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# 匹配: "name (type): description" 或 "name: description"
|
||||
m = re.match(r"(\w+)\s*(?:\(\s*(\w+)\s*\))?\s*:", line)
|
||||
if m:
|
||||
param_name = m.group(1)
|
||||
params["properties"][param_name] = {"type": "string", "description": line}
|
||||
params["required"].append(param_name)
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def _build_agent_tools() -> list[ToolInfo]:
|
||||
"""扫描 app/agents/tools/ 目录,内省 @tool 装饰器"""
|
||||
tools: list[ToolInfo] = []
|
||||
|
||||
# 分类映射:文件名 -> (分类名, 子分类名)
|
||||
category_map = {
|
||||
"search": ("Agent层", "知识检索"),
|
||||
"schedule": ("Agent层", "日程管理"),
|
||||
"task": ("Agent层", "任务管理"),
|
||||
"forum": ("Agent层", "论坛功能"),
|
||||
"time_reasoning": ("Agent层", "时间推理"),
|
||||
"builtins/file_tools": ("Agent层", "文件工具"),
|
||||
"builtins/system_tools": ("Agent层", "系统命令"),
|
||||
"builtins/dev_tools": ("Agent层", "开发工具"),
|
||||
"builtins/collaboration_tools": ("Agent层", "协作工具"),
|
||||
}
|
||||
|
||||
# 工具名称 -> 中文显示名
|
||||
display_names = {
|
||||
"search_knowledge": "知识库搜索",
|
||||
"get_knowledge_graph_context": "知识图谱查询",
|
||||
"build_knowledge_graph": "构建知识图谱",
|
||||
"hybrid_search": "混合搜索",
|
||||
"web_search": "联网搜索",
|
||||
"get_schedule_day": "获取日程",
|
||||
"create_todo": "创建待办",
|
||||
"create_schedule_task": "创建日程任务",
|
||||
"create_reminder": "创建提醒",
|
||||
"create_goal": "创建目标",
|
||||
"get_tasks": "获取任务列表",
|
||||
"create_task": "创建任务",
|
||||
"update_task_status": "更新任务状态",
|
||||
"get_forum_posts": "获取论坛帖子",
|
||||
"create_forum_post": "发布论坛帖子",
|
||||
"scan_forum_for_instructions": "扫描论坛指令",
|
||||
"resolve_time_expression": "解析时间表达式",
|
||||
"glob": "文件路径匹配",
|
||||
"grep": "文件内容搜索",
|
||||
"read_file": "读取文件",
|
||||
"write_file": "写入文件",
|
||||
"bash": "Bash命令",
|
||||
"powershell": "PowerShell命令",
|
||||
"git": "Git操作",
|
||||
"lsp_tools": "LSP代码导航",
|
||||
"team_agent": "团队Agent通信",
|
||||
"task_broadcast": "任务广播",
|
||||
}
|
||||
|
||||
# 工具描述
|
||||
descriptions = {
|
||||
"search_knowledge": "搜索用户的私人知识库,返回最相关的文档片段",
|
||||
"get_knowledge_graph_context": "获取用户知识图谱的上下文信息",
|
||||
"build_knowledge_graph": "从文档构建/更新知识图谱",
|
||||
"hybrid_search": "混合搜索,结合向量语义检索和关键词匹配",
|
||||
"web_search": "通过 SearxNG 搜索外部网页信息",
|
||||
"get_schedule_day": "获取指定日期的 todo/task/reminder/goal 聚合信息",
|
||||
"create_todo": "创建指定日期的待办",
|
||||
"create_schedule_task": "创建任务,支持优先级和截止日期",
|
||||
"create_reminder": "创建提醒,支持自然语言时间",
|
||||
"create_goal": "创建指定日期的目标",
|
||||
"get_tasks": "获取用户当前的任务列表",
|
||||
"create_task": "创建新任务",
|
||||
"update_task_status": "更新任务状态",
|
||||
"get_forum_posts": "获取论坛帖子列表",
|
||||
"create_forum_post": "在论坛发布新帖子",
|
||||
"scan_forum_for_instructions": "扫描论坛中的指令类帖子",
|
||||
"resolve_time_expression": "解析中文自然语言时间表达",
|
||||
"glob": "使用 glob 模式查找文件路径",
|
||||
"grep": "在文件中搜索匹配的文本行",
|
||||
"read_file": "读取文件内容",
|
||||
"write_file": "写入文件内容",
|
||||
"bash": "执行 Bash 命令",
|
||||
"powershell": "执行 PowerShell 命令",
|
||||
"git": "执行 Git 命令",
|
||||
"lsp_tools": "LSP 代码导航和查找引用",
|
||||
"team_agent": "向团队 Agent 发送消息或请求协作",
|
||||
"task_broadcast": "向多个 Agent 广播任务",
|
||||
}
|
||||
|
||||
# 需要扫描的模块
|
||||
modules_to_scan = [
|
||||
("app.agents.tools.search", "search"),
|
||||
("app.agents.tools.schedule", "schedule"),
|
||||
("app.agents.tools.task", "task"),
|
||||
("app.agents.tools.forum", "forum"),
|
||||
("app.agents.tools.time_reasoning", "time_reasoning"),
|
||||
("app.agents.tools.builtins.file_tools", "builtins/file_tools"),
|
||||
("app.agents.tools.builtins.system_tools", "builtins/system_tools"),
|
||||
("app.agents.tools.builtins.dev_tools", "builtins/dev_tools"),
|
||||
("app.agents.tools.builtins.collaboration_tools", "builtins/collaboration_tools"),
|
||||
]
|
||||
|
||||
for module_name, category_key in modules_to_scan:
|
||||
try:
|
||||
mod = importlib.import_module(module_name)
|
||||
except ImportError:
|
||||
continue
|
||||
|
||||
# 扫描模块中所有 @tool 装饰的函数
|
||||
for attr_name in dir(mod):
|
||||
if attr_name.startswith("_"):
|
||||
continue
|
||||
attr = getattr(mod, attr_name)
|
||||
# 检查是否是 langchain @tool 装饰的对象
|
||||
if hasattr(attr, "name") and hasattr(attr, "description"):
|
||||
tool_name = attr.name
|
||||
tool_desc = attr.description or ""
|
||||
# 清理 docstring 中的参数说明用于显示
|
||||
display_desc = re.sub(r"\s*Args:\s*.*", "", tool_desc, flags=re.DOTALL).strip()
|
||||
display_desc = re.sub(
|
||||
r"\s*Returns?:\s*.*", "", display_desc, flags=re.DOTALL
|
||||
).strip()
|
||||
|
||||
# 获取 category 和 subcategory
|
||||
cat_info = category_map.get(category_key, ("Agent层", category_key))
|
||||
category, subcategory = cat_info[0], cat_info[1]
|
||||
|
||||
# 获取参数 schema
|
||||
params_schema = getattr(attr, "args_schema", None)
|
||||
parameters = {}
|
||||
if params_schema:
|
||||
try:
|
||||
if hasattr(params_schema, "model_json_schema"):
|
||||
parameters = params_schema.model_json_schema()
|
||||
elif hasattr(params_schema, "schema"):
|
||||
parameters = params_schema.schema()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tool_info = ToolInfo(
|
||||
name=tool_name,
|
||||
display_name=display_names.get(tool_name, tool_name),
|
||||
description=descriptions.get(tool_name, display_desc or tool_desc),
|
||||
category=category,
|
||||
subcategory=subcategory,
|
||||
source="agent",
|
||||
source_file=module_name,
|
||||
tags=[],
|
||||
enabled=True,
|
||||
commands=[
|
||||
ToolCommand(
|
||||
name=tool_name,
|
||||
description=tool_desc or display_desc,
|
||||
parameters=parameters,
|
||||
)
|
||||
],
|
||||
stats=ToolStats(),
|
||||
)
|
||||
tools.append(tool_info)
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def _build_manifest_tools() -> list[ToolInfo]:
|
||||
"""从 YAML manifest 构建工具信息"""
|
||||
tools: list[ToolInfo] = []
|
||||
|
||||
# manifest 文件 -> 分类映射
|
||||
manifest_map = {
|
||||
"file_operator": (
|
||||
"注册层",
|
||||
"文件操作",
|
||||
[
|
||||
ToolCommand(name="read_file", description="读取指定路径的文件内容"),
|
||||
ToolCommand(name="write_file", description="将内容写入文件"),
|
||||
ToolCommand(name="list_directory", description="列出目录内容"),
|
||||
ToolCommand(name="search_files", description="递归搜索匹配模式的文件"),
|
||||
],
|
||||
),
|
||||
"task_manager": (
|
||||
"注册层",
|
||||
"任务管理",
|
||||
[
|
||||
ToolCommand(name="create_task", description="创建新任务"),
|
||||
ToolCommand(name="list_tasks", description="列出任务"),
|
||||
ToolCommand(name="get_task", description="获取任务详情"),
|
||||
ToolCommand(name="complete_task", description="标记任务完成"),
|
||||
ToolCommand(name="fail_task", description="标记任务失败"),
|
||||
],
|
||||
),
|
||||
"web_fetch": (
|
||||
"注册层",
|
||||
"网页抓取",
|
||||
[
|
||||
ToolCommand(name="fetch", description="抓取网页内容"),
|
||||
ToolCommand(name="screenshot", description="截取网页截图"),
|
||||
],
|
||||
),
|
||||
"web_search": (
|
||||
"注册层",
|
||||
"联网搜索",
|
||||
[
|
||||
ToolCommand(name="search", description="执行语义级搜索"),
|
||||
ToolCommand(name="deep_search", description="深度搜索,带摘要生成"),
|
||||
],
|
||||
),
|
||||
}
|
||||
|
||||
manifest_descriptions = {
|
||||
"file_operator": "强大的文件系统操作工具,支持读写、搜索、下载等功能",
|
||||
"task_manager": "任务创建、查询、更新和状态管理",
|
||||
"web_fetch": "网页内容抓取工具,支持 HTML 解析、截图等功能",
|
||||
"web_search": "语义级并发搜索引擎,支持多源搜索和结果聚合",
|
||||
}
|
||||
|
||||
for tool_name, (category, subcategory, commands) in manifest_map.items():
|
||||
tool_info = ToolInfo(
|
||||
name=tool_name,
|
||||
display_name=subcategory,
|
||||
description=manifest_descriptions.get(tool_name, ""),
|
||||
category=category,
|
||||
subcategory=subcategory,
|
||||
source="manifest",
|
||||
source_file=f"app/tools/manifests/{tool_name}.yaml",
|
||||
tags=[],
|
||||
enabled=True,
|
||||
commands=commands,
|
||||
stats=ToolStats(),
|
||||
)
|
||||
tools.append(tool_info)
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 路由
|
||||
# ============================================================
|
||||
|
||||
|
||||
@router.get("", response_model=ToolsResponse)
|
||||
async def list_tools(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取所有内置工具列表(只读)"""
|
||||
# 构建工具列表
|
||||
manifest_tools = _build_manifest_tools()
|
||||
agent_tools = _build_agent_tools()
|
||||
|
||||
all_tools = manifest_tools + agent_tools
|
||||
|
||||
# 按 category 和 subcategory 分组
|
||||
category_map: dict[str, dict[str, list[ToolInfo]]] = {
|
||||
"注册层": {},
|
||||
"Agent层": {},
|
||||
}
|
||||
|
||||
for tool in all_tools:
|
||||
cat = tool.category
|
||||
subcat = tool.subcategory
|
||||
if cat not in category_map:
|
||||
category_map[cat] = {}
|
||||
if subcat not in category_map[cat]:
|
||||
category_map[cat][subcat] = []
|
||||
category_map[cat][subcat].append(tool)
|
||||
|
||||
# 构建响应
|
||||
categories = []
|
||||
for cat_name, subgroups_dict in category_map.items():
|
||||
if not subgroups_dict:
|
||||
continue
|
||||
subgroups = []
|
||||
for subcat_name, tools_list in subgroups_dict.items():
|
||||
subgroups.append(
|
||||
ToolSubgroup(
|
||||
name=subcat_name,
|
||||
display_name=subcat_name,
|
||||
tools=tools_list,
|
||||
)
|
||||
)
|
||||
categories.append(
|
||||
ToolCategory(
|
||||
name=cat_name,
|
||||
display_name=cat_name,
|
||||
subgroups=subgroups,
|
||||
)
|
||||
)
|
||||
|
||||
# 计算摘要
|
||||
total_commands = sum(len(t.commands) for t in all_tools)
|
||||
active_commands = sum(len(t.commands) for t in all_tools if t.enabled)
|
||||
|
||||
summary = ToolSummary(
|
||||
total_commands=total_commands,
|
||||
active_commands=active_commands,
|
||||
total_tools=len(all_tools),
|
||||
manifest_tools=len(manifest_tools),
|
||||
agent_tools=len(agent_tools),
|
||||
)
|
||||
|
||||
return ToolsResponse(categories=categories, summary=summary)
|
||||
@@ -1,5 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
@@ -37,6 +39,7 @@ class ChatRequest(BaseModel):
|
||||
conversation_id: str | None = None
|
||||
agent_id: str | None = None
|
||||
model_name: str | None = None
|
||||
runtime: Literal["jarvis", "hermes"] | None = None
|
||||
file_ids: list[str] = []
|
||||
|
||||
|
||||
|
||||
58
backend/app/schemas/remote_mount.py
Normal file
58
backend/app/schemas/remote_mount.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
|
||||
|
||||
class RemoteMountCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
base_url: HttpUrl
|
||||
username: str | None = Field(default=None, max_length=255)
|
||||
password: str | None = Field(default=None, max_length=2000)
|
||||
root_path: str = Field(default="/", min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class RemoteMountOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
mount_type: str
|
||||
base_url: str
|
||||
username: str | None
|
||||
root_path: str
|
||||
is_active: bool
|
||||
last_sync_at: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RemoteNodeOut(BaseModel):
|
||||
path: str
|
||||
name: str
|
||||
is_dir: bool
|
||||
size: int | None = None
|
||||
modified_at: str | None = None
|
||||
etag: str | None = None
|
||||
children: list["RemoteNodeOut"] = []
|
||||
|
||||
|
||||
class RemoteMountTreeOut(BaseModel):
|
||||
mount_id: str
|
||||
root_path: str
|
||||
nodes: list[RemoteNodeOut]
|
||||
|
||||
|
||||
class RemoteSyncRequest(BaseModel):
|
||||
remote_path: str = Field(..., min_length=1, max_length=2000)
|
||||
local_folder_id: str = Field(..., min_length=1, max_length=36)
|
||||
mode: str = Field(default="file", pattern="^(file|folder)$")
|
||||
|
||||
|
||||
class RemoteSyncResultOut(BaseModel):
|
||||
synced: int
|
||||
skipped: int
|
||||
failed: int
|
||||
document_ids: list[str]
|
||||
errors: list[str]
|
||||
|
||||
|
||||
RemoteNodeOut.model_rebuild()
|
||||
@@ -1,7 +1,8 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models.task import TaskDispatchStatus, TaskPriority, TaskQuadrant, TaskStatus
|
||||
from app.schemas.goal import GoalOut
|
||||
from app.schemas.reminder import ReminderOut
|
||||
from app.schemas.task import TaskOut
|
||||
@@ -18,6 +19,47 @@ class ScheduleCenterDaySummary(BaseModel):
|
||||
goal_total: int
|
||||
|
||||
|
||||
class ScheduleCenterFocusTaskOut(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
status: TaskStatus
|
||||
priority: TaskPriority
|
||||
quadrant: TaskQuadrant | None = None
|
||||
assignee_type: str | None = None
|
||||
assignee_id: str | None = None
|
||||
dispatch_status: TaskDispatchStatus
|
||||
due_date: datetime | None = None
|
||||
|
||||
|
||||
class ScheduleCenterQuadrantTaskOut(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
status: TaskStatus
|
||||
priority: TaskPriority
|
||||
dispatch_status: TaskDispatchStatus
|
||||
assignee_type: str | None = None
|
||||
assignee_id: str | None = None
|
||||
|
||||
|
||||
class ScheduleCenterQuadrantOut(BaseModel):
|
||||
id: TaskQuadrant
|
||||
title: str
|
||||
subtitle: str
|
||||
color: str
|
||||
glow_color: str
|
||||
icon: str
|
||||
tasks: list[ScheduleCenterQuadrantTaskOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ScheduleCenterCommanderSummaryOut(BaseModel):
|
||||
total: int = 0
|
||||
queued: int = 0
|
||||
running: int = 0
|
||||
completed: int = 0
|
||||
failed: int = 0
|
||||
overall_status: str | None = None
|
||||
|
||||
|
||||
class ScheduleCenterMonthOut(BaseModel):
|
||||
month: str
|
||||
days: list[ScheduleCenterDaySummary]
|
||||
@@ -30,4 +72,9 @@ class ScheduleCenterDateOut(BaseModel):
|
||||
reminders: list[ReminderOut]
|
||||
goals: list[GoalOut]
|
||||
summary: ScheduleCenterDaySummary
|
||||
focus_tasks: list[ScheduleCenterFocusTaskOut] = Field(default_factory=list)
|
||||
quadrants: list[ScheduleCenterQuadrantOut] = Field(default_factory=list)
|
||||
commander_summary: ScheduleCenterCommanderSummaryOut = Field(
|
||||
default_factory=ScheduleCenterCommanderSummaryOut,
|
||||
)
|
||||
generated_at: datetime
|
||||
|
||||
@@ -14,6 +14,10 @@ class SkillCreate(BaseModel):
|
||||
visibility: str = "private"
|
||||
team_id: Optional[str] = None
|
||||
is_active: bool = True
|
||||
status: str = "active"
|
||||
scope: list[str] = []
|
||||
effectiveness: Optional[float] = None
|
||||
review_after: Optional[datetime] = None
|
||||
|
||||
|
||||
class SkillUpdate(BaseModel):
|
||||
@@ -28,6 +32,10 @@ class SkillUpdate(BaseModel):
|
||||
visibility: Optional[str] = None
|
||||
team_id: Optional[str] = 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):
|
||||
@@ -43,6 +51,12 @@ class SkillOut(BaseModel):
|
||||
is_builtin: bool
|
||||
team_id: Optional[str]
|
||||
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
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -1,14 +1,146 @@
|
||||
from pydantic import BaseModel
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from app.models.task import TaskStatus, TaskPriority
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.orm.attributes import NO_VALUE
|
||||
|
||||
from app.models.task import (
|
||||
Task,
|
||||
TaskAssigneeType,
|
||||
TaskDispatchStatus,
|
||||
TaskHistory,
|
||||
TaskPriority,
|
||||
TaskQuadrant,
|
||||
TaskSource,
|
||||
TaskStatus,
|
||||
TaskSubTask,
|
||||
)
|
||||
|
||||
|
||||
def _coerce_enum(value, enum_cls, default=None):
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, enum_cls):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
raw = value.strip()
|
||||
if not raw:
|
||||
return default
|
||||
for item in enum_cls:
|
||||
if raw == item.value or raw.lower() == item.value:
|
||||
return item
|
||||
if raw.upper() == item.name:
|
||||
return item
|
||||
return default
|
||||
|
||||
|
||||
def parse_tags(raw_tags: str | None) -> list[str]:
|
||||
if not raw_tags:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(raw_tags)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(parsed, list):
|
||||
return []
|
||||
return [str(item) for item in parsed]
|
||||
|
||||
|
||||
def serialize_tags(tags: list[str] | None) -> str | None:
|
||||
if not tags:
|
||||
return None
|
||||
return json.dumps([str(item) for item in tags], ensure_ascii=False)
|
||||
|
||||
|
||||
class TaskSubTaskCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: TaskStatus = TaskStatus.TODO
|
||||
order_index: int | None = None
|
||||
assignee_type: TaskAssigneeType | None = None
|
||||
assignee_id: str | None = None
|
||||
|
||||
|
||||
class TaskSubTaskUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: TaskStatus | None = None
|
||||
order_index: int | None = None
|
||||
assignee_type: TaskAssigneeType | None = None
|
||||
assignee_id: str | None = None
|
||||
dispatch_status: TaskDispatchStatus | None = None
|
||||
dispatch_run_id: str | None = None
|
||||
result_summary: str | None = None
|
||||
|
||||
|
||||
class TaskSubTaskReorderItem(BaseModel):
|
||||
id: str
|
||||
order_index: int
|
||||
|
||||
|
||||
class TaskSubTaskReorderRequest(BaseModel):
|
||||
items: list[TaskSubTaskReorderItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TaskSubTaskOut(BaseModel):
|
||||
id: str
|
||||
task_id: str
|
||||
title: str
|
||||
description: str | None
|
||||
status: TaskStatus
|
||||
order_index: int
|
||||
assignee_type: TaskAssigneeType | None
|
||||
assignee_id: str | None
|
||||
dispatch_status: TaskDispatchStatus
|
||||
dispatch_run_id: str | None
|
||||
result_summary: str | None = None
|
||||
completed_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TaskHistoryOut(BaseModel):
|
||||
id: str
|
||||
task_id: str
|
||||
action: str
|
||||
old_value: str | None
|
||||
new_value: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TaskDispatchSummary(BaseModel):
|
||||
status: TaskDispatchStatus
|
||||
run_id: str | None = None
|
||||
result_summary: str | None = None
|
||||
started_at: datetime | None = None
|
||||
last_synced_at: datetime | None = None
|
||||
total_subtasks: int = 0
|
||||
dispatched_subtasks: int = 0
|
||||
subtask_dispatch_statuses: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: TaskStatus = TaskStatus.TODO
|
||||
priority: TaskPriority = TaskPriority.MEDIUM
|
||||
due_date: datetime | None = None
|
||||
tags: list[str] | None = None
|
||||
source: TaskSource = TaskSource.MANUAL
|
||||
conversation_id: str | None = None
|
||||
quadrant: TaskQuadrant | None = None
|
||||
assignee_type: TaskAssigneeType | None = None
|
||||
assignee_id: str | None = None
|
||||
subtasks: list[TaskSubTaskCreate] = Field(default_factory=list)
|
||||
dispatch_to_commander: bool = False
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
@@ -18,6 +150,16 @@ class TaskUpdate(BaseModel):
|
||||
priority: TaskPriority | None = None
|
||||
due_date: datetime | None = None
|
||||
tags: list[str] | None = None
|
||||
source: TaskSource | None = None
|
||||
conversation_id: str | None = None
|
||||
quadrant: TaskQuadrant | None = None
|
||||
assignee_type: TaskAssigneeType | None = None
|
||||
assignee_id: str | None = None
|
||||
dispatch_status: TaskDispatchStatus | None = None
|
||||
dispatch_run_id: str | None = None
|
||||
result_summary: str | None = None
|
||||
started_at: datetime | None = None
|
||||
last_synced_at: datetime | None = None
|
||||
|
||||
|
||||
class TaskOut(BaseModel):
|
||||
@@ -28,12 +170,128 @@ class TaskOut(BaseModel):
|
||||
priority: TaskPriority
|
||||
due_date: datetime | None
|
||||
completed_at: datetime | None
|
||||
tags: str | None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
source: TaskSource
|
||||
conversation_id: str | None
|
||||
quadrant: TaskQuadrant | None
|
||||
assignee_type: TaskAssigneeType | None
|
||||
assignee_id: str | None
|
||||
dispatch_status: TaskDispatchStatus
|
||||
dispatch_run_id: str | None
|
||||
result_summary: str | None
|
||||
started_at: datetime | None
|
||||
last_synced_at: datetime | None
|
||||
subtask_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
class TaskDetailOut(TaskOut):
|
||||
subtasks: list[TaskSubTaskOut] = Field(default_factory=list)
|
||||
history: list[TaskHistoryOut] = Field(default_factory=list)
|
||||
dispatch: TaskDispatchSummary
|
||||
dispatch_summary: TaskDispatchSummary
|
||||
|
||||
|
||||
|
||||
|
||||
class TaskDispatchRequest(BaseModel):
|
||||
target: str = "commander"
|
||||
conversation_id: str | None = None
|
||||
assignee_type: TaskAssigneeType | None = None
|
||||
assignee_id: str | None = None
|
||||
|
||||
|
||||
class TaskDispatchResponse(BaseModel):
|
||||
status: TaskDispatchStatus
|
||||
run_id: str | None = None
|
||||
task: TaskDetailOut
|
||||
payload: dict[str, object] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DailyPlanRequest(BaseModel):
|
||||
user_id: str
|
||||
|
||||
|
||||
def build_task_out(task: Task) -> TaskOut:
|
||||
subtasks_attr = inspect(task).attrs.subtasks.loaded_value
|
||||
return TaskOut(
|
||||
id=task.id,
|
||||
title=task.title,
|
||||
description=task.description,
|
||||
status=_coerce_enum(task.status, TaskStatus, TaskStatus.TODO),
|
||||
priority=_coerce_enum(task.priority, TaskPriority, TaskPriority.MEDIUM),
|
||||
due_date=task.due_date,
|
||||
completed_at=task.completed_at,
|
||||
tags=parse_tags(task.tags),
|
||||
source=_coerce_enum(task.source, TaskSource, TaskSource.MANUAL),
|
||||
conversation_id=task.conversation_id,
|
||||
quadrant=_coerce_enum(task.quadrant, TaskQuadrant, None),
|
||||
assignee_type=_coerce_enum(task.assignee_type, TaskAssigneeType, None),
|
||||
assignee_id=task.assignee_id,
|
||||
dispatch_status=_coerce_enum(task.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE),
|
||||
dispatch_run_id=task.dispatch_run_id,
|
||||
result_summary=task.result_summary,
|
||||
started_at=task.started_at,
|
||||
last_synced_at=task.last_synced_at,
|
||||
subtask_count=0 if subtasks_attr is NO_VALUE else len(subtasks_attr or []),
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def build_task_detail_out(task: Task) -> TaskDetailOut:
|
||||
normalized_task_dispatch = _coerce_enum(task.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE)
|
||||
|
||||
normalized_subtasks = [
|
||||
TaskSubTaskOut(
|
||||
id=item.id,
|
||||
task_id=item.task_id,
|
||||
title=item.title,
|
||||
description=item.description,
|
||||
status=_coerce_enum(item.status, TaskStatus, TaskStatus.TODO),
|
||||
order_index=item.order_index,
|
||||
assignee_type=_coerce_enum(item.assignee_type, TaskAssigneeType, None),
|
||||
assignee_id=item.assignee_id,
|
||||
dispatch_status=_coerce_enum(item.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE),
|
||||
dispatch_run_id=item.dispatch_run_id,
|
||||
result_summary=item.result_summary,
|
||||
completed_at=item.completed_at,
|
||||
created_at=item.created_at,
|
||||
updated_at=item.updated_at,
|
||||
)
|
||||
for item in task.subtasks
|
||||
]
|
||||
|
||||
subtask_dispatch_statuses: dict[str, int] = {}
|
||||
for item in normalized_subtasks:
|
||||
key = item.dispatch_status.value
|
||||
subtask_dispatch_statuses[key] = subtask_dispatch_statuses.get(key, 0) + 1
|
||||
|
||||
dispatched_subtasks = sum(1 for item in normalized_subtasks if item.dispatch_status != TaskDispatchStatus.IDLE)
|
||||
|
||||
return TaskDetailOut(
|
||||
**build_task_out(task).model_dump(),
|
||||
subtasks=normalized_subtasks,
|
||||
history=[TaskHistoryOut.model_validate(item) for item in task.history],
|
||||
dispatch=TaskDispatchSummary(
|
||||
status=normalized_task_dispatch,
|
||||
run_id=task.dispatch_run_id,
|
||||
result_summary=task.result_summary,
|
||||
started_at=task.started_at,
|
||||
last_synced_at=task.last_synced_at,
|
||||
total_subtasks=len(normalized_subtasks),
|
||||
dispatched_subtasks=dispatched_subtasks,
|
||||
subtask_dispatch_statuses=subtask_dispatch_statuses,
|
||||
),
|
||||
dispatch_summary=TaskDispatchSummary(
|
||||
status=normalized_task_dispatch,
|
||||
run_id=task.dispatch_run_id,
|
||||
result_summary=task.result_summary,
|
||||
started_at=task.started_at,
|
||||
last_synced_at=task.last_synced_at,
|
||||
total_subtasks=len(normalized_subtasks),
|
||||
dispatched_subtasks=dispatched_subtasks,
|
||||
subtask_dispatch_statuses=subtask_dispatch_statuses,
|
||||
),
|
||||
)
|
||||
|
||||
76
backend/app/schemas/tools.py
Normal file
76
backend/app/schemas/tools.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Tools API Schemas"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ToolCommand(BaseModel):
|
||||
"""单个工具命令"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
parameters: dict = {}
|
||||
|
||||
|
||||
class ToolStats(BaseModel):
|
||||
"""工具调用统计"""
|
||||
|
||||
call_count: int = 0
|
||||
error_count: int = 0
|
||||
total_duration_ms: int = 0
|
||||
avg_duration_ms: int = 0
|
||||
error_rate: float = 0.0
|
||||
|
||||
|
||||
class ToolInfo(BaseModel):
|
||||
"""工具完整信息"""
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
description: str
|
||||
category: str # 中文分类名
|
||||
subcategory: str = "" # 子分类
|
||||
source: str # "manifest" | "agent"
|
||||
source_file: str = "" # 来源文件路径
|
||||
tags: list[str] = []
|
||||
enabled: bool = True
|
||||
commands: list[ToolCommand] = []
|
||||
stats: Optional[ToolStats] = None
|
||||
config: dict = {} # 配置参数(只读)
|
||||
|
||||
|
||||
class ToolCategory(BaseModel):
|
||||
"""工具分类"""
|
||||
|
||||
name: str # 大分类:注册层 / Agent层
|
||||
display_name: str # 中文显示名
|
||||
subgroups: list["ToolSubgroup"] = []
|
||||
|
||||
|
||||
class ToolSubgroup(BaseModel):
|
||||
"""工具子分类"""
|
||||
|
||||
name: str # 子分类名
|
||||
display_name: str # 中文显示名
|
||||
tools: list[ToolInfo] = []
|
||||
|
||||
|
||||
class ToolSummary(BaseModel):
|
||||
"""工具统计摘要"""
|
||||
|
||||
total_commands: int = 0
|
||||
active_commands: int = 0
|
||||
total_tools: int = 0
|
||||
manifest_tools: int = 0
|
||||
agent_tools: int = 0
|
||||
|
||||
|
||||
class ToolsResponse(BaseModel):
|
||||
"""GET /api/tools 响应"""
|
||||
|
||||
categories: list[ToolCategory]
|
||||
summary: ToolSummary
|
||||
|
||||
|
||||
# 更新前向引用
|
||||
ToolCategory.model_rebuild()
|
||||
9
backend/app/services/agent_runtime/__init__.py
Normal file
9
backend/app/services/agent_runtime/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from app.services.agent_runtime.hermes_runtime import HermesRuntimeAdapter, hermes_runtime_adapter
|
||||
from app.services.agent_runtime.jarvis_runtime import JarvisRuntimeAdapter, jarvis_runtime_adapter
|
||||
|
||||
__all__ = [
|
||||
"HermesRuntimeAdapter",
|
||||
"hermes_runtime_adapter",
|
||||
"JarvisRuntimeAdapter",
|
||||
"jarvis_runtime_adapter",
|
||||
]
|
||||
37
backend/app/services/agent_runtime/base.py
Normal file
37
backend/app/services/agent_runtime/base.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, AsyncGenerator, Protocol
|
||||
|
||||
from app.models.conversation import Conversation, Message
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
RuntimeName = str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RuntimePreparedContext:
|
||||
user: User
|
||||
conversation: Conversation
|
||||
user_message: Message
|
||||
assistant_message: Message
|
||||
raw_message: str
|
||||
full_message: str
|
||||
file_ids: list[str]
|
||||
model_name: str | None
|
||||
memory_context: str | None
|
||||
|
||||
|
||||
class ChatRuntime(Protocol):
|
||||
name: RuntimeName
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
prepared: RuntimePreparedContext,
|
||||
) -> AsyncGenerator[dict[str, Any], None]: ...
|
||||
|
||||
async def chat_once(
|
||||
self,
|
||||
prepared: RuntimePreparedContext,
|
||||
) -> tuple[str, str | None]: ...
|
||||
172
backend/app/services/agent_runtime/hermes_runtime.py
Normal file
172
backend/app/services/agent_runtime/hermes_runtime.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
from app.services.agent_runtime.base import ChatRuntime, RuntimePreparedContext
|
||||
from app.services.agent_runtime.hermes_session_manager import hermes_session_manager
|
||||
|
||||
|
||||
class HermesRuntimeAdapter(ChatRuntime):
|
||||
name = "hermes"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._repo_path = Path(__file__).resolve().parents[4] / ".tmp" / "hermes-agent"
|
||||
self._agent_class = None
|
||||
|
||||
def probe(self) -> dict[str, Any]:
|
||||
cli_path = self._repo_path / "cli.py"
|
||||
run_agent_path = self._repo_path / "run_agent.py"
|
||||
return {
|
||||
"repo_path": str(self._repo_path),
|
||||
"repo_exists": self._repo_path.exists(),
|
||||
"cli_exists": cli_path.exists(),
|
||||
"run_agent_exists": run_agent_path.exists(),
|
||||
"supports_single_query": True,
|
||||
"supports_resume": True,
|
||||
"integration_mode": "python_ai_agent_bridge",
|
||||
}
|
||||
|
||||
def _load_agent_class(self):
|
||||
if self._agent_class is not None:
|
||||
return self._agent_class
|
||||
|
||||
run_agent_path = self._repo_path / "run_agent.py"
|
||||
if not run_agent_path.exists():
|
||||
raise RuntimeError(f"Hermes run_agent.py 未找到: {run_agent_path}")
|
||||
|
||||
repo_path = str(self._repo_path)
|
||||
if repo_path not in sys.path:
|
||||
sys.path.insert(0, repo_path)
|
||||
|
||||
spec = importlib.util.spec_from_file_location("jarvis_hermes_run_agent", run_agent_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("无法加载 Hermes run_agent 模块")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
self._agent_class = getattr(module, "AIAgent")
|
||||
return self._agent_class
|
||||
|
||||
def _build_agent(self, prepared: RuntimePreparedContext, session_id: str):
|
||||
agent_class = self._load_agent_class()
|
||||
kwargs: dict[str, Any] = {
|
||||
"session_id": session_id,
|
||||
"platform": "jarvis",
|
||||
"user_id": prepared.user.id,
|
||||
"quiet_mode": True,
|
||||
"persist_session": True,
|
||||
"skip_context_files": True,
|
||||
"max_iterations": 30,
|
||||
}
|
||||
if prepared.model_name:
|
||||
kwargs["model"] = prepared.model_name
|
||||
return agent_class(**kwargs)
|
||||
|
||||
def _build_system_message(self, prepared: RuntimePreparedContext) -> str:
|
||||
parts = [
|
||||
"You are Hermes running inside the Jarvis chat runtime.",
|
||||
"Return normal assistant text for the user. Do not mention internal bridge details unless asked.",
|
||||
]
|
||||
if prepared.memory_context:
|
||||
parts.append(prepared.memory_context)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
prepared: RuntimePreparedContext,
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
handle = hermes_session_manager.get_or_create(
|
||||
conversation_id=prepared.conversation.id,
|
||||
user_id=prepared.user.id,
|
||||
)
|
||||
async with handle.lock:
|
||||
yield {
|
||||
"type": "progress",
|
||||
"stage": "planning",
|
||||
"label": "Hermes 正在准备会话",
|
||||
"agent": "hermes",
|
||||
"step": "加载 Hermes runtime",
|
||||
"steps": [
|
||||
"恢复会话上下文",
|
||||
"调用 Hermes AIAgent",
|
||||
"回传流式回复",
|
||||
],
|
||||
}
|
||||
|
||||
queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()
|
||||
loop = asyncio.get_running_loop()
|
||||
result_box: dict[str, Any] = {"content": None, "error": None, "model": prepared.model_name or "hermes"}
|
||||
|
||||
def stream_callback(delta: str) -> None:
|
||||
loop.call_soon_threadsafe(queue.put_nowait, {"type": "chunk", "content": delta})
|
||||
|
||||
def run_sync() -> None:
|
||||
try:
|
||||
agent = self._build_agent(prepared, handle.hermes_session_id)
|
||||
result = agent.run_conversation(
|
||||
prepared.full_message,
|
||||
system_message=self._build_system_message(prepared),
|
||||
stream_callback=stream_callback,
|
||||
)
|
||||
result_box["content"] = str(result.get("final_response") or "")
|
||||
result_box["model"] = getattr(agent, "model", prepared.model_name or "hermes")
|
||||
except Exception as exc: # pragma: no cover - surfaced through queue
|
||||
result_box["error"] = f"Hermes 执行失败: {exc}"
|
||||
loop.call_soon_threadsafe(
|
||||
queue.put_nowait,
|
||||
{"type": "error", "error": result_box["error"]},
|
||||
)
|
||||
finally:
|
||||
loop.call_soon_threadsafe(queue.put_nowait, None)
|
||||
|
||||
worker = asyncio.create_task(asyncio.to_thread(run_sync))
|
||||
streamed_text = ""
|
||||
while True:
|
||||
event = await queue.get()
|
||||
if event is None:
|
||||
break
|
||||
if event.get("type") == "chunk":
|
||||
streamed_text += str(event.get("content", ""))
|
||||
yield event
|
||||
|
||||
await worker
|
||||
handle.last_used_at = datetime.now(UTC)
|
||||
handle.metadata = {
|
||||
"session_id": handle.hermes_session_id,
|
||||
"model": result_box["model"],
|
||||
"last_error": result_box["error"],
|
||||
}
|
||||
|
||||
final_text = result_box["content"] or streamed_text
|
||||
if final_text and final_text != streamed_text:
|
||||
yield {"type": "chunk", "content": final_text}
|
||||
|
||||
async def chat_once(self, prepared: RuntimePreparedContext) -> tuple[str, str | None]:
|
||||
handle = hermes_session_manager.get_or_create(
|
||||
conversation_id=prepared.conversation.id,
|
||||
user_id=prepared.user.id,
|
||||
)
|
||||
|
||||
async with handle.lock:
|
||||
agent = await asyncio.to_thread(self._build_agent, prepared, handle.hermes_session_id)
|
||||
result = await asyncio.to_thread(
|
||||
agent.run_conversation,
|
||||
prepared.full_message,
|
||||
self._build_system_message(prepared),
|
||||
)
|
||||
handle.last_used_at = datetime.now(UTC)
|
||||
resolved_model = getattr(agent, "model", prepared.model_name or "hermes")
|
||||
handle.metadata = {
|
||||
"session_id": handle.hermes_session_id,
|
||||
"model": resolved_model,
|
||||
"last_error": None,
|
||||
}
|
||||
return str(result.get("final_response") or ""), resolved_model
|
||||
|
||||
|
||||
hermes_runtime_adapter = HermesRuntimeAdapter()
|
||||
37
backend/app/services/agent_runtime/hermes_session_manager.py
Normal file
37
backend/app/services/agent_runtime/hermes_session_manager.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class HermesSessionHandle:
|
||||
conversation_id: str
|
||||
user_id: str
|
||||
hermes_session_id: str
|
||||
last_used_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
restart_count: int = 0
|
||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class HermesSessionManager:
|
||||
def __init__(self) -> None:
|
||||
self._sessions: dict[str, HermesSessionHandle] = {}
|
||||
|
||||
def get_or_create(self, *, conversation_id: str, user_id: str) -> HermesSessionHandle:
|
||||
handle = self._sessions.get(conversation_id)
|
||||
if handle is None:
|
||||
handle = HermesSessionHandle(
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
hermes_session_id=f"jarvis-{conversation_id}",
|
||||
)
|
||||
self._sessions[conversation_id] = handle
|
||||
handle.last_used_at = datetime.now(UTC)
|
||||
return handle
|
||||
|
||||
|
||||
hermes_session_manager = HermesSessionManager()
|
||||
21
backend/app/services/agent_runtime/jarvis_runtime.py
Normal file
21
backend/app/services/agent_runtime/jarvis_runtime.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
from app.services.agent_runtime.base import ChatRuntime, RuntimePreparedContext
|
||||
|
||||
|
||||
class JarvisRuntimeAdapter(ChatRuntime):
|
||||
name = "jarvis"
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
prepared: RuntimePreparedContext,
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
raise NotImplementedError("Jarvis runtime is executed inside AgentService")
|
||||
|
||||
async def chat_once(self, prepared: RuntimePreparedContext) -> tuple[str, str | None]:
|
||||
raise NotImplementedError("Jarvis runtime is executed inside AgentService")
|
||||
|
||||
|
||||
jarvis_runtime_adapter = JarvisRuntimeAdapter()
|
||||
@@ -7,12 +7,13 @@ import json
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from time import perf_counter
|
||||
from typing import Any, AsyncGenerator
|
||||
import asyncio
|
||||
from openai import BadRequestError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from langchain_core.messages import HumanMessage, AIMessage
|
||||
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
|
||||
|
||||
from app.database import async_session
|
||||
from app.logging_utils import summarize_llm_config
|
||||
@@ -21,12 +22,29 @@ from app.models.conversation import Conversation, Message
|
||||
from app.models.user import User
|
||||
from app.agents.graph import get_agent_graph
|
||||
from app.agents.context import set_current_user, clear_current_user
|
||||
from app.agents.learning.jobs import schedule_retrospective_job
|
||||
from app.agents.learning.retrospector import build_session_retrospective
|
||||
from app.agents.learning.session_search import SessionRetrospectiveSearch, summarize_retrospective
|
||||
from app.agents.orchestration.task_graph import build_bounded_task_graph
|
||||
from app.agents.learning.store import append_retrospective_attachment
|
||||
from app.agents.schemas.orchestration import (
|
||||
RuntimeRequestContext,
|
||||
assess_parallel_worthiness,
|
||||
render_runtime_request_context_summary,
|
||||
)
|
||||
from app.agents.schemas.skills import SkillActivationRecord
|
||||
from app.agents.skills.registry import get_skill_registry
|
||||
from app.agents.skills.retriever import shortlist_skills_for_request
|
||||
from app.services import memory_service
|
||||
from app.services.brain_service import BrainService
|
||||
from app.services.llm_service import create_llm_from_config, resolve_provider_capabilities
|
||||
from app.services.rollback_controller import RollbackController
|
||||
from app.services.runtime_observability import build_runtime_observability_report
|
||||
from app.agents.tools.time_reasoning import extract_reference_datetime
|
||||
from app.agents.state import initial_state
|
||||
from app.services.agent_runtime.base import RuntimePreparedContext
|
||||
from app.services.agent_runtime.hermes_runtime import hermes_runtime_adapter
|
||||
from app.services.agent_runtime.hermes_session_manager import hermes_session_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,6 +54,7 @@ MEMORY_SECTION_HEADERS = (
|
||||
"【之前对话摘要】",
|
||||
"【知识大脑】",
|
||||
)
|
||||
MEMORY_INLINE_HEADERS = {"[关于你的记忆]"}
|
||||
|
||||
|
||||
def _split_memory_context_sections(memory_context: str | None) -> dict[str, str]:
|
||||
@@ -81,6 +100,41 @@ def _derive_role_memory_contexts(memory_context: str | None) -> dict[str, str |
|
||||
}
|
||||
|
||||
|
||||
def _extract_memory_highlights(memory_context: str | None, *, limit: int = 5) -> list[str]:
|
||||
text = (memory_context or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
|
||||
highlights: list[str] = []
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line in MEMORY_SECTION_HEADERS or line in MEMORY_INLINE_HEADERS:
|
||||
continue
|
||||
if line.startswith("-"):
|
||||
normalized = line[1:].strip()
|
||||
else:
|
||||
normalized = line
|
||||
if normalized:
|
||||
highlights.append(normalized)
|
||||
if len(highlights) >= limit:
|
||||
break
|
||||
return highlights
|
||||
|
||||
|
||||
def _summarize_retrospective(retrospective: Any) -> str:
|
||||
summary = str(getattr(retrospective, "summary", "") or "").strip()
|
||||
task_type = str(getattr(retrospective, "task_type", "") or "").strip()
|
||||
execution_mode = str(getattr(retrospective, "execution_mode", "") or "").strip()
|
||||
outcome = str(getattr(retrospective, "outcome", "") or "").strip()
|
||||
|
||||
parts = [summary[:80] or task_type or "历史复盘"]
|
||||
if execution_mode:
|
||||
parts.append(f"mode={execution_mode}")
|
||||
if outcome:
|
||||
parts.append(f"outcome={outcome}")
|
||||
return ";".join(parts)
|
||||
|
||||
|
||||
def _is_streaming_rejection_error(error: Exception, user_llm_config: dict | None) -> bool:
|
||||
capabilities = resolve_provider_capabilities(user_llm_config)
|
||||
error_text = str(error).lower()
|
||||
@@ -327,6 +381,9 @@ class AgentService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
def _resolve_runtime(self, runtime: str | None) -> str:
|
||||
return runtime or "jarvis"
|
||||
|
||||
async def _try_auto_summarize_background(self, user_id: str, conversation_id: str) -> None:
|
||||
async with async_session() as session:
|
||||
await memory_service.try_auto_summarize(session, user_id, conversation_id)
|
||||
@@ -461,18 +518,27 @@ class AgentService:
|
||||
async def _build_agent_state(
|
||||
self,
|
||||
*,
|
||||
request_id: str,
|
||||
user_id: str,
|
||||
conversation: Conversation,
|
||||
raw_user_query: str,
|
||||
full_message: str,
|
||||
memory_context: str | None,
|
||||
current_datetime_context: str,
|
||||
current_datetime_reference: dict[str, str],
|
||||
user_llm_config: dict | None,
|
||||
runtime_request_context: RuntimeRequestContext,
|
||||
recalled_retrospectives: list[dict[str, Any]],
|
||||
skill_shortlist: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
state = initial_state(user_id, conversation.id)
|
||||
runtime_summary = render_runtime_request_context_summary(runtime_request_context)
|
||||
state.update(
|
||||
{
|
||||
"messages": [HumanMessage(content=full_message)],
|
||||
"messages": [
|
||||
SystemMessage(content=runtime_summary),
|
||||
HumanMessage(content=full_message),
|
||||
],
|
||||
"memory_context": memory_context,
|
||||
"current_datetime_context": current_datetime_context,
|
||||
"current_datetime_reference": current_datetime_reference,
|
||||
@@ -482,9 +548,119 @@ class AgentService:
|
||||
previous_snapshot = await self._load_continuity_snapshot(conversation)
|
||||
if previous_snapshot:
|
||||
state.update(previous_snapshot)
|
||||
state["messages"] = [HumanMessage(content=full_message)]
|
||||
state["messages"] = [
|
||||
SystemMessage(content=runtime_summary),
|
||||
HumanMessage(content=full_message),
|
||||
]
|
||||
state.update(
|
||||
{
|
||||
"runtime_request_context": runtime_request_context.model_dump(mode="json"),
|
||||
"task_graph": (
|
||||
runtime_request_context.task_graph.model_dump(mode="json")
|
||||
if runtime_request_context.task_graph is not None
|
||||
else None
|
||||
),
|
||||
"feature_flags": RollbackController().snapshot_flags(),
|
||||
"recalled_retrospectives": recalled_retrospectives,
|
||||
"retrospective_shortlist": recalled_retrospectives,
|
||||
"skill_shortlist": skill_shortlist,
|
||||
"skill_activation_records": [
|
||||
SkillActivationRecord(
|
||||
skill_name=item.get("skill_name"),
|
||||
source=item.get("source", "runtime"),
|
||||
source_id=item.get("source_id"),
|
||||
status=item.get("status", "active"),
|
||||
injection_mode=item.get("injection_mode", "metadata_only"),
|
||||
matched_terms=item.get("matched_terms", []),
|
||||
rationale=item.get("rationale"),
|
||||
).model_dump(mode="json")
|
||||
for item in skill_shortlist
|
||||
if item.get("skill_name")
|
||||
],
|
||||
"parallel_worthiness": runtime_request_context.parallel_worthiness.model_dump(
|
||||
mode="json"
|
||||
),
|
||||
}
|
||||
)
|
||||
return state
|
||||
|
||||
async def _build_runtime_request_context(
|
||||
self,
|
||||
*,
|
||||
request_id: str,
|
||||
user_id: str,
|
||||
conversation: Conversation,
|
||||
user_query: str,
|
||||
memory_context: str | None,
|
||||
) -> tuple[RuntimeRequestContext, list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
started_at = perf_counter()
|
||||
retrospectives_started = perf_counter()
|
||||
recent_retrospectives = await SessionRetrospectiveSearch(self.db).shortlist(
|
||||
user_id=user_id,
|
||||
query_text=user_query,
|
||||
conversation_id=conversation.id,
|
||||
limit=3,
|
||||
)
|
||||
retrospective_ms = (perf_counter() - retrospectives_started) * 1000
|
||||
feature_flags = RollbackController().snapshot_flags()
|
||||
shortlist_started = perf_counter()
|
||||
skill_shortlist = await shortlist_skills_for_request(
|
||||
self.db,
|
||||
user_id=user_id,
|
||||
user_query=user_query,
|
||||
memory_context=memory_context,
|
||||
retrospectives=[item.model_dump(mode="json") for item in recent_retrospectives],
|
||||
include_learned=feature_flags["ENABLE_LEARNED_SKILL_LOADING"],
|
||||
limit=4,
|
||||
)
|
||||
skill_shortlist_ms = (perf_counter() - shortlist_started) * 1000
|
||||
parallel_worthiness = assess_parallel_worthiness(
|
||||
user_query,
|
||||
retrospective_count=len(recent_retrospectives),
|
||||
skill_count=len(skill_shortlist),
|
||||
)
|
||||
recommended_runtime_mode = (
|
||||
"collaboration" if parallel_worthiness.preferred_mode != "direct" else "direct"
|
||||
)
|
||||
task_graph = (
|
||||
build_bounded_task_graph(
|
||||
query_text=user_query,
|
||||
parallel_worthiness=parallel_worthiness,
|
||||
)
|
||||
if feature_flags["ENABLE_PARALLEL_TASK_GRAPH"]
|
||||
else None
|
||||
)
|
||||
runtime_request_context = RuntimeRequestContext(
|
||||
request_id=request_id,
|
||||
session_id=conversation.id,
|
||||
conversation_id=conversation.id,
|
||||
user_id=user_id,
|
||||
query_text=user_query,
|
||||
raw_user_query=user_query,
|
||||
recalled_memories=_extract_memory_highlights(memory_context),
|
||||
recalled_retrospectives=[
|
||||
summarize_retrospective(retrospective) for retrospective in recent_retrospectives
|
||||
],
|
||||
shortlisted_skills=[entry.skill_name for entry in skill_shortlist],
|
||||
skill_shortlist=skill_shortlist,
|
||||
current_agent_role="master",
|
||||
execution_mode=recommended_runtime_mode,
|
||||
conversation_state_ref=conversation.id,
|
||||
parallel_worthiness=parallel_worthiness,
|
||||
task_graph=task_graph,
|
||||
recommended_runtime_mode=recommended_runtime_mode,
|
||||
assembly_metrics={
|
||||
"retrospective_ms": round(retrospective_ms, 3),
|
||||
"skill_shortlist_ms": round(skill_shortlist_ms, 3),
|
||||
"total_ms": round((perf_counter() - started_at) * 1000, 3),
|
||||
},
|
||||
)
|
||||
return (
|
||||
runtime_request_context,
|
||||
[item.model_dump(mode="json") for item in recent_retrospectives],
|
||||
[item.model_dump(mode="json") for item in skill_shortlist],
|
||||
)
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
user_id: str,
|
||||
@@ -492,10 +668,12 @@ class AgentService:
|
||||
conversation_id: str | None = None,
|
||||
file_ids: list[str] | None = None,
|
||||
model_name: str | None = None,
|
||||
runtime: str | None = None,
|
||||
) -> tuple[str, str, AsyncGenerator[dict[str, Any], None]]:
|
||||
"""
|
||||
处理对话请求(流式)
|
||||
"""
|
||||
runtime_name = self._resolve_runtime(runtime)
|
||||
user_llm_config = await self._get_user_llm_config(user_id, model_name)
|
||||
model_name_used = model_name
|
||||
if model_name and not user_llm_config:
|
||||
@@ -588,7 +766,7 @@ class AgentService:
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content="",
|
||||
model=model_name_used or "jarvis",
|
||||
model=(model_name_used or "jarvis") if runtime_name == "jarvis" else runtime_name,
|
||||
attachments=None,
|
||||
)
|
||||
self.db.add(assistant_msg)
|
||||
@@ -603,28 +781,113 @@ class AgentService:
|
||||
"title": "Assistant message",
|
||||
"content_summary": content[:500],
|
||||
"raw_excerpt": content[:2000],
|
||||
"metadata_": {"role": "assistant"},
|
||||
"metadata_": {"role": "assistant", "runtime": runtime_name},
|
||||
"importance_signal": 0.8,
|
||||
}
|
||||
|
||||
if runtime_name == "hermes":
|
||||
user = await self.db.get(User, user_id)
|
||||
if user is None:
|
||||
raise ValueError("用户不存在")
|
||||
|
||||
prepared = RuntimePreparedContext(
|
||||
user=user,
|
||||
conversation=conv,
|
||||
user_message=user_msg,
|
||||
assistant_message=assistant_msg,
|
||||
raw_message=message,
|
||||
full_message=full_message,
|
||||
file_ids=file_ids or [],
|
||||
model_name=model_name_used,
|
||||
memory_context=memory_ctx,
|
||||
)
|
||||
|
||||
async def run_hermes():
|
||||
collected = ""
|
||||
stream_failed = False
|
||||
try:
|
||||
async for event in hermes_runtime_adapter.chat_stream(prepared):
|
||||
if event.get("type") == "chunk":
|
||||
collected += str(event.get("content", ""))
|
||||
elif event.get("type") == "error":
|
||||
stream_failed = True
|
||||
yield event
|
||||
finally:
|
||||
try:
|
||||
session_handle = hermes_session_manager.get_or_create(
|
||||
conversation_id=conv.id,
|
||||
user_id=user_id,
|
||||
)
|
||||
assistant_msg.content = collected if collected else ("Hermes 执行失败,请检查运行配置。" if stream_failed else "")
|
||||
assistant_msg.model = str(session_handle.metadata.get("model") or "hermes")
|
||||
assistant_msg.attachments = [
|
||||
{
|
||||
"kind": "runtime_info",
|
||||
"runtime": "hermes",
|
||||
"session_id": session_handle.hermes_session_id,
|
||||
"model": session_handle.metadata.get("model"),
|
||||
"last_error": session_handle.metadata.get("last_error"),
|
||||
}
|
||||
]
|
||||
conv.agent_state = {
|
||||
"runtime": "hermes",
|
||||
"runtime_state": {
|
||||
"hermes": {
|
||||
"session_id": session_handle.hermes_session_id,
|
||||
"message_id": assistant_msg.id,
|
||||
"model": session_handle.metadata.get("model"),
|
||||
"last_error": session_handle.metadata.get("last_error"),
|
||||
}
|
||||
},
|
||||
}
|
||||
await BrainService(self.db).create_event(
|
||||
user_id,
|
||||
**_build_assistant_event_payload(assistant_msg.content),
|
||||
)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(assistant_msg)
|
||||
except Exception:
|
||||
logger.exception("save_hermes_assistant_message_failed")
|
||||
asyncio.create_task(self._try_auto_summarize_background(user_id, conversation_id))
|
||||
asyncio.create_task(self._extract_memories_background(user_id, conversation_id))
|
||||
|
||||
return conversation_id, assistant_msg.id, run_hermes()
|
||||
|
||||
async def run_agent():
|
||||
collected = ""
|
||||
state: dict[str, Any] | None = None
|
||||
runtime_request_context: RuntimeRequestContext | None = None
|
||||
set_current_user(user_id)
|
||||
try:
|
||||
graph = get_agent_graph()
|
||||
current_datetime_context, current_datetime_reference = (
|
||||
self._build_current_datetime_context()
|
||||
)
|
||||
|
||||
state = await self._build_agent_state(
|
||||
(
|
||||
runtime_request_context,
|
||||
recalled_retrospectives,
|
||||
skill_shortlist,
|
||||
) = await self._build_runtime_request_context(
|
||||
request_id=assistant_msg.id,
|
||||
user_id=user_id,
|
||||
conversation=conv,
|
||||
user_query=message,
|
||||
memory_context=memory_ctx,
|
||||
)
|
||||
|
||||
state = await self._build_agent_state(
|
||||
request_id=assistant_msg.id,
|
||||
user_id=user_id,
|
||||
conversation=conv,
|
||||
raw_user_query=message,
|
||||
full_message=full_message,
|
||||
memory_context=memory_ctx,
|
||||
current_datetime_context=current_datetime_context,
|
||||
current_datetime_reference=current_datetime_reference,
|
||||
user_llm_config=user_llm_config,
|
||||
runtime_request_context=runtime_request_context,
|
||||
recalled_retrospectives=recalled_retrospectives,
|
||||
skill_shortlist=skill_shortlist,
|
||||
)
|
||||
state.update(_derive_role_memory_contexts(memory_ctx))
|
||||
|
||||
@@ -749,7 +1012,7 @@ class AgentService:
|
||||
if collected:
|
||||
assistant_msg.content = collected
|
||||
continuity_snapshot = _build_continuity_snapshot(state or {})
|
||||
assistant_msg.attachments = (
|
||||
attachments = (
|
||||
[
|
||||
{
|
||||
"kind": "agent_continuity_state",
|
||||
@@ -757,7 +1020,25 @@ class AgentService:
|
||||
}
|
||||
]
|
||||
if continuity_snapshot
|
||||
else None
|
||||
else []
|
||||
)
|
||||
if state is not None and runtime_request_context is not None:
|
||||
retrospective = build_session_retrospective(
|
||||
request_id=assistant_msg.id,
|
||||
session_id=conversation_id,
|
||||
user_query=message,
|
||||
state=state,
|
||||
runtime_context=runtime_request_context,
|
||||
)
|
||||
attachments = append_retrospective_attachment(attachments, retrospective)
|
||||
attachments.append(
|
||||
{
|
||||
"kind": "runtime_observability",
|
||||
"payload": build_runtime_observability_report(
|
||||
state=state,
|
||||
feature_flags=state.get("feature_flags") or {},
|
||||
),
|
||||
}
|
||||
)
|
||||
conv.agent_state = (
|
||||
{
|
||||
@@ -771,8 +1052,18 @@ class AgentService:
|
||||
user_id,
|
||||
**_build_assistant_event_payload(collected),
|
||||
)
|
||||
assistant_msg.attachments = attachments or None
|
||||
await self.db.commit()
|
||||
await self.db.refresh(assistant_msg)
|
||||
schedule_retrospective_job(
|
||||
user_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
request_message_id=user_msg.id,
|
||||
response_message_id=assistant_msg.id,
|
||||
query_text=message,
|
||||
final_response=collected,
|
||||
state=state,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("save_assistant_message_failed")
|
||||
asyncio.create_task(self._try_auto_summarize_background(user_id, conversation_id))
|
||||
@@ -788,10 +1079,12 @@ class AgentService:
|
||||
conversation_id: str | None = None,
|
||||
file_ids: list[str] | None = None,
|
||||
model_name: str | None = None,
|
||||
runtime: str | None = None,
|
||||
) -> tuple[str, str, str, str | None]:
|
||||
"""
|
||||
简单同步版对话
|
||||
"""
|
||||
runtime_name = self._resolve_runtime(runtime)
|
||||
user_llm_config = await self._get_user_llm_config(user_id, model_name)
|
||||
model_name_used = model_name
|
||||
if model_name and not user_llm_config:
|
||||
@@ -828,7 +1121,7 @@ class AgentService:
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content="",
|
||||
model=model_name_used or "jarvis",
|
||||
model=(model_name_used or "jarvis") if runtime_name == "jarvis" else runtime_name,
|
||||
attachments=None,
|
||||
)
|
||||
self.db.add(assistant_msg)
|
||||
@@ -857,20 +1150,100 @@ class AgentService:
|
||||
if recall_ctx:
|
||||
memory_ctx = f"{memory_ctx}\n{recall_ctx}" if memory_ctx else recall_ctx
|
||||
|
||||
if runtime_name == "hermes":
|
||||
user = await self.db.get(User, user_id)
|
||||
if user is None:
|
||||
raise ValueError("用户不存在")
|
||||
prepared = RuntimePreparedContext(
|
||||
user=user,
|
||||
conversation=conv,
|
||||
user_message=user_msg,
|
||||
assistant_message=assistant_msg,
|
||||
raw_message=message,
|
||||
full_message=message,
|
||||
file_ids=file_ids or [],
|
||||
model_name=model_name_used,
|
||||
memory_context=memory_ctx,
|
||||
)
|
||||
response_content, resolved_model_name = await hermes_runtime_adapter.chat_once(prepared)
|
||||
assistant_msg.content = response_content
|
||||
assistant_msg.model = resolved_model_name or "hermes"
|
||||
assistant_msg.attachments = [{
|
||||
"kind": "runtime_info",
|
||||
"runtime": "hermes",
|
||||
"session_id": hermes_session_manager.get_or_create(
|
||||
conversation_id=conv.id,
|
||||
user_id=user_id,
|
||||
).hermes_session_id,
|
||||
"model": resolved_model_name,
|
||||
}]
|
||||
conv.agent_state = {
|
||||
"runtime": "hermes",
|
||||
"runtime_state": {
|
||||
"hermes": {
|
||||
"session_id": hermes_session_manager.get_or_create(
|
||||
conversation_id=conv.id,
|
||||
user_id=user_id,
|
||||
).hermes_session_id,
|
||||
"message_id": assistant_msg.id,
|
||||
"model": resolved_model_name,
|
||||
}
|
||||
},
|
||||
}
|
||||
await brain_service.create_event(
|
||||
user_id,
|
||||
source_type="conversation",
|
||||
source_id=conversation_id,
|
||||
event_type="message_created",
|
||||
title="Assistant message",
|
||||
content_summary=response_content[:500],
|
||||
raw_excerpt=response_content[:2000],
|
||||
metadata_={"role": "assistant", "runtime": "hermes"},
|
||||
importance_signal=0.8,
|
||||
)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(assistant_msg)
|
||||
schedule_retrospective_job(
|
||||
user_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
request_message_id=user_msg.id,
|
||||
response_message_id=assistant_msg.id,
|
||||
query_text=message,
|
||||
final_response=response_content,
|
||||
state=None,
|
||||
)
|
||||
return conversation_id, assistant_msg.id, response_content, assistant_msg.model
|
||||
|
||||
set_current_user(user_id)
|
||||
try:
|
||||
graph = get_agent_graph()
|
||||
current_datetime_context, current_datetime_reference = (
|
||||
self._build_current_datetime_context()
|
||||
)
|
||||
state = await self._build_agent_state(
|
||||
(
|
||||
runtime_request_context,
|
||||
recalled_retrospectives,
|
||||
skill_shortlist,
|
||||
) = await self._build_runtime_request_context(
|
||||
request_id=assistant_msg.id,
|
||||
user_id=user_id,
|
||||
conversation=conv,
|
||||
user_query=message,
|
||||
memory_context=memory_ctx,
|
||||
)
|
||||
state = await self._build_agent_state(
|
||||
request_id=assistant_msg.id,
|
||||
user_id=user_id,
|
||||
conversation=conv,
|
||||
raw_user_query=message,
|
||||
full_message=message,
|
||||
memory_context=memory_ctx,
|
||||
current_datetime_context=current_datetime_context,
|
||||
current_datetime_reference=current_datetime_reference,
|
||||
user_llm_config=user_llm_config,
|
||||
runtime_request_context=runtime_request_context,
|
||||
recalled_retrospectives=recalled_retrospectives,
|
||||
skill_shortlist=skill_shortlist,
|
||||
)
|
||||
state.update(_derive_role_memory_contexts(memory_ctx))
|
||||
result_state = await graph.ainvoke(state)
|
||||
@@ -900,7 +1273,7 @@ class AgentService:
|
||||
continuity_snapshot = (
|
||||
_build_continuity_snapshot(result_state) if "result_state" in locals() else None
|
||||
)
|
||||
assistant_msg.attachments = (
|
||||
attachments = (
|
||||
[
|
||||
{
|
||||
"kind": "agent_continuity_state",
|
||||
@@ -908,7 +1281,25 @@ class AgentService:
|
||||
}
|
||||
]
|
||||
if continuity_snapshot
|
||||
else None
|
||||
else []
|
||||
)
|
||||
if "result_state" in locals() and "runtime_request_context" in locals():
|
||||
retrospective = build_session_retrospective(
|
||||
request_id=assistant_msg.id,
|
||||
session_id=conversation_id,
|
||||
user_query=message,
|
||||
state=result_state,
|
||||
runtime_context=runtime_request_context,
|
||||
)
|
||||
attachments = append_retrospective_attachment(attachments, retrospective)
|
||||
attachments.append(
|
||||
{
|
||||
"kind": "runtime_observability",
|
||||
"payload": build_runtime_observability_report(
|
||||
state=result_state,
|
||||
feature_flags=result_state.get("feature_flags") or {},
|
||||
),
|
||||
}
|
||||
)
|
||||
conv.agent_state = (
|
||||
{
|
||||
@@ -918,7 +1309,17 @@ class AgentService:
|
||||
if continuity_snapshot
|
||||
else None
|
||||
)
|
||||
assistant_msg.attachments = attachments or None
|
||||
await self.db.commit()
|
||||
await self.db.refresh(assistant_msg)
|
||||
schedule_retrospective_job(
|
||||
user_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
request_message_id=user_msg.id,
|
||||
response_message_id=assistant_msg.id,
|
||||
query_text=message,
|
||||
final_response=response_content,
|
||||
state=result_state if "result_state" in locals() else None,
|
||||
)
|
||||
|
||||
return conversation_id, assistant_msg.id, response_content, model_name_used
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import shutil
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from fastapi import UploadFile
|
||||
@@ -18,7 +19,6 @@ import json
|
||||
import os
|
||||
import re
|
||||
import aiofiles
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@@ -52,9 +52,9 @@ class DocumentService:
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise ValueError(f"不支持的文件类型: {ext}")
|
||||
|
||||
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
||||
file_id = str(uuid.uuid4())
|
||||
file_path = os.path.join(settings.UPLOAD_DIR, f"{file_id}{ext}")
|
||||
folder_path = await self._get_storage_directory(user_id, folder_id)
|
||||
folder_path.mkdir(parents=True, exist_ok=True)
|
||||
file_path = self._resolve_unique_file_path(folder_path, file.filename)
|
||||
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
@@ -64,7 +64,7 @@ class DocumentService:
|
||||
async with aiofiles.open(file_path, "wb") as f:
|
||||
await f.write(content)
|
||||
|
||||
parsed = await self._parse_document(file_path, ext)
|
||||
parsed = await self._parse_document(str(file_path), ext)
|
||||
parsed.structured_markdown = self._render_structured_markdown(parsed)
|
||||
|
||||
doc = Document(
|
||||
@@ -73,7 +73,7 @@ class DocumentService:
|
||||
filename=file.filename,
|
||||
file_type=ext[1:],
|
||||
file_size=file_size,
|
||||
file_path=file_path,
|
||||
file_path=str(file_path),
|
||||
summary=parsed.summary[:500] if len(parsed.summary) > 500 else parsed.summary,
|
||||
folder_id=folder_id,
|
||||
ingestion_status="uploaded",
|
||||
@@ -171,6 +171,83 @@ class DocumentService:
|
||||
|
||||
return "/" + "/".join(path_parts) if path_parts else None
|
||||
|
||||
async def ensure_folder_directory(self, user_id: str, folder_id: str | None) -> Path:
|
||||
folder_path = await self._get_storage_directory(user_id, folder_id)
|
||||
folder_path.mkdir(parents=True, exist_ok=True)
|
||||
return folder_path
|
||||
|
||||
async def delete_folder_directory(self, user_id: str, folder_id: str) -> None:
|
||||
folder_path = await self._get_storage_directory(user_id, folder_id)
|
||||
if folder_path.exists():
|
||||
shutil.rmtree(folder_path, ignore_errors=True)
|
||||
|
||||
async def rename_folder_directory(self, user_id: str, folder_id: str, old_name: str, new_name: str) -> None:
|
||||
folder = await self.db.get(Folder, folder_id)
|
||||
if folder is None:
|
||||
return
|
||||
|
||||
parent_path = await self._get_storage_directory(user_id, folder.parent_id)
|
||||
old_path = parent_path / self._sanitize_storage_name(old_name)
|
||||
new_path = parent_path / self._sanitize_storage_name(new_name)
|
||||
|
||||
if old_path != new_path:
|
||||
parent_path.mkdir(parents=True, exist_ok=True)
|
||||
if old_path.exists():
|
||||
old_path.rename(new_path)
|
||||
else:
|
||||
new_path.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
new_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
document_result = await self.db.execute(
|
||||
select(Document).where(Document.user_id == user_id)
|
||||
)
|
||||
for document in document_result.scalars().all():
|
||||
try:
|
||||
relative_path = Path(document.file_path).relative_to(old_path)
|
||||
except ValueError:
|
||||
continue
|
||||
document.file_path = str(new_path / relative_path)
|
||||
|
||||
async def _get_storage_directory(self, user_id: str, folder_id: str | None) -> Path:
|
||||
base_path = Path(settings.UPLOAD_DIR) / user_id
|
||||
if not folder_id:
|
||||
return base_path
|
||||
|
||||
folders = await self.db.execute(
|
||||
select(Folder).where(Folder.user_id == user_id)
|
||||
)
|
||||
folder_map = {folder.id: folder for folder in folders.scalars().all()}
|
||||
|
||||
path_segments: list[str] = []
|
||||
current_id = folder_id
|
||||
while current_id:
|
||||
folder = folder_map.get(current_id)
|
||||
if folder is None:
|
||||
raise ValueError("鐖舵枃浠跺す涓嶅瓨鍦?")
|
||||
path_segments.insert(0, self._sanitize_storage_name(folder.name))
|
||||
current_id = folder.parent_id
|
||||
|
||||
return base_path.joinpath(*path_segments)
|
||||
|
||||
def _resolve_unique_file_path(self, directory: Path, original_name: str) -> Path:
|
||||
safe_name = self._sanitize_storage_name(Path(original_name).name, is_file=True)
|
||||
stem = Path(safe_name).stem
|
||||
suffix = Path(safe_name).suffix
|
||||
candidate = directory / safe_name
|
||||
counter = 2
|
||||
while candidate.exists():
|
||||
candidate = directory / f"{stem}-{counter}{suffix}"
|
||||
counter += 1
|
||||
return candidate
|
||||
|
||||
def _sanitize_storage_name(self, name: str, is_file: bool = False) -> str:
|
||||
invalid_chars = '<>:"/\\|?*'
|
||||
sanitized = ''.join('_' if char in invalid_chars or ord(char) < 32 else char for char in name).strip().rstrip('.')
|
||||
if not sanitized:
|
||||
return 'untitled' if is_file else 'folder'
|
||||
return sanitized
|
||||
|
||||
async def delete_document(self, user_id: str, document_id: str):
|
||||
result = await self.db.execute(
|
||||
select(Document).where(
|
||||
|
||||
108
backend/app/services/remote_sync_service.py
Normal file
108
backend/app/services/remote_sync_service.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from io import BytesIO
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.datastructures import UploadFile
|
||||
|
||||
from app.models.folder import Folder
|
||||
from app.models.remote_mount import RemoteMount, RemoteSyncItem
|
||||
from app.services.document_service import DocumentService
|
||||
from app.services.webdav_service import WebDavNode, WebDavService
|
||||
|
||||
|
||||
class RemoteSyncService:
|
||||
def __init__(self, db: AsyncSession, user_id: str):
|
||||
self.db = db
|
||||
self.user_id = user_id
|
||||
|
||||
async def sync_remote_path(
|
||||
self,
|
||||
mount: RemoteMount,
|
||||
remote_path: str,
|
||||
local_folder_id: str,
|
||||
mode: str = "file",
|
||||
) -> dict:
|
||||
folder = await self.db.execute(
|
||||
select(Folder).where(and_(Folder.id == local_folder_id, Folder.user_id == self.user_id))
|
||||
)
|
||||
if folder.scalar_one_or_none() is None:
|
||||
raise ValueError("本地目标文件夹不存在")
|
||||
|
||||
webdav = WebDavService(mount)
|
||||
document_service = DocumentService(self.db, self.user_id)
|
||||
|
||||
synced = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
document_ids: list[str] = []
|
||||
errors: list[str] = []
|
||||
|
||||
if mode == "folder":
|
||||
nodes = await webdav.list_tree(remote_path)
|
||||
targets = self._flatten_files(nodes)
|
||||
else:
|
||||
name = remote_path.rstrip("/").split("/")[-1] or "remote-file"
|
||||
targets = [WebDavNode(path=remote_path, name=name, is_dir=False)]
|
||||
|
||||
for node in targets:
|
||||
try:
|
||||
content, filename = await webdav.download_file(node.path)
|
||||
upload = UploadFile(filename=filename, file=BytesIO(content))
|
||||
document = await document_service.upload_document(self.user_id, upload, folder_id=local_folder_id)
|
||||
await self._upsert_sync_item(mount.id, node, local_folder_id, document.id)
|
||||
document_ids.append(document.id)
|
||||
synced += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed += 1
|
||||
errors.append(f"{node.path}: {exc}")
|
||||
await self._upsert_sync_item(mount.id, node, local_folder_id, None, status="failed", error=str(exc))
|
||||
|
||||
mount.last_sync_at = datetime.now(UTC).isoformat()
|
||||
await self.db.commit()
|
||||
return {
|
||||
"synced": synced,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"document_ids": document_ids,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
def _flatten_files(self, nodes: list[WebDavNode]) -> list[WebDavNode]:
|
||||
results: list[WebDavNode] = []
|
||||
for node in nodes:
|
||||
if node.is_dir:
|
||||
results.extend(self._flatten_files(node.children))
|
||||
else:
|
||||
results.append(node)
|
||||
return results
|
||||
|
||||
async def _upsert_sync_item(
|
||||
self,
|
||||
mount_id: str,
|
||||
node: WebDavNode,
|
||||
local_folder_id: str,
|
||||
local_document_id: str | None,
|
||||
status: str = "synced",
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
result = await self.db.execute(
|
||||
select(RemoteSyncItem).where(
|
||||
and_(RemoteSyncItem.mount_id == mount_id, RemoteSyncItem.remote_path == node.path)
|
||||
)
|
||||
)
|
||||
sync_item = result.scalar_one_or_none()
|
||||
if sync_item is None:
|
||||
sync_item = RemoteSyncItem(
|
||||
mount_id=mount_id,
|
||||
remote_path=node.path,
|
||||
)
|
||||
self.db.add(sync_item)
|
||||
|
||||
sync_item.remote_etag = node.etag
|
||||
sync_item.remote_modified_at = node.modified_at
|
||||
sync_item.local_folder_id = local_folder_id
|
||||
sync_item.local_document_id = local_document_id
|
||||
sync_item.sync_status = status
|
||||
sync_item.last_error = error
|
||||
sync_item.last_synced_at = datetime.now(UTC).isoformat()
|
||||
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,
|
||||
),
|
||||
}
|
||||
24
backend/app/services/secret_service.py
Normal file
24
backend/app/services/secret_service.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _build_fernet() -> Fernet:
|
||||
digest = hashlib.sha256(settings.SECRET_KEY.encode("utf-8")).digest()
|
||||
key = base64.urlsafe_b64encode(digest)
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def encrypt_secret(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
return _build_fernet().encrypt(value.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_secret(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
return _build_fernet().decrypt(value.encode("utf-8")).decode("utf-8")
|
||||
@@ -3,9 +3,13 @@ Skill Service - 技能管理服务层
|
||||
负责技能的创建、查询、更新、删除等操作
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, or_
|
||||
from app.agents.schemas.learning import SkillCandidate
|
||||
from app.agents.skills.models import SkillLifecycleDecision
|
||||
from app.models.skill import Skill
|
||||
from app.models.user import User
|
||||
|
||||
@@ -28,6 +32,10 @@ class SkillService:
|
||||
visibility=data.get("visibility", "private"),
|
||||
team_id=data.get("team_id"),
|
||||
is_active=data.get("is_active", True),
|
||||
status=data.get("status", "active"),
|
||||
scope=data.get("scope", []),
|
||||
effectiveness=data.get("effectiveness", 0.0),
|
||||
review_after=data.get("review_after"),
|
||||
)
|
||||
self.db.add(skill)
|
||||
await self.db.commit()
|
||||
@@ -41,6 +49,17 @@ class SkillService:
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_name_for_user(self, user_id: str, name: str) -> Optional[Skill]:
|
||||
access_scope = or_(
|
||||
Skill.owner_id == user_id,
|
||||
Skill.visibility == "market",
|
||||
Skill.team_id == user_id,
|
||||
)
|
||||
result = await self.db.execute(
|
||||
select(Skill).where(and_(Skill.name == name, access_scope))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_for_user(
|
||||
self,
|
||||
user_id: str,
|
||||
@@ -56,7 +75,7 @@ class SkillService:
|
||||
Skill.team_id == user_id,
|
||||
)
|
||||
|
||||
filters = [access_scope, Skill.is_active == True]
|
||||
filters = [access_scope, Skill.is_active == True, Skill.status != "retired"]
|
||||
|
||||
if agent_type:
|
||||
filters.append(Skill.agent_type == agent_type)
|
||||
@@ -83,7 +102,7 @@ class SkillService:
|
||||
update_fields = [
|
||||
"name", "description", "instructions", "agent_type",
|
||||
"tools", "required_context", "output_format", "visibility",
|
||||
"team_id", "is_active"
|
||||
"team_id", "is_active", "status", "scope", "effectiveness", "review_after"
|
||||
]
|
||||
|
||||
for field in update_fields:
|
||||
@@ -117,6 +136,7 @@ class SkillService:
|
||||
and_(
|
||||
Skill.agent_type == agent_type,
|
||||
Skill.is_active == True,
|
||||
Skill.status == "active",
|
||||
or_(
|
||||
Skill.visibility == "market",
|
||||
Skill.visibility == "private"
|
||||
@@ -125,3 +145,234 @@ class SkillService:
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_runtime_candidates(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
agent_type: Optional[str] = None,
|
||||
include_shadow: bool = True,
|
||||
include_learned: bool = True,
|
||||
) -> list[Skill]:
|
||||
allowed_statuses = ["active", "shadow"] if include_shadow else ["active"]
|
||||
access_scope = or_(
|
||||
Skill.owner_id == user_id,
|
||||
Skill.visibility == "market",
|
||||
Skill.team_id == user_id,
|
||||
)
|
||||
|
||||
filters = [
|
||||
access_scope,
|
||||
Skill.is_active == True,
|
||||
Skill.status.in_(allowed_statuses),
|
||||
]
|
||||
if not include_learned:
|
||||
filters.append(Skill.is_builtin == True)
|
||||
if agent_type:
|
||||
filters.append(Skill.agent_type == agent_type)
|
||||
|
||||
result = await self.db.execute(select(Skill).where(and_(*filters)))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def upsert_learned_candidate(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
candidate: SkillCandidate,
|
||||
primary_agent: str | None,
|
||||
evidence_refs: list[dict] | None = None,
|
||||
) -> SkillLifecycleDecision:
|
||||
source_hash = self._build_candidate_source_hash(candidate)
|
||||
skill = await self.get_by_name_for_user(user_id, candidate.name)
|
||||
if skill is None:
|
||||
review_after = datetime.now(UTC) + timedelta(days=7)
|
||||
skill = Skill(
|
||||
owner_id=user_id,
|
||||
name=candidate.name,
|
||||
description=candidate.summary,
|
||||
instructions=candidate.summary,
|
||||
agent_type=primary_agent or "master",
|
||||
tools=[],
|
||||
required_context=[],
|
||||
output_format=None,
|
||||
visibility="private",
|
||||
is_active=True,
|
||||
status="candidate",
|
||||
scope=[primary_agent or "master", "learned", candidate.candidate_type],
|
||||
effectiveness=candidate.confidence,
|
||||
review_after=review_after,
|
||||
candidate_count=1,
|
||||
candidate_source_hashes=[source_hash],
|
||||
)
|
||||
self.db.add(skill)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(skill)
|
||||
return SkillLifecycleDecision(
|
||||
skill_name=skill.name,
|
||||
action="created_candidate",
|
||||
previous_status=None,
|
||||
new_status="candidate",
|
||||
reason="First learned candidate created from retrospective evidence.",
|
||||
evidence_refs=evidence_refs or [],
|
||||
confidence=candidate.confidence,
|
||||
review_after=review_after,
|
||||
)
|
||||
|
||||
previous_status = skill.status
|
||||
known_hashes = list(skill.candidate_source_hashes or [])
|
||||
is_duplicate_candidate = source_hash in known_hashes
|
||||
if not is_duplicate_candidate:
|
||||
skill.candidate_count = int(skill.candidate_count or 0) + 1
|
||||
known_hashes.append(source_hash)
|
||||
skill.candidate_source_hashes = known_hashes
|
||||
current_effectiveness = float(skill.effectiveness or 0.0)
|
||||
skill.effectiveness = round(max(current_effectiveness, float(candidate.confidence or 0.0)), 3)
|
||||
skill.review_after = datetime.now(UTC) + timedelta(days=7)
|
||||
if primary_agent and primary_agent not in (skill.scope or []):
|
||||
skill.scope = [*(skill.scope or []), primary_agent]
|
||||
|
||||
action = "no_change"
|
||||
reason = "Candidate evidence refreshed."
|
||||
if is_duplicate_candidate:
|
||||
reason = "Duplicate candidate evidence ignored for promotion counting."
|
||||
if (
|
||||
not is_duplicate_candidate
|
||||
and skill.status == "candidate"
|
||||
and skill.candidate_count >= 2
|
||||
and skill.effectiveness >= 0.6
|
||||
):
|
||||
skill.status = "shadow"
|
||||
action = "promoted_to_shadow"
|
||||
reason = "Repeated candidate evidence promoted the learned skill to shadow."
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(skill)
|
||||
return SkillLifecycleDecision(
|
||||
skill_name=skill.name,
|
||||
action=action,
|
||||
previous_status=previous_status,
|
||||
new_status=skill.status,
|
||||
reason=reason,
|
||||
evidence_refs=evidence_refs or [],
|
||||
confidence=skill.effectiveness,
|
||||
review_after=skill.review_after,
|
||||
)
|
||||
|
||||
async def record_activation_feedback(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
skill_name: str,
|
||||
outcome_score: float,
|
||||
evidence_refs: list[dict] | None = None,
|
||||
) -> SkillLifecycleDecision | None:
|
||||
skill = await self.get_by_name_for_user(user_id, skill_name)
|
||||
if skill is None or skill.status not in {"shadow", "active", "deprecated"}:
|
||||
return None
|
||||
|
||||
previous_status = skill.status
|
||||
previous_activation_count = int(skill.activation_count or 0)
|
||||
skill.activation_count = previous_activation_count + 1
|
||||
skill.last_activated_at = datetime.now(UTC)
|
||||
|
||||
previous_effectiveness = float(skill.effectiveness or 0.0)
|
||||
if previous_activation_count <= 0:
|
||||
skill.effectiveness = round(outcome_score, 3)
|
||||
else:
|
||||
skill.effectiveness = round(
|
||||
((previous_effectiveness * previous_activation_count) + outcome_score)
|
||||
/ skill.activation_count,
|
||||
3,
|
||||
)
|
||||
|
||||
action = "feedback_recorded"
|
||||
reason = "Activation outcome recorded."
|
||||
if skill.status == "shadow" and skill.activation_count >= 2 and skill.effectiveness >= 0.7:
|
||||
skill.status = "active"
|
||||
action = "promoted_to_active"
|
||||
reason = "Shadow skill proved effective enough to become active."
|
||||
elif skill.status == "active" and skill.activation_count >= 3 and skill.effectiveness < 0.35:
|
||||
skill.status = "deprecated"
|
||||
action = "degraded_to_deprecated"
|
||||
reason = "Active skill underperformed repeatedly and was deprecated."
|
||||
elif skill.status == "deprecated" and skill.activation_count >= 4 and skill.effectiveness < 0.2:
|
||||
skill.status = "retired"
|
||||
action = "retired"
|
||||
reason = "Deprecated skill stayed ineffective and was retired."
|
||||
elif skill.status == "deprecated" and skill.effectiveness >= 0.65 and outcome_score >= 0.8:
|
||||
skill.status = "active"
|
||||
action = "reactivated"
|
||||
reason = "Deprecated skill recovered with strong positive feedback."
|
||||
|
||||
skill.review_after = datetime.now(UTC) + timedelta(days=7)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(skill)
|
||||
return SkillLifecycleDecision(
|
||||
skill_name=skill.name,
|
||||
action=action,
|
||||
previous_status=previous_status,
|
||||
new_status=skill.status,
|
||||
reason=reason,
|
||||
evidence_refs=evidence_refs or [],
|
||||
confidence=skill.effectiveness,
|
||||
review_after=skill.review_after,
|
||||
)
|
||||
|
||||
async def run_decay_review(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
as_of: datetime | None = None,
|
||||
) -> list[SkillLifecycleDecision]:
|
||||
review_time = as_of or datetime.now(UTC)
|
||||
result = await self.db.execute(
|
||||
select(Skill).where(
|
||||
and_(
|
||||
Skill.owner_id == user_id,
|
||||
Skill.is_active == True,
|
||||
Skill.status.in_(["shadow", "active", "deprecated"]),
|
||||
Skill.review_after.is_not(None),
|
||||
Skill.review_after <= review_time,
|
||||
)
|
||||
)
|
||||
)
|
||||
skills = list(result.scalars().all())
|
||||
decisions: list[SkillLifecycleDecision] = []
|
||||
for skill in skills:
|
||||
previous_status = skill.status
|
||||
action = "no_change"
|
||||
reason = "Review completed without status change."
|
||||
|
||||
if skill.status == "shadow" and float(skill.effectiveness or 0.0) < 0.45:
|
||||
skill.status = "deprecated"
|
||||
action = "degraded_to_deprecated"
|
||||
reason = "Shadow skill review found low effectiveness."
|
||||
elif skill.status == "deprecated" and float(skill.effectiveness or 0.0) < 0.2:
|
||||
skill.status = "retired"
|
||||
action = "retired"
|
||||
reason = "Deprecated skill remained weak through review."
|
||||
|
||||
skill.review_after = review_time + timedelta(days=7)
|
||||
decisions.append(
|
||||
SkillLifecycleDecision(
|
||||
skill_name=skill.name,
|
||||
action=action,
|
||||
previous_status=previous_status,
|
||||
new_status=skill.status,
|
||||
reason=reason,
|
||||
confidence=skill.effectiveness,
|
||||
review_after=skill.review_after,
|
||||
)
|
||||
)
|
||||
|
||||
await self.db.commit()
|
||||
return decisions
|
||||
|
||||
@staticmethod
|
||||
def _build_candidate_source_hash(candidate: SkillCandidate) -> str:
|
||||
raw = (
|
||||
f"{candidate.name}|{candidate.summary}|"
|
||||
f"{','.join(candidate.source_pattern_ids)}|"
|
||||
f"{len(candidate.evidence_refs)}"
|
||||
).encode("utf-8")
|
||||
return hashlib.sha1(raw).hexdigest()
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from datetime import datetime, UTC
|
||||
from time import monotonic
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
try:
|
||||
import psutil
|
||||
@@ -14,6 +18,15 @@ class SystemService:
|
||||
_last_net_bytes_sent: int | None = None
|
||||
_last_net_bytes_recv: int | None = None
|
||||
_last_net_sample_at: float | None = None
|
||||
_weather_cache: dict | None = None
|
||||
_weather_cached_at: float | None = None
|
||||
_weather_cached_location: str | None = None
|
||||
_weather_cache_ttl_seconds: float = 10 * 60 # 10 minutes
|
||||
|
||||
def __init__(self):
|
||||
# Import settings here to avoid circular imports
|
||||
from app.config import settings
|
||||
self._settings = settings
|
||||
|
||||
def _get_network_rates(self) -> tuple[float, float]:
|
||||
counters = psutil.net_io_counters()
|
||||
@@ -127,3 +140,96 @@ class SystemService:
|
||||
**gpu_status,
|
||||
'timestamp': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
async def _fetch_weather(self, location: str) -> dict:
|
||||
try:
|
||||
timeout = httpx.Timeout(10.0, connect=5.0)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.get(f'https://wttr.in/{location}', params={'format': 'j1'})
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
current = (payload.get('current_condition') or [{}])[0]
|
||||
weather_code = current.get('weatherCode')
|
||||
temp = current.get('temp_C')
|
||||
parsed_code = int(weather_code) if weather_code is not None and str(weather_code).isdigit() else None
|
||||
if parsed_code is None or temp in (None, ''):
|
||||
return {'weather_code': None, 'weather_summary': 'Weather unavailable'}
|
||||
|
||||
label = self._weather_code_label(parsed_code)
|
||||
return {
|
||||
'weather_code': parsed_code,
|
||||
'weather_summary': f'{label} {temp}°C',
|
||||
}
|
||||
except (httpx.HTTPError, ValueError, TypeError):
|
||||
return {'weather_code': None, 'weather_summary': 'Weather unavailable'}
|
||||
|
||||
@staticmethod
|
||||
def _weather_code_label(code: int | None) -> str:
|
||||
if code == 0:
|
||||
return 'Clear'
|
||||
if code in {1, 2}:
|
||||
return 'Partly Cloudy'
|
||||
if code == 3:
|
||||
return 'Overcast'
|
||||
if code in {45, 48}:
|
||||
return 'Fog'
|
||||
if code in {51, 53, 55, 56, 57}:
|
||||
return 'Drizzle'
|
||||
if code in {61, 63, 65, 66, 67, 80, 81, 82}:
|
||||
return 'Rain'
|
||||
if code in {71, 73, 75, 77, 85, 86}:
|
||||
return 'Snow'
|
||||
if code in {95, 96, 99}:
|
||||
return 'Thunderstorm'
|
||||
return 'Weather'
|
||||
|
||||
async def get_config(self) -> dict:
|
||||
"""Get public system configuration."""
|
||||
location = self._settings.LOCATION
|
||||
now = time.time()
|
||||
cached_weather = self.__class__._weather_cache
|
||||
cached_at = self.__class__._weather_cached_at
|
||||
cached_location = self.__class__._weather_cached_location
|
||||
|
||||
cache_is_valid = (
|
||||
cached_weather is not None
|
||||
and cached_at is not None
|
||||
and cached_location == location
|
||||
and (now - cached_at) < self.__class__._weather_cache_ttl_seconds
|
||||
)
|
||||
|
||||
if cache_is_valid:
|
||||
return {
|
||||
'location': location,
|
||||
**cached_weather,
|
||||
'weather_cached': True,
|
||||
'weather_cached_at': cached_at,
|
||||
}
|
||||
|
||||
weather = await self._fetch_weather(location)
|
||||
|
||||
# If fetch failed but we have *any* last known weather for same location, return it to avoid UI flicker.
|
||||
if (
|
||||
(weather.get('weather_code') is None)
|
||||
and cached_weather is not None
|
||||
and cached_location == location
|
||||
):
|
||||
return {
|
||||
'location': location,
|
||||
**cached_weather,
|
||||
'weather_cached': True,
|
||||
'weather_cached_at': cached_at,
|
||||
'weather_stale': True,
|
||||
}
|
||||
|
||||
# Update cache on successful/meaningful payload (or keep "unavailable" if never succeeded).
|
||||
self.__class__._weather_cache = weather
|
||||
self.__class__._weather_cached_at = now
|
||||
self.__class__._weather_cached_location = location
|
||||
|
||||
return {
|
||||
'location': location,
|
||||
**weather,
|
||||
'weather_cached': False,
|
||||
'weather_cached_at': now,
|
||||
}
|
||||
|
||||
238
backend/app/services/task_dispatch.py
Normal file
238
backend/app/services/task_dispatch.py
Normal file
@@ -0,0 +1,238 @@
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from sqlalchemy.orm import object_session
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.task import (
|
||||
Task,
|
||||
TaskDispatchStatus,
|
||||
TaskHistory,
|
||||
TaskPriority,
|
||||
TaskStatus,
|
||||
TaskSubTask,
|
||||
)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _stringify(value: object | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
|
||||
def append_task_history(
|
||||
task: Task,
|
||||
*,
|
||||
action: str,
|
||||
old_value: object | None = None,
|
||||
new_value: object | None = None,
|
||||
) -> None:
|
||||
entry = TaskHistory(
|
||||
task_id=task.id,
|
||||
action=action,
|
||||
old_value=_stringify(old_value),
|
||||
new_value=_stringify(new_value),
|
||||
)
|
||||
session = object_session(task)
|
||||
if session is not None:
|
||||
session.add(entry)
|
||||
return
|
||||
task.history.append(entry)
|
||||
|
||||
|
||||
def build_dispatch_payload(task: Task, subtasks: list[TaskSubTask]) -> dict[str, object]:
|
||||
return {
|
||||
"business_task_id": task.id,
|
||||
"title": task.title,
|
||||
"description": task.description,
|
||||
"priority": task.priority.value if isinstance(task.priority, TaskPriority) else str(task.priority),
|
||||
"due_date": task.due_date.isoformat() if task.due_date else None,
|
||||
"conversation_id": task.conversation_id,
|
||||
"user_id": task.user_id,
|
||||
"subtasks": [
|
||||
{
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
"description": item.description,
|
||||
"status": item.status.value if isinstance(item.status, TaskStatus) else str(item.status),
|
||||
"assignee_type": item.assignee_type.value if item.assignee_type else None,
|
||||
"assignee_id": item.assignee_id,
|
||||
"dispatch_status": (
|
||||
item.dispatch_status.value
|
||||
if isinstance(item.dispatch_status, TaskDispatchStatus)
|
||||
else str(item.dispatch_status)
|
||||
),
|
||||
"order_index": item.order_index,
|
||||
}
|
||||
for item in subtasks
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def _run_dispatch_flow(
|
||||
task_id: str,
|
||||
run_id: str,
|
||||
*,
|
||||
session_factory,
|
||||
subtask_id: str | None = None,
|
||||
) -> None:
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
async with session_factory() as db:
|
||||
task = await db.get(Task, task_id)
|
||||
if task is None:
|
||||
return
|
||||
target = await db.get(TaskSubTask, subtask_id) if subtask_id else None
|
||||
if subtask_id and target is None:
|
||||
return
|
||||
|
||||
if subtask_id:
|
||||
previous = target.dispatch_status
|
||||
target.dispatch_status = TaskDispatchStatus.RUNNING
|
||||
target.dispatch_run_id = run_id
|
||||
target.completed_at = None
|
||||
task.dispatch_status = TaskDispatchStatus.RUNNING
|
||||
task.dispatch_run_id = run_id
|
||||
task.started_at = task.started_at or _now()
|
||||
task.last_synced_at = _now()
|
||||
append_task_history(
|
||||
task,
|
||||
action="dispatch_status_changed",
|
||||
old_value=f"{subtask_id}:{previous.value}",
|
||||
new_value=f"{subtask_id}:{TaskDispatchStatus.RUNNING.value}",
|
||||
)
|
||||
else:
|
||||
previous = task.dispatch_status
|
||||
task.dispatch_status = TaskDispatchStatus.RUNNING
|
||||
task.dispatch_run_id = run_id
|
||||
task.started_at = task.started_at or _now()
|
||||
task.last_synced_at = _now()
|
||||
task.status = TaskStatus.IN_PROGRESS
|
||||
append_task_history(
|
||||
task,
|
||||
action="dispatch_status_changed",
|
||||
old_value=previous.value,
|
||||
new_value=TaskDispatchStatus.RUNNING.value,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
async with session_factory() as db:
|
||||
task = await db.get(Task, task_id)
|
||||
if task is None:
|
||||
return
|
||||
target = await db.get(TaskSubTask, subtask_id) if subtask_id else None
|
||||
if subtask_id and target is None:
|
||||
return
|
||||
|
||||
synced_at = _now()
|
||||
if subtask_id:
|
||||
previous = target.dispatch_status
|
||||
target.dispatch_status = TaskDispatchStatus.COMPLETED
|
||||
target.dispatch_run_id = run_id
|
||||
target.status = TaskStatus.DONE
|
||||
target.completed_at = synced_at
|
||||
task.dispatch_status = TaskDispatchStatus.COMPLETED
|
||||
task.dispatch_run_id = run_id
|
||||
task.result_summary = f"Commander completed subtask {target.title}"
|
||||
task.last_synced_at = synced_at
|
||||
append_task_history(
|
||||
task,
|
||||
action="dispatch_status_changed",
|
||||
old_value=f"{subtask_id}:{previous.value}",
|
||||
new_value=f"{subtask_id}:{TaskDispatchStatus.COMPLETED.value}",
|
||||
)
|
||||
else:
|
||||
previous = task.dispatch_status
|
||||
task.dispatch_status = TaskDispatchStatus.COMPLETED
|
||||
task.dispatch_run_id = run_id
|
||||
task.result_summary = f"Commander completed task {task.title}"
|
||||
task.last_synced_at = synced_at
|
||||
task.status = TaskStatus.DONE
|
||||
task.completed_at = synced_at
|
||||
append_task_history(
|
||||
task,
|
||||
action="dispatch_status_changed",
|
||||
old_value=previous.value,
|
||||
new_value=TaskDispatchStatus.COMPLETED.value,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
def schedule_dispatch(task_id: str, run_id: str, *, session_factory, subtask_id: str | None = None) -> None:
|
||||
asyncio.create_task(
|
||||
_run_dispatch_flow(
|
||||
task_id,
|
||||
run_id,
|
||||
session_factory=session_factory,
|
||||
subtask_id=subtask_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def queue_task_dispatch(
|
||||
task: Task,
|
||||
*,
|
||||
db,
|
||||
subtask: TaskSubTask | None = None,
|
||||
) -> tuple[str, dict[str, object]]:
|
||||
subtasks = list(task.subtasks)
|
||||
run_id = uuid4().hex[:12]
|
||||
synced_at = _now()
|
||||
|
||||
if subtask is not None:
|
||||
previous = subtask.dispatch_status
|
||||
subtask.dispatch_status = TaskDispatchStatus.QUEUED
|
||||
subtask.dispatch_run_id = run_id
|
||||
task.dispatch_status = TaskDispatchStatus.QUEUED
|
||||
task.dispatch_run_id = run_id
|
||||
task.result_summary = None
|
||||
task.last_synced_at = synced_at
|
||||
append_task_history(
|
||||
task,
|
||||
action="dispatched_to_commander",
|
||||
old_value=f"{subtask.id}:{previous.value}",
|
||||
new_value=f"{subtask.id}:{TaskDispatchStatus.QUEUED.value}",
|
||||
)
|
||||
else:
|
||||
previous = task.dispatch_status
|
||||
task.dispatch_status = TaskDispatchStatus.QUEUED
|
||||
task.dispatch_run_id = run_id
|
||||
task.result_summary = None
|
||||
task.started_at = None
|
||||
task.last_synced_at = synced_at
|
||||
append_task_history(
|
||||
task,
|
||||
action="dispatched_to_commander",
|
||||
old_value=previous.value,
|
||||
new_value=TaskDispatchStatus.QUEUED.value,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
payload = build_dispatch_payload(task, subtasks)
|
||||
session_factory = async_sessionmaker(bind=db.bind, expire_on_commit=False)
|
||||
schedule_dispatch(
|
||||
task.id,
|
||||
run_id,
|
||||
session_factory=session_factory,
|
||||
subtask_id=subtask.id if subtask else None,
|
||||
)
|
||||
return run_id, payload
|
||||
|
||||
|
||||
async def load_task_with_details(db, *, task_id: str, user_id: str) -> Task | None:
|
||||
result = await db.execute(
|
||||
select(Task)
|
||||
.options(selectinload(Task.subtasks), selectinload(Task.history))
|
||||
.where(Task.id == task_id, Task.user_id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
127
backend/app/services/webdav_service.py
Normal file
127
backend/app/services/webdav_service.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from dataclasses import dataclass, field
|
||||
from urllib.parse import quote, urljoin
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import httpx
|
||||
|
||||
from app.models.remote_mount import RemoteMount
|
||||
from app.services.secret_service import decrypt_secret
|
||||
|
||||
|
||||
WEBDAV_NAMESPACE = {
|
||||
"d": "DAV:",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebDavNode:
|
||||
path: str
|
||||
name: str
|
||||
is_dir: bool
|
||||
size: int | None = None
|
||||
modified_at: str | None = None
|
||||
etag: str | None = None
|
||||
children: list["WebDavNode"] = field(default_factory=list)
|
||||
|
||||
|
||||
class WebDavService:
|
||||
def __init__(self, mount: RemoteMount):
|
||||
self.mount = mount
|
||||
self.username = mount.username or None
|
||||
self.password = decrypt_secret(mount.password_encrypted)
|
||||
|
||||
def _normalize_remote_path(self, remote_path: str | None = None) -> str:
|
||||
path = remote_path or self.mount.root_path or "/"
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
return path
|
||||
|
||||
def _build_url(self, remote_path: str | None = None) -> str:
|
||||
path = self._normalize_remote_path(remote_path)
|
||||
encoded = "/".join(quote(segment) for segment in path.split("/") if segment)
|
||||
if not encoded:
|
||||
return self.mount.base_url.rstrip("/") + "/"
|
||||
return urljoin(self.mount.base_url.rstrip("/") + "/", encoded)
|
||||
|
||||
async def list_directory(self, remote_path: str | None = None) -> list[WebDavNode]:
|
||||
path = self._normalize_remote_path(remote_path)
|
||||
body = """<?xml version="1.0" encoding="utf-8" ?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:displayname />
|
||||
<d:resourcetype />
|
||||
<d:getcontentlength />
|
||||
<d:getlastmodified />
|
||||
<d:getetag />
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
async with httpx.AsyncClient(timeout=30.0, auth=self._auth()) as client:
|
||||
response = await client.request(
|
||||
"PROPFIND",
|
||||
self._build_url(path),
|
||||
headers={"Depth": "1", "Content-Type": "application/xml"},
|
||||
content=body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return self._parse_propfind(path, response.text)
|
||||
|
||||
async def list_tree(self, remote_path: str | None = None, max_depth: int = 4) -> list[WebDavNode]:
|
||||
path = self._normalize_remote_path(remote_path)
|
||||
nodes = await self.list_directory(path)
|
||||
if max_depth <= 1:
|
||||
return nodes
|
||||
|
||||
for node in nodes:
|
||||
if node.is_dir:
|
||||
node.children = await self.list_tree(node.path, max_depth=max_depth - 1)
|
||||
return nodes
|
||||
|
||||
async def download_file(self, remote_path: str) -> tuple[bytes, str]:
|
||||
normalized = self._normalize_remote_path(remote_path)
|
||||
async with httpx.AsyncClient(timeout=120.0, auth=self._auth()) as client:
|
||||
response = await client.get(self._build_url(normalized))
|
||||
response.raise_for_status()
|
||||
name = normalized.rstrip("/").split("/")[-1] or "remote-file"
|
||||
return response.content, name
|
||||
|
||||
def _auth(self) -> httpx.BasicAuth | None:
|
||||
if self.username and self.password:
|
||||
return httpx.BasicAuth(self.username, self.password)
|
||||
return None
|
||||
|
||||
def _parse_propfind(self, parent_path: str, payload: str) -> list[WebDavNode]:
|
||||
root = ET.fromstring(payload)
|
||||
nodes: list[WebDavNode] = []
|
||||
|
||||
for response in root.findall("d:response", WEBDAV_NAMESPACE):
|
||||
href = response.findtext("d:href", default="", namespaces=WEBDAV_NAMESPACE)
|
||||
if not href:
|
||||
continue
|
||||
|
||||
normalized_href = "/" + href.split("://", 1)[-1].split("/", 1)[-1].strip("/")
|
||||
normalized_href = "/" if normalized_href == "/" else normalized_href.rstrip("/")
|
||||
normalized_parent = self._normalize_remote_path(parent_path).rstrip("/") or "/"
|
||||
if normalized_href.rstrip("/") == normalized_parent.rstrip("/"):
|
||||
continue
|
||||
|
||||
prop = response.find("d:propstat/d:prop", WEBDAV_NAMESPACE)
|
||||
if prop is None:
|
||||
continue
|
||||
|
||||
is_dir = prop.find("d:resourcetype/d:collection", WEBDAV_NAMESPACE) is not None
|
||||
display_name = prop.findtext("d:displayname", default="", namespaces=WEBDAV_NAMESPACE) or normalized_href.split("/")[-1]
|
||||
size_text = prop.findtext("d:getcontentlength", default="", namespaces=WEBDAV_NAMESPACE)
|
||||
etag = prop.findtext("d:getetag", default=None, namespaces=WEBDAV_NAMESPACE)
|
||||
modified_at = prop.findtext("d:getlastmodified", default=None, namespaces=WEBDAV_NAMESPACE)
|
||||
|
||||
nodes.append(WebDavNode(
|
||||
path=normalized_href,
|
||||
name=display_name,
|
||||
is_dir=is_dir,
|
||||
size=int(size_text) if size_text.isdigit() else None,
|
||||
etag=etag,
|
||||
modified_at=modified_at,
|
||||
))
|
||||
|
||||
nodes.sort(key=lambda item: (not item.is_dir, item.name.lower()))
|
||||
return nodes
|
||||
@@ -314,6 +314,22 @@ class FailIfCalledLLM:
|
||||
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():
|
||||
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'] == '我查了外部网页,下面是最新结果摘要。'
|
||||
|
||||
|
||||
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):
|
||||
fake_llm = TripleResponseFallbackLLM([
|
||||
'{"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()
|
||||
assert payload['total_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['side_effect_scope'] == 'network'
|
||||
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
|
||||
async def test_visibility_events_reject_invalid_datetime(visibility_env):
|
||||
app, ids = visibility_env
|
||||
|
||||
@@ -15,6 +15,7 @@ from starlette.datastructures import UploadFile
|
||||
import app.models # noqa: F401
|
||||
from app.database import Base
|
||||
from app.models.document import Document, DocumentChunk
|
||||
from app.models.folder import Folder
|
||||
from app.models.user import User
|
||||
from app.services.auth_service import get_password_hash
|
||||
from app.services.document_service import DocumentService
|
||||
@@ -199,6 +200,29 @@ async def test_upload_document_persists_structured_metadata_json(document_test_e
|
||||
assert stored_document.normalized_content == 'title\n\nplain text body for metadata storage'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_document_stores_file_in_nested_folder_with_original_name(document_test_env):
|
||||
session, user = document_test_env
|
||||
service = DocumentService(session)
|
||||
|
||||
root = Folder(user_id=user.id, name='Projects')
|
||||
session.add(root)
|
||||
await session.flush()
|
||||
child = Folder(user_id=user.id, name='Specs', parent_id=root.id)
|
||||
session.add(child)
|
||||
await session.commit()
|
||||
await session.refresh(child)
|
||||
|
||||
upload = UploadFile(filename='system-design.md', file=BytesIO(b'# Design'))
|
||||
document = await service.upload_document(user.id, upload, folder_id=child.id)
|
||||
|
||||
file_path = Path(document.file_path)
|
||||
assert file_path.name == 'system-design.md'
|
||||
assert file_path.parent.name == 'Specs'
|
||||
assert file_path.parent.parent.name == 'Projects'
|
||||
assert file_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_document_extracts_docx_heading_and_table_structure(document_test_env):
|
||||
session, user = document_test_env
|
||||
|
||||
75
backend/tests/backend/app/services/test_hermes_runtime.py
Normal file
75
backend/tests/backend/app/services/test_hermes_runtime.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.agent_runtime.base import RuntimePreparedContext
|
||||
from app.services.agent_runtime.hermes_runtime import HermesRuntimeAdapter
|
||||
from app.services.agent_runtime.hermes_session_manager import hermes_session_manager
|
||||
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.model = kwargs.get("model", "fake-hermes-model")
|
||||
|
||||
def run_conversation(self, user_message, system_message=None, stream_callback=None):
|
||||
if stream_callback is not None:
|
||||
stream_callback("hello ")
|
||||
stream_callback("world")
|
||||
return {"final_response": "hello world"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_hermes_sessions():
|
||||
hermes_session_manager._sessions.clear()
|
||||
yield
|
||||
hermes_session_manager._sessions.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prepared_context():
|
||||
return RuntimePreparedContext(
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
conversation=SimpleNamespace(id="conv-1"),
|
||||
user_message=SimpleNamespace(id="msg-user"),
|
||||
assistant_message=SimpleNamespace(id="msg-assistant"),
|
||||
raw_message="hi",
|
||||
full_message="hi",
|
||||
file_ids=[],
|
||||
model_name="hermes-test-model",
|
||||
memory_context="memory block",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_once_calls_ai_agent(monkeypatch, prepared_context):
|
||||
adapter = HermesRuntimeAdapter()
|
||||
monkeypatch.setattr(adapter, "_load_agent_class", lambda: FakeAgent)
|
||||
|
||||
content, model = await adapter.chat_once(prepared_context)
|
||||
|
||||
assert content == "hello world"
|
||||
assert model == "hermes-test-model"
|
||||
handle = hermes_session_manager.get_or_create(conversation_id="conv-1", user_id="user-1")
|
||||
assert handle.metadata["model"] == "hermes-test-model"
|
||||
assert handle.metadata["last_error"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_stream_emits_progress_and_chunks(monkeypatch, prepared_context):
|
||||
adapter = HermesRuntimeAdapter()
|
||||
monkeypatch.setattr(adapter, "_load_agent_class", lambda: FakeAgent)
|
||||
|
||||
events = []
|
||||
async for event in adapter.chat_stream(prepared_context):
|
||||
events.append(event)
|
||||
|
||||
assert events[0]["type"] == "progress"
|
||||
chunks = [event["content"] for event in events if event["type"] == "chunk"]
|
||||
assert "hello " in chunks
|
||||
assert "world" in chunks
|
||||
handle = hermes_session_manager.get_or_create(conversation_id="conv-1", user_id="user-1")
|
||||
assert handle.metadata["model"] == "hermes-test-model"
|
||||
assert handle.metadata["last_error"] is None
|
||||
39
backend/tests/backend/app/services/test_webdav_service.py
Normal file
39
backend/tests/backend/app/services/test_webdav_service.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from app.models.remote_mount import RemoteMount
|
||||
from app.services.secret_service import encrypt_secret
|
||||
from app.services.webdav_service import WebDavService
|
||||
|
||||
|
||||
def test_parse_propfind_returns_sorted_nodes():
|
||||
mount = RemoteMount(
|
||||
user_id='user-1',
|
||||
name='Docs',
|
||||
mount_type='webdav',
|
||||
base_url='https://example.com/dav/',
|
||||
username='alice',
|
||||
password_encrypted=encrypt_secret('secret'),
|
||||
root_path='/knowledge',
|
||||
is_active=True,
|
||||
)
|
||||
payload = """<?xml version="1.0" encoding="utf-8" ?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>/knowledge/</d:href>
|
||||
<d:propstat><d:prop><d:displayname>knowledge</d:displayname><d:resourcetype><d:collection /></d:resourcetype></d:prop></d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/knowledge/specs/</d:href>
|
||||
<d:propstat><d:prop><d:displayname>specs</d:displayname><d:resourcetype><d:collection /></d:resourcetype></d:prop></d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/knowledge/roadmap.md</d:href>
|
||||
<d:propstat><d:prop><d:displayname>roadmap.md</d:displayname><d:getcontentlength>128</d:getcontentlength><d:getetag>"etag-1"</d:getetag><d:getlastmodified>Wed, 09 Apr 2026 10:00:00 GMT</d:getlastmodified><d:resourcetype /></d:prop></d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>"""
|
||||
|
||||
nodes = WebDavService(mount)._parse_propfind('/knowledge', payload)
|
||||
|
||||
assert [node.name for node in nodes] == ['specs', 'roadmap.md']
|
||||
assert nodes[0].is_dir is True
|
||||
assert nodes[1].is_dir is False
|
||||
assert nodes[1].size == 128
|
||||
assert nodes[1].etag == '"etag-1"'
|
||||
@@ -73,3 +73,87 @@ async def test_list_conversations_succeeds_when_agent_state_column_was_missing(c
|
||||
assert len(payload) == 1
|
||||
assert payload[0]['title'] == 'Existing conversation'
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_stream_passes_runtime_to_agent_service(conversation_env, monkeypatch):
|
||||
recorded: dict[str, object] = {}
|
||||
|
||||
async def fake_chat(self, **kwargs):
|
||||
recorded.update(kwargs)
|
||||
|
||||
async def empty_stream():
|
||||
if False:
|
||||
yield None
|
||||
|
||||
return 'conv-rt', 'msg-rt', empty_stream()
|
||||
|
||||
monkeypatch.setattr('app.routers.conversation.AgentService.chat', fake_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', 'runtime': 'hermes'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert recorded['runtime'] == 'hermes'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_defaults_agent_name_to_jarvis(conversation_env, monkeypatch):
|
||||
async def fake_chat_simple(self, **kwargs):
|
||||
return 'conv-id', 'msg-id', 'ok', 'test-model'
|
||||
|
||||
monkeypatch.setattr('app.routers.conversation.AgentService.chat_simple', fake_chat_simple)
|
||||
|
||||
transport = ASGITransport(app=conversation_env)
|
||||
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||
response = await client.post(
|
||||
'/api/conversations/chat',
|
||||
json={'message': 'hello'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()['agent_name'] == 'jarvis'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_returns_hermes_agent_name_when_requested(conversation_env, monkeypatch):
|
||||
async def fake_chat_simple(self, **kwargs):
|
||||
return 'conv-id', 'msg-id', 'ok', 'hermes-model'
|
||||
|
||||
monkeypatch.setattr('app.routers.conversation.AgentService.chat_simple', fake_chat_simple)
|
||||
|
||||
transport = ASGITransport(app=conversation_env)
|
||||
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||
response = await client.post(
|
||||
'/api/conversations/chat',
|
||||
json={'message': 'hello', 'runtime': 'hermes'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()['agent_name'] == 'hermes'
|
||||
|
||||
182
backend/tests/backend/app/test_database_schema_bootstrap.py
Normal file
182
backend/tests/backend/app/test_database_schema_bootstrap.py
Normal file
@@ -0,0 +1,182 @@
|
||||
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, ensure_task_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()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ensure_task_columns_adds_today_status_columns_and_subtask_table(tmp_path):
|
||||
db_path = tmp_path / 'test_tasks.db'
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
'''
|
||||
CREATE TABLE tasks (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
priority VARCHAR(20) NOT NULL,
|
||||
due_date DATETIME,
|
||||
completed_at DATETIME,
|
||||
tags VARCHAR(1000),
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
)
|
||||
'''
|
||||
))
|
||||
await conn.execute(text(
|
||||
'''
|
||||
CREATE TABLE task_histories (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
task_id VARCHAR(36) NOT NULL,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
)
|
||||
'''
|
||||
))
|
||||
|
||||
await ensure_task_columns(conn)
|
||||
|
||||
task_columns = await conn.execute(text("PRAGMA table_info(tasks)"))
|
||||
task_column_names = {row[1] for row in task_columns.fetchall()}
|
||||
assert 'source' in task_column_names
|
||||
assert 'conversation_id' in task_column_names
|
||||
assert 'quadrant' in task_column_names
|
||||
assert 'assignee_type' in task_column_names
|
||||
assert 'dispatch_status' in task_column_names
|
||||
assert 'dispatch_run_id' in task_column_names
|
||||
assert 'last_synced_at' in task_column_names
|
||||
|
||||
history_columns = await conn.execute(text("PRAGMA table_info(task_histories)"))
|
||||
history_column_names = {row[1] for row in history_columns.fetchall()}
|
||||
assert 'subtask_id' in history_column_names
|
||||
|
||||
subtask_columns = await conn.execute(text("PRAGMA table_info(task_subtasks)"))
|
||||
subtask_column_names = {row[1] for row in subtask_columns.fetchall()}
|
||||
assert 'task_id' in subtask_column_names
|
||||
assert 'order_index' in subtask_column_names
|
||||
assert 'dispatch_status' in subtask_column_names
|
||||
|
||||
indexes = await conn.execute(text("PRAGMA index_list(task_subtasks)"))
|
||||
index_names = {row[1] for row in indexes.fetchall()}
|
||||
assert 'ix_task_subtasks_task_id' in index_names
|
||||
assert 'ix_task_subtasks_dispatch_status' in index_names
|
||||
|
||||
await engine.dispose()
|
||||
90
backend/tests/backend/app/test_folder_router.py
Normal file
90
backend/tests/backend/app/test_folder_router.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
import app.models # noqa: F401
|
||||
from app.database import Base, get_db
|
||||
from app.main import app
|
||||
from app.models.folder import Folder
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
from app.services.auth_service import get_password_hash
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def folder_router_env(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / 'test_folders_router.db'
|
||||
upload_dir = tmp_path / 'uploads'
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with session_factory() as session:
|
||||
user = User(
|
||||
email='folders@example.com',
|
||||
hashed_password=get_password_hash('secret123'),
|
||||
full_name='Folder Tester',
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
monkeypatch.setattr('app.services.document_service.settings.UPLOAD_DIR', str(upload_dir))
|
||||
|
||||
async def override_get_db():
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
|
||||
async def override_get_current_user():
|
||||
return user
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
|
||||
try:
|
||||
yield user, upload_dir, session_factory
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_folder_creates_matching_local_directory(folder_router_env):
|
||||
user, upload_dir, _session_factory = folder_router_env
|
||||
transport = ASGITransport(app=app)
|
||||
|
||||
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||
response = await client.post('/api/folders', json={'name': 'Projects', 'parent_id': None})
|
||||
|
||||
assert response.status_code == 201
|
||||
folder_id = response.json()['id']
|
||||
|
||||
expected_path = upload_dir / user.id / 'Projects'
|
||||
assert expected_path.exists()
|
||||
assert expected_path.is_dir()
|
||||
assert folder_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_moves_local_directory(folder_router_env):
|
||||
user, upload_dir, session_factory = folder_router_env
|
||||
|
||||
async with session_factory() as session:
|
||||
folder = Folder(user_id=user.id, name='Old', parent_id=None)
|
||||
session.add(folder)
|
||||
await session.commit()
|
||||
await session.refresh(folder)
|
||||
|
||||
(upload_dir / user.id / 'Old').mkdir(parents=True, exist_ok=True)
|
||||
transport = ASGITransport(app=app)
|
||||
|
||||
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||
response = await client.put(f'/api/folders/{folder.id}', json={'name': 'New'})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert not (upload_dir / user.id / 'Old').exists()
|
||||
assert (upload_dir / user.id / 'New').exists()
|
||||
@@ -1,10 +1,11 @@
|
||||
import sys
|
||||
from datetime import UTC, date, datetime
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
sys.modules.setdefault('psutil', Mock())
|
||||
@@ -13,7 +14,7 @@ import app.models # noqa: F401
|
||||
from app.database import Base, get_db
|
||||
from app.models.goal import Goal
|
||||
from app.models.reminder import Reminder
|
||||
from app.models.task import Task, TaskPriority, TaskStatus
|
||||
from app.models.task import DispatchStatus, Task, TaskPriority, TaskQuadrant, TaskStatus, TaskSubTask
|
||||
from app.models.todo import DailyTodo, TodoSource
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
@@ -50,7 +51,7 @@ async def schedule_env(tmp_path):
|
||||
session.add_all([user, other_user])
|
||||
await session.flush()
|
||||
|
||||
session.add_all([
|
||||
seeded_items = [
|
||||
DailyTodo(
|
||||
user_id=user.id,
|
||||
title='Legacy todo',
|
||||
@@ -78,13 +79,19 @@ async def schedule_env(tmp_path):
|
||||
title='High priority task',
|
||||
priority=TaskPriority.HIGH,
|
||||
status=TaskStatus.TODO,
|
||||
source='schedule_center',
|
||||
quadrant=TaskQuadrant.URGENT_IMPORTANT,
|
||||
due_date=datetime(2026, 4, 10, 14, 0, tzinfo=UTC),
|
||||
assignee_type='commander',
|
||||
assignee_id='master',
|
||||
dispatch_status=DispatchStatus.RUNNING,
|
||||
),
|
||||
Task(
|
||||
user_id=user.id,
|
||||
title='Urgent task next day',
|
||||
priority=TaskPriority.URGENT,
|
||||
status=TaskStatus.IN_PROGRESS,
|
||||
quadrant=TaskQuadrant.NOT_URGENT_IMPORTANT,
|
||||
due_date=datetime(2026, 4, 11, 10, 0, tzinfo=UTC),
|
||||
),
|
||||
Task(
|
||||
@@ -106,6 +113,30 @@ async def schedule_env(tmp_path):
|
||||
note='Ship MVP',
|
||||
goal_date='2026-04-10',
|
||||
),
|
||||
]
|
||||
session.add_all(seeded_items)
|
||||
await session.flush()
|
||||
high_priority_task = next(item for item in seeded_items if isinstance(item, Task) and item.title == 'High priority task')
|
||||
session.add_all([
|
||||
TaskSubTask(
|
||||
task_id=high_priority_task.id,
|
||||
title='Commander follow-up',
|
||||
status=TaskStatus.TODO,
|
||||
order_index=0,
|
||||
assignee_type='agent',
|
||||
assignee_id='executor',
|
||||
dispatch_status=DispatchStatus.QUEUED,
|
||||
),
|
||||
TaskSubTask(
|
||||
task_id=high_priority_task.id,
|
||||
title='Commander completed step',
|
||||
status=TaskStatus.DONE,
|
||||
order_index=1,
|
||||
assignee_type='agent',
|
||||
assignee_id='executor',
|
||||
dispatch_status=DispatchStatus.COMPLETED,
|
||||
completed_at=datetime(2026, 4, 10, 16, 0, tzinfo=UTC),
|
||||
),
|
||||
])
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
@@ -211,10 +242,143 @@ async def test_get_schedule_center_date_returns_aggregated_resources(schedule_en
|
||||
'reminder_total': 1,
|
||||
'goal_total': 1,
|
||||
}
|
||||
assert [item['title'] for item in payload['focus_tasks']] == ['High priority task']
|
||||
assert [item['id'] for item in payload['quadrants']] == [
|
||||
'urgent-important',
|
||||
'not-urgent-important',
|
||||
'urgent-not-important',
|
||||
'not-urgent-not-important',
|
||||
]
|
||||
assert payload['quadrants'][0]['tasks'][0]['title'] == 'High priority task'
|
||||
assert payload['commander_summary'] == {
|
||||
'total': 3,
|
||||
'queued': 1,
|
||||
'running': 1,
|
||||
'completed': 1,
|
||||
'failed': 0,
|
||||
'overall_status': 'running',
|
||||
}
|
||||
assert [item['title'] for item in payload['reminders']] == ['Doctor reminder']
|
||||
assert [item['title'] for item in payload['goals']] == ['Launch calendar beta']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_detail_and_subtask_crud(schedule_env):
|
||||
transport = ASGITransport(app=schedule_env)
|
||||
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||
create_response = await client.post(
|
||||
'/api/tasks',
|
||||
json={
|
||||
'title': 'Plan Today Status',
|
||||
'description': 'Wire task detail and quadrants',
|
||||
'priority': 'high',
|
||||
'quadrant': 'urgent-important',
|
||||
'source': 'today_status',
|
||||
'assignee_type': 'commander',
|
||||
},
|
||||
)
|
||||
task_id = create_response.json()['id']
|
||||
subtask_create = await client.post(
|
||||
f'/api/tasks/{task_id}/subtasks',
|
||||
json={'title': 'Model backend', 'assignee_type': 'executor'},
|
||||
)
|
||||
subtask_id = subtask_create.json()['id']
|
||||
subtask_update = await client.patch(
|
||||
f'/api/tasks/{task_id}/subtasks/{subtask_id}',
|
||||
json={'status': 'done'},
|
||||
)
|
||||
detail_response = await client.get(f'/api/tasks/{task_id}')
|
||||
reorder_response = await client.post(
|
||||
f'/api/tasks/{task_id}/subtasks/reorder',
|
||||
json={'items': [{'id': subtask_id, 'order_index': 0}]},
|
||||
)
|
||||
|
||||
assert create_response.status_code == 201
|
||||
assert subtask_create.status_code == 201
|
||||
assert subtask_update.status_code == 200
|
||||
assert detail_response.status_code == 200
|
||||
assert reorder_response.status_code == 200
|
||||
detail_payload = detail_response.json()
|
||||
assert detail_payload['source'] == 'today_status'
|
||||
assert detail_payload['quadrant'] == 'urgent-important'
|
||||
assert detail_payload['subtasks'][0]['title'] == 'Model backend'
|
||||
assert detail_payload['subtasks'][0]['status'] == 'done'
|
||||
assert any(entry['action'] == 'subtask_created' for entry in detail_payload['history'])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_dispatch_updates_summary_and_detail(schedule_env):
|
||||
transport = ASGITransport(app=schedule_env)
|
||||
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||
create_response = await client.post(
|
||||
'/api/tasks',
|
||||
json={'title': 'Dispatch me', 'priority': 'urgent', 'due_date': '2026-04-10T09:00:00Z'},
|
||||
)
|
||||
task_id = create_response.json()['id']
|
||||
dispatch_response = await client.post(
|
||||
f'/api/tasks/{task_id}/dispatch',
|
||||
json={'assignee_type': 'commander', 'note': 'Send to runtime'},
|
||||
)
|
||||
detail_response = await client.get(f'/api/tasks/{task_id}')
|
||||
|
||||
assert dispatch_response.status_code == 200
|
||||
dispatch_payload = dispatch_response.json()
|
||||
assert dispatch_payload['status'] == 'queued'
|
||||
assert dispatch_payload['run_id']
|
||||
detail_payload = detail_response.json()
|
||||
assert detail_payload['dispatch_status'] == 'queued'
|
||||
assert detail_payload['dispatch_summary']['status'] == 'queued'
|
||||
assert any(entry['action'] == 'dispatched_to_commander' for entry in detail_payload['history'])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_center_created_task_is_visible_in_today_status_aggregate(schedule_env):
|
||||
transport = ASGITransport(app=schedule_env)
|
||||
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||
create_response = await client.post(
|
||||
'/api/tasks',
|
||||
json={
|
||||
'title': 'Schedule Center created task',
|
||||
'source': 'schedule_center',
|
||||
'priority': 'medium',
|
||||
'quadrant': 'not-urgent-important',
|
||||
'due_date': '2026-04-10T09:00:00Z',
|
||||
},
|
||||
)
|
||||
date_response = await client.get('/api/schedule-center/date', params={'date_str': '2026-04-10'})
|
||||
|
||||
assert create_response.status_code == 201
|
||||
titles = [item['title'] for item in date_response.json()['tasks']]
|
||||
assert 'Schedule Center created task' in titles
|
||||
focus_titles = [item['title'] for item in date_response.json()['focus_tasks']]
|
||||
assert 'Schedule Center created task' in focus_titles
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_today_status_created_task_is_visible_in_schedule_center_aggregate(schedule_env):
|
||||
transport = ASGITransport(app=schedule_env)
|
||||
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||
create_response = await client.post(
|
||||
'/api/tasks',
|
||||
json={
|
||||
'title': 'Today Status created task',
|
||||
'source': 'today_status',
|
||||
'priority': 'high',
|
||||
'quadrant': 'urgent-important',
|
||||
'due_date': '2026-04-10T11:00:00Z',
|
||||
},
|
||||
)
|
||||
date_response = await client.get('/api/schedule-center/date', params={'date_str': '2026-04-10'})
|
||||
|
||||
assert create_response.status_code == 201
|
||||
quadrant_titles = [
|
||||
task['title']
|
||||
for quadrant in date_response.json()['quadrants']
|
||||
for task in quadrant['tasks']
|
||||
]
|
||||
assert 'Today Status created task' in quadrant_titles
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_schedule_center_month_returns_day_summaries(schedule_env):
|
||||
transport = ASGITransport(app=schedule_env)
|
||||
@@ -239,6 +403,65 @@ async def test_get_schedule_center_month_returns_day_summaries(schedule_env):
|
||||
assert day_11['high_priority_total'] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_center_tolerates_legacy_lowercase_task_enum_values(schedule_env):
|
||||
app = schedule_env
|
||||
session_override = app.dependency_overrides[get_db]
|
||||
|
||||
async for session in session_override():
|
||||
user_id = (
|
||||
await session.execute(
|
||||
text("SELECT id FROM users WHERE email = 'schedule@example.com'")
|
||||
)
|
||||
).scalar_one()
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO tasks (
|
||||
id, created_at, updated_at, user_id, title, description,
|
||||
status, priority, due_date, completed_at, tags, source,
|
||||
conversation_id, quadrant, assignee_type, assignee_id,
|
||||
dispatch_status, dispatch_run_id, result_summary, started_at, last_synced_at
|
||||
) VALUES (
|
||||
:id, :created_at, :updated_at, :user_id, :title, NULL,
|
||||
:status, :priority, :due_date, NULL, NULL, :source,
|
||||
NULL, :quadrant, :assignee_type, :assignee_id,
|
||||
:dispatch_status, NULL, NULL, NULL, NULL
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
'id': 'legacy-task-1',
|
||||
'created_at': '2026-04-10 06:00:00',
|
||||
'updated_at': '2026-04-10 06:00:00',
|
||||
'user_id': user_id,
|
||||
'title': 'Legacy lowercase enum task',
|
||||
'status': 'todo',
|
||||
'priority': 'high',
|
||||
'due_date': '2026-04-10 09:00:00',
|
||||
'source': 'manual',
|
||||
'quadrant': 'urgent-important',
|
||||
'assignee_type': 'commander',
|
||||
'assignee_id': 'legacy-master',
|
||||
'dispatch_status': 'queued',
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
break
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||
date_response = await client.get('/api/schedule-center/date', params={'date_str': '2026-04-10'})
|
||||
month_response = await client.get('/api/schedule-center/month', params={'year': 2026, 'month': 4})
|
||||
|
||||
assert date_response.status_code == 200
|
||||
assert month_response.status_code == 200
|
||||
date_payload = date_response.json()
|
||||
month_payload = month_response.json()
|
||||
assert 'Legacy lowercase enum task' in [item['title'] for item in date_payload['tasks']]
|
||||
assert next(item for item in month_payload['days'] if item['date'] == '2026-04-10')['task_due_total'] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_reminder_with_naive_datetime_and_time_zone_appears_in_schedule_center(schedule_env):
|
||||
transport = ASGITransport(app=schedule_env)
|
||||
|
||||
@@ -54,6 +54,9 @@ async def skill_env(tmp_path, monkeypatch):
|
||||
required_context=[],
|
||||
visibility='private',
|
||||
is_active=True,
|
||||
status='active',
|
||||
scope=['schedule_planner'],
|
||||
effectiveness=0.88,
|
||||
owner_id=user.id,
|
||||
),
|
||||
Skill(
|
||||
@@ -65,6 +68,9 @@ async def skill_env(tmp_path, monkeypatch):
|
||||
required_context=[],
|
||||
visibility='private',
|
||||
is_active=True,
|
||||
status='shadow',
|
||||
scope=['executor'],
|
||||
effectiveness=0.41,
|
||||
owner_id=user.id,
|
||||
),
|
||||
Skill(
|
||||
@@ -76,6 +82,8 @@ async def skill_env(tmp_path, monkeypatch):
|
||||
required_context=[],
|
||||
visibility='private',
|
||||
is_active=True,
|
||||
status='active',
|
||||
scope=['schedule_planner'],
|
||||
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('is_builtin' in item 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',
|
||||
}
|
||||
221
backend/tests/backend/app/test_task_router.py
Normal file
221
backend/tests/backend/app/test_task_router.py
Normal file
@@ -0,0 +1,221 @@
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import UTC, date, datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
sys.modules.setdefault('psutil', Mock())
|
||||
|
||||
import app.models # noqa: F401
|
||||
from app.database import Base, get_db
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
from app.routers.schedule_center import router as schedule_center_router
|
||||
from app.routers.task import router as task_router
|
||||
from app.services.auth_service import get_password_hash
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def task_env(tmp_path):
|
||||
db_path = tmp_path / 'test_task_router.db'
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with session_factory() as session:
|
||||
user = User(
|
||||
username='task_user',
|
||||
email='task@example.com',
|
||||
hashed_password=get_password_hash('secret123'),
|
||||
full_name='Task Tester',
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
async def override_get_db():
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
|
||||
async def override_get_current_user():
|
||||
return user
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(task_router)
|
||||
test_app.include_router(schedule_center_router)
|
||||
test_app.dependency_overrides[get_db] = override_get_db
|
||||
test_app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
|
||||
try:
|
||||
yield test_app
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_and_get_detail_with_business_fields(task_env):
|
||||
transport = ASGITransport(app=task_env)
|
||||
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||
create_response = await client.post(
|
||||
'/api/tasks',
|
||||
json={
|
||||
'title': 'Prepare daily status',
|
||||
'description': 'Assemble the updated today status payload',
|
||||
'priority': 'high',
|
||||
'due_date': '2026-04-10T09:00:00Z',
|
||||
'tags': ['today-status', 'chat'],
|
||||
'source': 'chat',
|
||||
'conversation_id': 'conv-123',
|
||||
'quadrant': 'urgent-important',
|
||||
'assignee_type': 'commander',
|
||||
'assignee_id': 'code_commander',
|
||||
},
|
||||
)
|
||||
|
||||
assert create_response.status_code == 201
|
||||
created = create_response.json()
|
||||
detail_response = await client.get(f"/api/tasks/{created['id']}")
|
||||
|
||||
assert detail_response.status_code == 200
|
||||
payload = detail_response.json()
|
||||
assert payload['title'] == 'Prepare daily status'
|
||||
assert payload['tags'] == ['today-status', 'chat']
|
||||
assert payload['source'] == 'chat'
|
||||
assert payload['conversation_id'] == 'conv-123'
|
||||
assert payload['quadrant'] == 'urgent-important'
|
||||
assert payload['assignee_type'] == 'commander'
|
||||
assert payload['assignee_id'] == 'code_commander'
|
||||
assert payload['dispatch']['status'] == 'idle'
|
||||
assert [item['action'] for item in payload['history'][:2]] == ['created_from_chat', 'created']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subtask_crud_and_reorder(task_env):
|
||||
transport = ASGITransport(app=task_env)
|
||||
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||
task_response = await client.post(
|
||||
'/api/tasks',
|
||||
json={
|
||||
'title': 'Implement kanban detail',
|
||||
'due_date': '2026-04-10T11:00:00Z',
|
||||
'quadrant': 'not-urgent-important',
|
||||
},
|
||||
)
|
||||
task_id = task_response.json()['id']
|
||||
|
||||
first_subtask = await client.post(
|
||||
f'/api/tasks/{task_id}/subtasks',
|
||||
json={'title': 'Load task detail', 'assignee_type': 'agent', 'assignee_id': 'planner'},
|
||||
)
|
||||
second_subtask = await client.post(
|
||||
f'/api/tasks/{task_id}/subtasks',
|
||||
json={'title': 'Persist task edits', 'assignee_type': 'agent', 'assignee_id': 'executor'},
|
||||
)
|
||||
first_id = first_subtask.json()['subtasks'][0]['id']
|
||||
second_id = second_subtask.json()['subtasks'][1]['id']
|
||||
|
||||
update_response = await client.patch(
|
||||
f'/api/tasks/{task_id}/subtasks/{first_id}',
|
||||
json={'status': 'done'},
|
||||
)
|
||||
reorder_response = await client.post(
|
||||
f'/api/tasks/{task_id}/subtasks/reorder',
|
||||
json={'items': [{'id': first_id, 'order_index': 1}, {'id': second_id, 'order_index': 0}]},
|
||||
)
|
||||
|
||||
assert update_response.status_code == 200
|
||||
assert reorder_response.status_code == 200
|
||||
reordered = reorder_response.json()
|
||||
assert [item['id'] for item in reordered['subtasks']] == [second_id, first_id]
|
||||
assert reordered['subtasks'][1]['status'] == 'done'
|
||||
assert any(item['action'] == 'subtask_reordered' for item in reordered['history'])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_today_status_task_persists_status_and_subtasks(task_env):
|
||||
transport = ASGITransport(app=task_env)
|
||||
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||
create_response = await client.post(
|
||||
'/api/tasks',
|
||||
json={
|
||||
'title': '今日看板任务',
|
||||
'description': '来自今日状态看板',
|
||||
'status': 'in_progress',
|
||||
'priority': 'high',
|
||||
'source': 'today_status',
|
||||
'quadrant': 'urgent-important',
|
||||
'subtasks': [
|
||||
{'title': '子任务一', 'status': 'todo'},
|
||||
{'title': '子任务二', 'status': 'done'},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
payload = create_response.json()
|
||||
|
||||
detail_response = await client.get(f"/api/tasks/{payload['id']}")
|
||||
|
||||
assert detail_response.status_code == 200
|
||||
detail_payload = detail_response.json()
|
||||
assert detail_payload['status'] == 'in_progress'
|
||||
assert detail_payload['source'] == 'today_status'
|
||||
assert detail_payload['quadrant'] == 'urgent-important'
|
||||
assert [item['title'] for item in detail_payload['subtasks']] == ['子任务一', '子任务二']
|
||||
assert detail_payload['subtasks'][0]['order_index'] == 0
|
||||
assert detail_payload['subtasks'][1]['order_index'] == 1
|
||||
assert detail_payload['subtasks'][1]['status'] == 'done'
|
||||
assert detail_payload['subtasks'][1]['completed_at'] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_updates_task_and_schedule_center_summary(task_env):
|
||||
transport = ASGITransport(app=task_env)
|
||||
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||
task_response = await client.post(
|
||||
'/api/tasks',
|
||||
json={
|
||||
'title': 'Dispatch to commander',
|
||||
'priority': 'urgent',
|
||||
'due_date': '2026-04-10T15:00:00Z',
|
||||
'quadrant': 'urgent-important',
|
||||
},
|
||||
)
|
||||
task_id = task_response.json()['id']
|
||||
|
||||
dispatch_response = await client.post(
|
||||
f'/api/tasks/{task_id}/dispatch',
|
||||
json={'target': 'commander'},
|
||||
)
|
||||
assert dispatch_response.status_code == 200
|
||||
assert dispatch_response.json()['task']['dispatch']['status'] == 'queued'
|
||||
|
||||
await asyncio.sleep(0.18)
|
||||
|
||||
detail_response = await client.get(f'/api/tasks/{task_id}')
|
||||
date_response = await client.get('/api/schedule-center/date', params={'date_str': '2026-04-10'})
|
||||
|
||||
assert detail_response.status_code == 200
|
||||
detail = detail_response.json()
|
||||
assert detail['dispatch']['status'] == 'completed'
|
||||
assert detail['status'] == 'done'
|
||||
assert detail['dispatch']['run_id']
|
||||
assert detail['dispatch']['result_summary']
|
||||
|
||||
assert date_response.status_code == 200
|
||||
payload = date_response.json()
|
||||
assert payload['commander_summary'] == {
|
||||
'total': 1,
|
||||
'queued': 0,
|
||||
'running': 0,
|
||||
'completed': 1,
|
||||
'failed': 0,
|
||||
}
|
||||
assert payload['focus_tasks'][0]['id'] == task_id
|
||||
quadrants = {item['id']: item for item in payload['quadrants']}
|
||||
assert quadrants['urgent-important']['tasks'][0]['id'] == task_id
|
||||
BIN
data/jarvis.db
BIN
data/jarvis.db
Binary file not shown.
86
development-doc/daily/2026-04-07.md
Normal file
86
development-doc/daily/2026-04-07.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# 2026-04-07 工作日志
|
||||
|
||||
## 今日开发计划
|
||||
|
||||
### 今日目标
|
||||
|
||||
- 梳理 chat 页面左侧日历与 conversation session 的关系
|
||||
- 明确“按日期点击切换 session”的改造方向
|
||||
- 记录当前 session 机制与后续实现方案
|
||||
|
||||
### 今日计划拆分
|
||||
|
||||
1. 盘点当前 conversation session 的数据结构与切换逻辑
|
||||
2. 确认 session 当前不是按“天”进行切分
|
||||
3. 设计日历点击后的 session 切换方案
|
||||
4. 将方案记录到 daily,作为后续改造依据
|
||||
|
||||
---
|
||||
|
||||
## 今日实际完成
|
||||
|
||||
- 检查了前端 `conversation store`、`conversation api` 与后端 `conversation router / model`
|
||||
- 确认当前 conversation session 以 `conversation_id` 为核心,不是按“天”自动切分
|
||||
- 确认现有字段主要依赖 `created_at` / `updated_at` 做时间记录,但单个 session 可跨多天持续使用
|
||||
- 明确了左侧日历点击切换 session 的推荐改造方式
|
||||
|
||||
---
|
||||
|
||||
## 当前结论
|
||||
|
||||
### session 现状
|
||||
|
||||
- 当前 session 不是以“天”为单位计算
|
||||
- 当前会话列表来源于 `/api/conversations`,按 `updated_at` 倒序展示
|
||||
- 点击某一天时,不能直接假定“一天对应一个现成 session”
|
||||
|
||||
### 推荐改造方案
|
||||
|
||||
采用“**保留现有 conversation 结构 + 前端增加按日期筛选/映射**”的方案:
|
||||
|
||||
1. 保持后端 `Conversation` / `Message` 结构不变
|
||||
2. 前端基于 `created_at` 或 `updated_at` 将 conversations 映射到具体日期
|
||||
3. 左侧日历某天被点击后,优先切换到该日期最近一次活跃的 session
|
||||
4. 如果该日期没有 session,则进入新会话态,必要时再创建新的 conversation
|
||||
5. 会话本质仍是 conversation,不强制把数据库层改成“每天一个 session”
|
||||
|
||||
### 这样处理的原因
|
||||
|
||||
- 不需要重做现有 conversation 数据模型
|
||||
- 不会破坏当前多轮上下文连续性
|
||||
- 可以快速给日历交互增加“按日期查看/切换”能力
|
||||
- 后续如果要做“每日会话视图”或“按天归档”也更容易扩展
|
||||
|
||||
---
|
||||
|
||||
## 建议的后续实现点
|
||||
|
||||
1. 在前端增加“选中日期”状态
|
||||
2. 将 conversations 按日期建立索引映射
|
||||
3. 日历点击时,根据日期找到对应 session 并调用 `selectConversation`
|
||||
4. 如果无匹配 session,则清空当前消息区并进入新会话态
|
||||
5. 视需要补充“当天无会话”的空态提示
|
||||
|
||||
---
|
||||
|
||||
## 风险与临时决策
|
||||
|
||||
### 风险
|
||||
|
||||
- 如果一个 conversation 跨多天活跃,按 `created_at` 还是 `updated_at` 归属日期需要统一规则
|
||||
- 如果同一天有多个 session,需要定义点击日历后的优先选择策略
|
||||
|
||||
### 当前临时决策
|
||||
|
||||
- 先按 `updated_at` 作为日历映射依据,更符合“最近活跃”的使用直觉
|
||||
- 先选择该日期下最近活跃的一条 conversation 作为默认切换目标
|
||||
- 暂不改数据库,不引入“每天强制新建 session”的硬规则
|
||||
|
||||
---
|
||||
|
||||
## 下一步计划
|
||||
|
||||
1. 在 chat 页整理日历点击事件接入点
|
||||
2. 补充 conversations 与日期映射的前端计算逻辑
|
||||
3. 明确空态与多 session 同日时的交互细节
|
||||
4. 开始实现“点击日历切换 session”功能
|
||||
160
development-doc/plan/hermes-update/README.md
Normal file
160
development-doc/plan/hermes-update/README.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Hermes-first 重构计划索引
|
||||
|
||||
本目录用于沉淀 Jarvis 从“自研 agent 主流程 + Hermes 可选 adapter”转向 **Hermes-first 架构** 的分阶段计划。
|
||||
|
||||
目标不是把 Jarvis 砍掉重写,而是把架构中心调整为:
|
||||
|
||||
- **Hermes**:默认 execution core
|
||||
- **Jarvis**:product shell,负责 chat UI、conversation/message 持久化、memory/knowledge/task、continuity、observability、rollback
|
||||
|
||||
---
|
||||
|
||||
## 当前目标
|
||||
|
||||
1. 不再把 Hermes 只看作可选 runtime,而是作为默认核心方向。
|
||||
2. 保留 Jarvis 的产品价值,不把业务层能力粗暴塞进 Hermes 黑盒。
|
||||
3. 保证 chat 仍是连续会话体验,不接受每轮冷启动。
|
||||
4. 保持现有 `/api/conversations/chat/stream` 与 SSE 契约稳定。
|
||||
5. 保留迁移期 fallback / 回滚能力,不做不可逆替换。
|
||||
|
||||
---
|
||||
|
||||
## 文档说明
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `README.md` | 总览、阶段关系、总体原则 |
|
||||
| `adr-hermes-first-architecture.md` | Hermes-first 的架构决策记录 |
|
||||
| `phase-h0-ownership-and-adr.md` | ownership matrix、边界与成功标准 |
|
||||
| `phase-h1-agent-service-inversion.md` | `AgentService` 从 runtime 本体转为产品层编排 |
|
||||
| `phase-h2-continuity-envelope.md` | `Conversation.agent_state` 的 runtime-neutral envelope |
|
||||
| `phase-h3-durable-session-lifecycle.md` | Hermes durable session lifecycle |
|
||||
| `phase-h4-product-shell-assembly.md` | Jarvis product shell 的 pre-runtime assembly |
|
||||
| `phase-h5-event-mapper-and-sse-contract.md` | Hermes event -> Jarvis SSE mapper |
|
||||
| `phase-h6-frontend-hermes-first-session-model.md` | 前端从 runtime toggle 过渡到 Hermes-first session model |
|
||||
| `phase-h7-default-rollout-and-fallback.md` | 默认切换、灰度、fallback 与回滚 |
|
||||
| `checklist.md` | 分阶段执行清单 |
|
||||
|
||||
---
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
1. `adr-hermes-first-architecture.md`
|
||||
2. `phase-h0-ownership-and-adr.md`
|
||||
3. `phase-h1-agent-service-inversion.md`
|
||||
4. `phase-h2-continuity-envelope.md`
|
||||
5. `phase-h3-durable-session-lifecycle.md`
|
||||
6. `phase-h4-product-shell-assembly.md`
|
||||
7. `phase-h5-event-mapper-and-sse-contract.md`
|
||||
8. `phase-h6-frontend-hermes-first-session-model.md`
|
||||
9. `phase-h7-default-rollout-and-fallback.md`
|
||||
10. `checklist.md`
|
||||
|
||||
---
|
||||
|
||||
## 当前总体状态(2026-04-10)
|
||||
|
||||
| Phase | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| H0 | 进行中 | 已明确从 adapter-first 转向 Hermes-first,需要先补完整文档 |
|
||||
| H1 | 待开始 | `AgentService` 仍过于集中,Jarvis runtime 尚未完全 adapter 化 |
|
||||
| H2 | 待开始 | `Conversation.agent_state` 尚未统一成 runtime-neutral envelope |
|
||||
| H3 | 待开始 | `HermesSessionManager` 仍偏进程内原型 |
|
||||
| H4 | 待开始 | Jarvis 的 memory/skills/task graph 仍需固化为 product shell 装配层 |
|
||||
| H5 | 待开始 | SSE 兼容已初步存在,但缺少稳定事件映射边界 |
|
||||
| H6 | 待开始 | 前端仍把 runtime 视作用户可切换字符串,而非 session model |
|
||||
| H7 | 待开始 | 还没有服务端默认 runtime policy / rollout / fallback 策略 |
|
||||
|
||||
---
|
||||
|
||||
## 总体实施原则
|
||||
|
||||
1. **先文档后开发**:先写清楚阶段文档,再按文档开发。
|
||||
2. **Hermes 做核心,Jarvis 做产品**:不让 Jarvis 继续承担主 runtime 本体。
|
||||
3. **连续对话优先**:必须支持 warm session / resumed session,而不是每轮冷启动。
|
||||
4. **契约稳定优先**:前端继续消费稳定 SSE,不直接理解 Hermes 内部事件。
|
||||
5. **渐进切换优先**:迁移期间保留 fallback 和回滚,不做一次性替换。
|
||||
6. **复用优先**:memory、skill shortlist、task graph、conversation persistence 尽量保留为 Jarvis 产品层能力。
|
||||
|
||||
---
|
||||
|
||||
## Ownership Matrix(摘要)
|
||||
|
||||
### Hermes Core
|
||||
- session lifecycle
|
||||
- runtime resume / recovery
|
||||
- turn execution loop
|
||||
- chunk streaming
|
||||
- runtime-internal tool loop
|
||||
|
||||
### Jarvis Product Shell
|
||||
- conversation/message persistence
|
||||
- memory context assembly
|
||||
- skill shortlist
|
||||
- task graph
|
||||
- product continuity
|
||||
- SSE contract
|
||||
- runtime observability
|
||||
- rollout / fallback policy
|
||||
|
||||
### Shared Contracts
|
||||
- runtime prepared context
|
||||
- runtime event model
|
||||
- continuity envelope
|
||||
- health / metrics metadata
|
||||
|
||||
---
|
||||
|
||||
## 阶段依赖图
|
||||
|
||||
```text
|
||||
H0 -> H1 -> H2 -> H3 -> H4 -> H5 -> H6 -> H7
|
||||
```
|
||||
|
||||
说明:
|
||||
- 没有 H1,就无法真正把 Jarvis 从 runtime 本体降级为产品层。
|
||||
- 没有 H2/H3,就无法让 Hermes-first 具备可靠 continuity。
|
||||
- 没有 H5/H6,前端会被 Hermes 内部细节污染。
|
||||
- 没有 H7,就无法安全默认切换。
|
||||
|
||||
---
|
||||
|
||||
## 关键风险
|
||||
|
||||
1. 把 Hermes session id 错当成完整 continuity。
|
||||
2. 让前端直接依赖 Hermes-native event 细节。
|
||||
3. `AgentService` 持续膨胀成新的耦合中心。
|
||||
4. runtime toggle 长期暴露为普通用户负担。
|
||||
5. 只靠进程内 session manager,缺少 durable 恢复。
|
||||
6. 没有 rollback policy 就直接默认切换。
|
||||
|
||||
---
|
||||
|
||||
## 当前代码锚点
|
||||
|
||||
### Backend
|
||||
- `backend/app/services/agent_service.py`
|
||||
- `backend/app/services/agent_runtime/base.py`
|
||||
- `backend/app/services/agent_runtime/hermes_runtime.py`
|
||||
- `backend/app/services/agent_runtime/hermes_session_manager.py`
|
||||
- `backend/app/models/conversation.py`
|
||||
- `backend/app/schemas/conversation.py`
|
||||
- `backend/app/routers/conversation.py`
|
||||
|
||||
### Frontend
|
||||
- `frontend/src/api/conversation.ts`
|
||||
- `frontend/src/pages/chat/composables/useChatView.ts`
|
||||
- `frontend/src/pages/chat/index.vue`
|
||||
- `frontend/src/stores/conversation.ts`
|
||||
- `frontend/src/api/agent.ts`
|
||||
|
||||
---
|
||||
|
||||
## 预期阶段结论
|
||||
|
||||
当本轮文档与实施完成后,应该达到:
|
||||
|
||||
- Hermes 成为默认 execution core 的明确落地方向。
|
||||
- Jarvis 保留为 product shell,而不是继续扩展自研 runtime。
|
||||
- chat 继续是消息流产品,不变成终端模拟器。
|
||||
- 默认切换前拥有清晰的灰度、fallback、回滚策略。
|
||||
@@ -0,0 +1,84 @@
|
||||
# ADR:Hermes-first 架构
|
||||
|
||||
## 状态
|
||||
|
||||
Accepted(进入实施规划)
|
||||
|
||||
## 背景
|
||||
|
||||
Jarvis 当前已经具备较强的自研 agent runtime 能力,但核心执行链路仍然偏自定义、偏集中式,导致:
|
||||
|
||||
- 执行 runtime 与产品层耦合过深
|
||||
- Hermes 虽已接入真实 bridge,但仍只是 adapter 分支
|
||||
- 长驻 session、恢复、执行循环等能力没有形成更清晰的 runtime ownership
|
||||
- 前端虽然能切 runtime,但本质仍是 Jarvis-centered UX + backend branching
|
||||
|
||||
同时,用户明确表达:
|
||||
|
||||
- 更偏好 Hermes 的体系化能力
|
||||
- 不希望继续扩展自研 agent 主链路
|
||||
- 希望连续对话、常驻 session、不冷启动
|
||||
- 要求先文档后开发
|
||||
|
||||
## 决策
|
||||
|
||||
采用 **Hermes-first architecture**:
|
||||
|
||||
- Hermes 成为默认 execution core
|
||||
- Jarvis 保留为 product shell
|
||||
- 旧 Jarvis graph 在迁移期保留为 fallback / specialist path
|
||||
- 前端继续使用 Jarvis chat product shell,而不是直接暴露 Hermes 终端形态
|
||||
- SSE 契约保持稳定,由 Jarvis 负责做 runtime event mapping
|
||||
|
||||
## 责任边界
|
||||
|
||||
### Hermes 负责
|
||||
- session lifecycle
|
||||
- runtime resume / restart / health
|
||||
- execution loop
|
||||
- chunk streaming
|
||||
- runtime-internal tool orchestration
|
||||
|
||||
### Jarvis 负责
|
||||
- conversation/message persistence
|
||||
- memory / knowledge / retrospective assembly
|
||||
- skill shortlist
|
||||
- task graph shaping
|
||||
- product continuity envelope
|
||||
- SSE contract
|
||||
- observability / metrics / attachments
|
||||
- rollout / fallback / rollback policy
|
||||
|
||||
## 不采用的方案
|
||||
|
||||
### 方案 A:继续保持 adapter-first
|
||||
不采用原因:
|
||||
- Hermes 长期只会是支线 runtime
|
||||
- `AgentService` 会继续膨胀
|
||||
- 无法真正把架构中心从 Jarvis runtime 本体迁走
|
||||
|
||||
### 方案 B:直接删除 Jarvis,自上而下改成 Hermes 原生产品
|
||||
不采用原因:
|
||||
- 会丢失 Jarvis 已有的 conversation、memory、task、业务层价值
|
||||
- 缺乏迁移和回滚路径
|
||||
- 风险过高
|
||||
|
||||
## 影响
|
||||
|
||||
正向影响:
|
||||
- runtime 职责更清晰
|
||||
- 长驻 session 方向更明确
|
||||
- 后续维护重心从“造 runtime”转向“做产品能力”
|
||||
|
||||
负向影响:
|
||||
- 迁移期需要同时维护 Hermes default path 与 Jarvis fallback path
|
||||
- 需要重构 `AgentService` 和 continuity state
|
||||
- 需要补 durable lifecycle 和 rollout policy
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. Hermes 成为默认 execution core。
|
||||
2. Jarvis 仍保留 product shell 能力。
|
||||
3. 多轮对话 continuity 不依赖每轮冷启动。
|
||||
4. SSE 前端契约保持稳定。
|
||||
5. 默认切换有灰度与回滚路径。
|
||||
65
development-doc/plan/hermes-update/checklist.md
Normal file
65
development-doc/plan/hermes-update/checklist.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Hermes-first 执行清单
|
||||
|
||||
## H0 Ownership / ADR
|
||||
|
||||
- [x] 新增 Hermes-first `README.md`
|
||||
- [x] 新增 ADR:Hermes-first architecture
|
||||
- [x] 明确 ownership matrix
|
||||
- [x] 明确 Jarvis product shell 与 Hermes core 的边界
|
||||
- [x] 明确成功标准与关键风险
|
||||
|
||||
## H1 AgentService 架构倒置
|
||||
|
||||
- [ ] 把 `AgentService` 明确拆分为 assembly / dispatch / finalization
|
||||
- [ ] 正式 adapter 化 Jarvis graph 路径
|
||||
- [ ] 引入 runtime registry / factory
|
||||
- [ ] 减少 `if runtime == ...` 的散落逻辑
|
||||
- [ ] 保持 router / SSE 契约不破坏
|
||||
|
||||
## H2 Continuity Envelope
|
||||
|
||||
- [ ] 设计统一 `Conversation.agent_state` envelope
|
||||
- [ ] 加入 `active_runtime`
|
||||
- [ ] 加入 `runtime_state.hermes`
|
||||
- [ ] 保留 Jarvis product continuity
|
||||
- [ ] 增加 migration/version metadata
|
||||
- [ ] 补充兼容旧状态读取策略
|
||||
|
||||
## H3 Durable Session Lifecycle
|
||||
|
||||
- [ ] 升级 `HermesSessionManager` 为 durable lifecycle manager
|
||||
- [ ] 支持 warm / resumed / cold 状态
|
||||
- [ ] 支持 hydrate / recreate / idle reclaim
|
||||
- [ ] 增加 health / restart / stale session 处理
|
||||
- [ ] 补充 session recovery 测试
|
||||
|
||||
## H4 Product Shell Assembly
|
||||
|
||||
- [ ] 固化 memory assembly
|
||||
- [ ] 固化 skill shortlist assembly
|
||||
- [ ] 固化 task graph assembly
|
||||
- [ ] 把这些能力统一收敛到 prepared context
|
||||
- [ ] 保证 Hermes 不直接吞掉产品层职责
|
||||
|
||||
## H5 Event Mapper / SSE
|
||||
|
||||
- [ ] 新增 Hermes event mapper 边界
|
||||
- [ ] 保持 `metadata/progress/chunk/error/done`
|
||||
- [ ] richer diagnostics 落到 observability / attachments
|
||||
- [ ] 保证前端 parser 无需重写
|
||||
|
||||
## H6 Frontend Hermes-first Session Model
|
||||
|
||||
- [ ] 减少 `jarvis` 默认 runtime 假设
|
||||
- [ ] 减少 Jarvis-specific runtime 文案耦合
|
||||
- [ ] 提升 session/run metadata 为一等状态
|
||||
- [ ] runtime toggle 收缩为灰度/调试能力
|
||||
- [ ] 保持 chat 仍是消息流体验
|
||||
|
||||
## H7 Default Rollout / Fallback
|
||||
|
||||
- [ ] 引入默认 runtime policy
|
||||
- [ ] 支持 cohort / feature flag rollout
|
||||
- [ ] 保留 Jarvis graph fallback 路径
|
||||
- [ ] 定义 rollback 条件与动作
|
||||
- [ ] 用真实对话与指标验证默认切换时机
|
||||
112
development-doc/plan/hermes-update/phase-h-0-recon-and-poc.md
Normal file
112
development-doc/plan/hermes-update/phase-h-0-recon-and-poc.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# H-0 Hermes 现状探测与 PoC 边界
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在不影响现有 Jarvis 主流程的前提下,确认 Hermes 是否适合作为 Jarvis chat 的可嵌入 runtime。
|
||||
|
||||
本阶段只回答 4 个问题:
|
||||
|
||||
1. Hermes 更适合如何接入:Python API、单次 CLI、长驻 CLI、gateway,还是其他形式?
|
||||
2. Hermes 是否支持 conversation 级别的长驻 session / resume?
|
||||
3. Hermes 是否能在后端被程序化调用,而不是只能人工交互?
|
||||
4. Hermes 的接入是否能保持 Jarvis 现有 chat 页面协议稳定?
|
||||
|
||||
## 2. 当前已知信息
|
||||
|
||||
### 2.1 来自 Hermes 仓库的直接结论
|
||||
|
||||
- Hermes 主入口是 CLI:`hermes`
|
||||
- 提供 single query 模式:`-q` / `query`
|
||||
- 提供 `resume` 机制
|
||||
- 提供 gateway 模式
|
||||
- README 明确说明:原生 Windows 不受支持,建议 WSL2
|
||||
- `run_agent.py` 暴露了更直接的 Python 级 `chat(message, stream_callback=...)` 接口
|
||||
- 内部有 SQLite session store,说明其本身有 session persistence 概念
|
||||
|
||||
### 2.2 对 Jarvis 的意义
|
||||
|
||||
这说明 Hermes **不是只能人手操作的纯 TUI**,而是具备:
|
||||
|
||||
- 单次 query 入口
|
||||
- session 恢复能力
|
||||
- Python 层 chat 接口
|
||||
- streaming callback 可能性
|
||||
|
||||
因此它存在被 Jarvis 后端托管成 runtime 的现实基础。
|
||||
|
||||
## 3. 本阶段输出
|
||||
|
||||
### 3.1 必须验证的能力
|
||||
|
||||
1. **安装方式**
|
||||
- 是否能在当前环境隔离安装
|
||||
- 是否需要迁移到 WSL2 才具备稳定运行条件
|
||||
|
||||
2. **非交互调用能力**
|
||||
- 是否能用 CLI 单次 query 跑通
|
||||
- 是否能用 Python 直接调用 `run_agent.py` 的 chat 接口
|
||||
|
||||
3. **session 能力**
|
||||
- 是否能创建、恢复、复用 session
|
||||
- 是否适合绑定 `conversation_id`
|
||||
|
||||
4. **输出接法**
|
||||
- 是否能通过 callback / stdout 获取稳定文本流
|
||||
- 是否可被映射成 Jarvis 现有 SSE 事件
|
||||
|
||||
### 3.2 不在本阶段做的事
|
||||
|
||||
- 不改现有 Jarvis 默认运行链路
|
||||
- 不重写前端 chat 页面
|
||||
- 不直接删除或停用 LangGraph 主流程
|
||||
- 不引入一次性大迁移
|
||||
|
||||
## 4. 推荐 PoC 边界
|
||||
|
||||
### 4.1 推荐优先级
|
||||
|
||||
1. **优先验证 Python chat 接口**
|
||||
- 理由:比解析 TUI 更稳
|
||||
- 若可行,首版桥接应优先走这个路径
|
||||
|
||||
2. **其次验证 CLI 单次 query + resume**
|
||||
- 作为备选方案
|
||||
- 若 Python 接口不可控,可退而求其次
|
||||
|
||||
3. **最后才考虑 TUI/PTY 桥接**
|
||||
- 成本高
|
||||
- 不适合作为 Jarvis chat 的第一接法
|
||||
|
||||
### 4.2 PoC 成功标准
|
||||
|
||||
- 能在隔离环境中启动 Hermes
|
||||
- 能程序化发送一条消息并得到结果
|
||||
- 能确认 session 可复用或可 resume
|
||||
- 能形成一个后端 runtime adapter 可实现的最小桥接思路
|
||||
|
||||
## 5. 可能结论及后续影响
|
||||
|
||||
### 结论 A:Python chat 接口稳定
|
||||
- 最优方案
|
||||
- H-1/H-2 直接围绕 Python adapter + session manager 展开
|
||||
|
||||
### 结论 B:CLI `-q` + `resume` 稳定
|
||||
- 可接受
|
||||
- H-2 要更强调 session 句柄与进程生命周期管理
|
||||
|
||||
### 结论 C:只能稳定跑 TUI
|
||||
- 风险显著升高
|
||||
- 需重新评估是否值得继续集成
|
||||
|
||||
### 结论 D:当前环境无法稳定运行
|
||||
- 可能需要 WSL2 或远程服务化托管
|
||||
- 再决定是否继续推进
|
||||
|
||||
## 6. 验证清单
|
||||
|
||||
- [ ] 拉取 Hermes 仓库到隔离目录
|
||||
- [ ] 明确 install 依赖与 Python 版本要求
|
||||
- [ ] 确认单次 query 调用方式
|
||||
- [ ] 确认 Python chat 接口是否可用
|
||||
- [ ] 确认 session / resume 的可编程性
|
||||
- [ ] 记录接入建议结论,作为 H-1 输入
|
||||
@@ -0,0 +1,90 @@
|
||||
# H-1 Runtime Adapter 边界
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在不改变现有 Jarvis 默认行为的前提下,先把 chat 主流程改造成**可切换 runtime** 的结构。
|
||||
|
||||
核心思想:
|
||||
- router 不变
|
||||
- SSE 契约尽量不变
|
||||
- `AgentService` 内新增 runtime 分发边界
|
||||
- Jarvis 先被包装成默认 runtime
|
||||
- Hermes 作为显式实验 runtime 并存
|
||||
|
||||
## 2. 当前主链路
|
||||
|
||||
当前 chat 路径:
|
||||
|
||||
```text
|
||||
frontend/useChatView.ts
|
||||
-> frontend/api/conversation.ts
|
||||
-> POST /api/conversations/chat/stream
|
||||
-> backend/app/routers/conversation.py
|
||||
-> backend/app/services/agent_service.py
|
||||
-> backend/app/agents/graph.py
|
||||
```
|
||||
|
||||
问题在于:
|
||||
- `AgentService` 直接耦合 Jarvis 图运行时
|
||||
- 没有 runtime selector
|
||||
- Hermes 无法以低风险方式并入
|
||||
|
||||
## 3. 本阶段目标结构
|
||||
|
||||
```text
|
||||
conversation router
|
||||
-> AgentService
|
||||
-> resolve runtime
|
||||
-> JarvisRuntimeAdapter | HermesRuntimeAdapter
|
||||
```
|
||||
|
||||
### 3.1 关键要求
|
||||
|
||||
1. Jarvis 仍为默认 runtime
|
||||
2. 不改现有 URL 和 SSE event name
|
||||
3. 前端只需要传一个可选 `runtime` 字段
|
||||
4. backend 可以继续把 Hermes 视为“可插拔执行器”
|
||||
|
||||
## 4. 数据契约
|
||||
|
||||
建议在 chat request 中增加:
|
||||
|
||||
- `runtime: "jarvis" | "hermes" | null`
|
||||
|
||||
规则:
|
||||
- `null` / 未传:默认 `jarvis`
|
||||
- `jarvis`:保持现有行为
|
||||
- `hermes`:转入 Hermes adapter
|
||||
|
||||
## 5. 推荐文件调整
|
||||
|
||||
### Backend
|
||||
- `backend/app/schemas/conversation.py`
|
||||
- 增加 runtime 字段
|
||||
- `backend/app/services/agent_service.py`
|
||||
- 增加 runtime 解析
|
||||
- 增加 runtime dispatch
|
||||
- 新目录:`backend/app/services/agent_runtime/`
|
||||
- `base.py`
|
||||
- `jarvis_runtime.py`
|
||||
- `hermes_runtime.py`
|
||||
|
||||
### Frontend
|
||||
- `frontend/src/api/conversation.ts`
|
||||
- 请求体增加 runtime
|
||||
- `frontend/src/pages/chat/composables/useChatView.ts`
|
||||
- 增加 selectedRuntime 状态
|
||||
|
||||
## 6. 约束
|
||||
|
||||
- 本阶段不要求 Hermes 已经完整可运行
|
||||
- 允许先落 Hermes adapter 骨架
|
||||
- 但不允许破坏 Jarvis 现有路径
|
||||
|
||||
## 7. 完成标准
|
||||
|
||||
- [ ] `runtime` 字段进入 request schema
|
||||
- [ ] backend 已有 runtime dispatch 入口
|
||||
- [ ] Jarvis 仍能正常完成原有 chat / chat_stream
|
||||
- [ ] Hermes 可以作为占位 runtime 被请求到
|
||||
- [ ] SSE 事件协议未被破坏
|
||||
@@ -0,0 +1,107 @@
|
||||
# H-2 长驻 Hermes Session Manager
|
||||
|
||||
## 1. 目标
|
||||
|
||||
让 Hermes 以 conversation 级别的长驻 session 运行,而不是每条消息都重新冷启动。
|
||||
|
||||
这是本次接入最关键的用户体验目标:
|
||||
- 连续上下文
|
||||
- 无缝多轮对话
|
||||
- 降低重复初始化耗时
|
||||
- 避免“每次都像重新开机”
|
||||
|
||||
## 2. 会话归属原则
|
||||
|
||||
Hermes session 以 `conversation_id` 作为主键绑定。
|
||||
|
||||
原因:
|
||||
1. Jarvis 现有 chat 的持久化中心本来就是 conversation
|
||||
2. 前后端现有逻辑都已围绕 conversation 组织
|
||||
3. conversation 是最自然的“连续对话上下文容器”
|
||||
|
||||
必要时可组合:
|
||||
- `user_id + conversation_id`
|
||||
|
||||
## 3. 会话管理职责
|
||||
|
||||
建议新增 `HermesSessionManager`,负责:
|
||||
|
||||
1. 根据 conversation 获取或创建 Hermes session
|
||||
2. 保存内存态句柄
|
||||
3. 记录 last_used 时间
|
||||
4. 做每会话锁,防止并发 turn 污染
|
||||
5. 做 idle timeout 回收
|
||||
6. 在异常时受控重建 session
|
||||
|
||||
## 4. 与持久化层的关系
|
||||
|
||||
### 4.1 内存态
|
||||
内存里保存:
|
||||
- session handle
|
||||
- lock
|
||||
- last_used
|
||||
- health status
|
||||
- restart count
|
||||
|
||||
### 4.2 数据库存储
|
||||
建议把 Hermes runtime 元数据落入 `Conversation.agent_state`,但不要覆盖现有 Jarvis continuity。
|
||||
|
||||
建议结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"runtime": "jarvis | hermes",
|
||||
"runtime_state": {
|
||||
"jarvis": { ... },
|
||||
"hermes": {
|
||||
"session_id": "...",
|
||||
"last_used_at": "...",
|
||||
"restart_count": 0,
|
||||
"status": "healthy"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这样支持:
|
||||
- 并存
|
||||
- 切换
|
||||
- 回滚
|
||||
- 不破坏旧 continuity 数据
|
||||
|
||||
## 5. 生命周期建议
|
||||
|
||||
```text
|
||||
用户发起消息
|
||||
-> 根据 conversation 找 session
|
||||
-> 有则复用
|
||||
-> 无则创建
|
||||
-> 执行消息
|
||||
-> 更新 last_used / 状态
|
||||
-> 空闲超时后回收
|
||||
```
|
||||
|
||||
### 5.1 回收策略
|
||||
- conversation 长时间无活动后可回收
|
||||
- 但回收前要把必要 runtime 元数据保存到 `agent_state`
|
||||
|
||||
### 5.2 异常策略
|
||||
- 首次异常:尝试一次受控重建
|
||||
- 重建失败:返回 clean error
|
||||
- 不能因此破坏 Jarvis 默认路径
|
||||
|
||||
## 6. 关键设计约束
|
||||
|
||||
1. 一个 conversation 同一时刻只能有一个进行中的 Hermes turn
|
||||
2. 不允许两个并发消息写进同一个 Hermes session
|
||||
3. session manager 不能成为 Jarvis 主流程的单点故障
|
||||
4. Hermes 失败时,不能污染 conversation 的历史结构
|
||||
|
||||
## 7. 完成标准
|
||||
|
||||
- [ ] `conversation_id` 能稳定映射到 Hermes session
|
||||
- [ ] session 可复用,不是每轮冷启动
|
||||
- [ ] 有 per-conversation lock
|
||||
- [ ] 有 idle timeout / cleanup 机制
|
||||
- [ ] 有 crash / recreate 基础机制
|
||||
- [ ] metadata 可写入 `Conversation.agent_state`
|
||||
@@ -0,0 +1,102 @@
|
||||
# H-3 Hermes Adapter 与上下文复用
|
||||
|
||||
## 1. 目标
|
||||
|
||||
Hermes 只作为新的执行 runtime 接进来,不重新发明一套 Jarvis memory / context / chat protocol。
|
||||
|
||||
也就是说:
|
||||
- Jarvis 已有的上下文构建能力继续复用
|
||||
- Hermes 输出被适配为现有 chat 消息流
|
||||
- 前端尽量不理解 Hermes 内部细节
|
||||
|
||||
## 2. 可复用能力
|
||||
|
||||
### 2.1 Memory
|
||||
- `backend/app/services/memory_service.py`
|
||||
|
||||
继续复用:
|
||||
- conversation summary
|
||||
- recalled memory
|
||||
- user memory
|
||||
- knowledge brain 注入
|
||||
|
||||
### 2.2 Skill shortlist
|
||||
- `backend/app/agents/skills/retriever.py`
|
||||
|
||||
继续复用:
|
||||
- request 相关 skill shortlist
|
||||
|
||||
### 2.3 Task graph
|
||||
- `backend/app/agents/orchestration/task_graph.py`
|
||||
|
||||
继续复用:
|
||||
- bounded task graph
|
||||
- parallel worthiness 等前置分析
|
||||
|
||||
## 3. 推荐数据流
|
||||
|
||||
```text
|
||||
AgentService
|
||||
-> 读取 conversation / message / files
|
||||
-> 构建 memory context
|
||||
-> 构建 skill shortlist
|
||||
-> 构建 task graph / runtime request context
|
||||
-> 根据 runtime 分发
|
||||
-> JarvisRuntimeAdapter
|
||||
-> HermesRuntimeAdapter
|
||||
```
|
||||
|
||||
这样 Hermes 看到的是**已整理好的 runtime context**,而不是被迫直接复用 Jarvis 图内部状态机。
|
||||
|
||||
## 4. SSE 契约保持不变
|
||||
|
||||
继续沿用现有事件:
|
||||
- `metadata`
|
||||
- `progress`
|
||||
- `chunk`
|
||||
- `error`
|
||||
- `done`
|
||||
|
||||
### 4.1 原因
|
||||
|
||||
前端现有:
|
||||
- `conversationApi.chatStream()` 已解析这套事件
|
||||
- `useChatView.ts` 已依赖这套事件更新 thinking state / orchestration panel
|
||||
|
||||
如果这里大改,会让前端接入成本飙升。
|
||||
|
||||
### 4.2 Hermes event mapping
|
||||
|
||||
Hermes 内部即使没有完全等价事件,也应该适配成:
|
||||
- 初始化 / session 准备 -> `progress`
|
||||
- 实际文本输出 -> `chunk`
|
||||
- 错误 -> `error`
|
||||
- 完成 -> `done`
|
||||
|
||||
缺字段可以降级,但 event 名称不要改。
|
||||
|
||||
## 5. 持久化与可观测性
|
||||
|
||||
继续沿用:
|
||||
- `Message` 表保存 user / assistant 内容
|
||||
- `Conversation.agent_state` 保存 runtime continuity 元数据
|
||||
- `attachments` 可用于记录 Hermes 运行附加信息
|
||||
|
||||
建议:
|
||||
- 把 Hermes 观测信息放在 runtime-tagged attachment 中
|
||||
- 不把探测日志直接渲染进用户可见消息正文
|
||||
|
||||
## 6. 边界约束
|
||||
|
||||
1. Hermes continuity 与 Jarvis continuity 分开存
|
||||
2. 不要让 Hermes adapter 直接改写现有 Jarvis graph 状态格式
|
||||
3. 前端不直接显示“终端字节流”
|
||||
4. Hermes 适配失败时,必须 clean fail
|
||||
|
||||
## 7. 完成标准
|
||||
|
||||
- [ ] 现有 memory pipeline 可被 Hermes 复用
|
||||
- [ ] 现有 skill shortlist / task graph 可被 Hermes 复用
|
||||
- [ ] Hermes 输出成功映射到既有 SSE 契约
|
||||
- [ ] assistant message 按现有结构持久化
|
||||
- [ ] Hermes continuity 数据不覆盖 Jarvis continuity 数据
|
||||
@@ -0,0 +1,85 @@
|
||||
# H-4 前端切换与并行评估
|
||||
|
||||
## 1. 目标
|
||||
|
||||
让 chat 页面在尽量不改变现有体验的前提下,支持切换 `jarvis | hermes`,并进入受控评估期。
|
||||
|
||||
重点不是做新 UI,而是:
|
||||
- 能切换 runtime
|
||||
- 能继续对话
|
||||
- 能收集真实效果
|
||||
- 不影响现有默认使用路径
|
||||
|
||||
## 2. 前端最小改动原则
|
||||
|
||||
### 2.1 继续复用现有页面
|
||||
主要锚点:
|
||||
- `frontend/src/pages/chat/composables/useChatView.ts`
|
||||
- `frontend/src/api/conversation.ts`
|
||||
- `frontend/src/pages/chat/index.vue`
|
||||
|
||||
### 2.2 最小改动内容
|
||||
|
||||
1. 增加 `selectedRuntime`
|
||||
2. 在发送消息时把 runtime 放入 request body
|
||||
3. 页面可加一个轻量 toggle / selector
|
||||
4. 不改变现有消息渲染逻辑
|
||||
5. 不把页面改造成“网页终端”
|
||||
|
||||
## 3. 评估期策略
|
||||
|
||||
### 3.1 默认值
|
||||
- Jarvis 仍为默认 runtime
|
||||
- Hermes 为显式选择项
|
||||
|
||||
### 3.2 评估维度
|
||||
|
||||
必须记录:
|
||||
- 首 token 延迟
|
||||
- 完整回复耗时
|
||||
- 第二轮/第三轮连续对话体验
|
||||
- session 是否稳定复用
|
||||
- 工具调用效果
|
||||
- memory 是否有效承接
|
||||
- 异常率 / 重启率
|
||||
- 开发维护复杂度
|
||||
|
||||
### 3.3 用户体验标准
|
||||
|
||||
如果 Hermes 要成为默认 runtime,至少应满足:
|
||||
1. 不比 Jarvis 更割裂
|
||||
2. 不出现频繁 session 丢失
|
||||
3. 前端不需要额外理解复杂运行细节
|
||||
4. 整体体验更像连续助手而不是一次性问答器
|
||||
|
||||
## 4. 验收建议
|
||||
|
||||
### Frontend
|
||||
- [ ] Jarvis 默认聊天体验不变
|
||||
- [ ] 可切换到 Hermes 并成功发消息
|
||||
- [ ] 历史会话读取不崩
|
||||
- [ ] orchestration panel 不因 Hermes 字段较少而崩溃
|
||||
|
||||
### Backend
|
||||
- [ ] Hermes 路径不影响 Jarvis 默认路径
|
||||
- [ ] SSE 解析不需要重写
|
||||
- [ ] conversation/message 结构保持兼容
|
||||
|
||||
### Product
|
||||
- [ ] 可以真实比较两个 runtime
|
||||
- [ ] 结论可支持“继续替换”或“放弃替换”
|
||||
|
||||
## 5. 阶段结论输出
|
||||
|
||||
本阶段结束后,应明确给出以下结论之一:
|
||||
|
||||
### 结论 A:Hermes 明显更优
|
||||
- 新开一轮“默认切换 / 逐步替换”规划
|
||||
|
||||
### 结论 B:Hermes 可保留为实验 runtime
|
||||
- 不切默认
|
||||
- 继续特定场景使用
|
||||
|
||||
### 结论 C:Hermes 不适合当前 Jarvis
|
||||
- 中止替换计划
|
||||
- 保留本轮探索结论供后续参考
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user