Compare commits
40 Commits
fca7a7cf3d
...
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 | |||
| 472528e708 | |||
| e24092f3ab | |||
| f0658201e5 | |||
| f033fb5879 | |||
| 5667190abe | |||
| 11160ec4d2 | |||
| 9bfa0dcc11 | |||
| bfe3b6bb9d | |||
| 10d9340c53 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -40,6 +40,9 @@ logs/
|
|||||||
.claude/
|
.claude/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
|
# Demo (excluded from version control)
|
||||||
|
demo/
|
||||||
|
|
||||||
# Lock files (use in development, commit in production)
|
# Lock files (use in development, commit in production)
|
||||||
# uv.lock - uncomment if you want to commit lock file
|
# uv.lock - uncomment if you want to commit lock file
|
||||||
# package-lock.json - uncomment if you want to commit lock file
|
# package-lock.json - uncomment if you want to commit lock file
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
19
backend/app/agents/learning/__init__.py
Normal file
19
backend/app/agents/learning/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from app.agents.learning.jobs import persist_retrospective, schedule_retrospective_job
|
||||||
|
from app.agents.learning.pattern_miner import LearningPatternMiner
|
||||||
|
from app.agents.learning.retrospector import build_session_retrospective
|
||||||
|
from app.agents.learning.session_search import SessionRetrospectiveSearch
|
||||||
|
from app.agents.learning.signal_extractor import RetrospectiveSignalExtractor
|
||||||
|
from app.agents.learning.skill_candidate_builder import SkillCandidateBuilder
|
||||||
|
from app.agents.learning.store import LearningArtifactStore, SessionRetrospectiveStore
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"build_session_retrospective",
|
||||||
|
"LearningArtifactStore",
|
||||||
|
"LearningPatternMiner",
|
||||||
|
"persist_retrospective",
|
||||||
|
"RetrospectiveSignalExtractor",
|
||||||
|
"schedule_retrospective_job",
|
||||||
|
"SessionRetrospectiveSearch",
|
||||||
|
"SessionRetrospectiveStore",
|
||||||
|
"SkillCandidateBuilder",
|
||||||
|
]
|
||||||
16
backend/app/agents/learning/audit.py
Normal file
16
backend/app/agents/learning/audit.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.schemas.learning import LearningDecision, SessionRetrospective
|
||||||
|
|
||||||
|
|
||||||
|
def build_learning_audit_entry(retrospective: SessionRetrospective) -> dict[str, object]:
|
||||||
|
decision = retrospective.learning_decision
|
||||||
|
return {
|
||||||
|
"retrospective_id": retrospective.retrospective_id,
|
||||||
|
"decision": decision.decision if isinstance(decision, LearningDecision) else None,
|
||||||
|
"explanation": decision.explanation if isinstance(decision, LearningDecision) else None,
|
||||||
|
"signal_count": len(retrospective.learning_signals),
|
||||||
|
"pattern_count": len(retrospective.pattern_candidates),
|
||||||
|
"skill_candidate_count": len(retrospective.skill_candidates),
|
||||||
|
"outcome": retrospective.outcome,
|
||||||
|
}
|
||||||
45
backend/app/agents/learning/bridge.py
Normal file
45
backend/app/agents/learning/bridge.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.schemas.learning import LearningDecision, LearningSignal
|
||||||
|
|
||||||
|
|
||||||
|
def route_learning_signal(signal: LearningSignal) -> str:
|
||||||
|
if signal.signal_type == "preference":
|
||||||
|
return "memory"
|
||||||
|
if signal.signal_type in {"workflow", "decomposition", "tool_success"}:
|
||||||
|
return "skill"
|
||||||
|
if signal.signal_type == "correction":
|
||||||
|
return "audit"
|
||||||
|
return "memory"
|
||||||
|
|
||||||
|
|
||||||
|
def build_learning_bridge_summary(signals: list[LearningSignal]) -> dict[str, object]:
|
||||||
|
memory_count = 0
|
||||||
|
skill_count = 0
|
||||||
|
audit_count = 0
|
||||||
|
|
||||||
|
for signal in signals:
|
||||||
|
route = route_learning_signal(signal)
|
||||||
|
if route == "memory":
|
||||||
|
memory_count += 1
|
||||||
|
elif route == "skill":
|
||||||
|
skill_count += 1
|
||||||
|
else:
|
||||||
|
audit_count += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"memory_signal_count": memory_count,
|
||||||
|
"skill_signal_count": skill_count,
|
||||||
|
"audit_signal_count": audit_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def update_learning_decision_with_bridge(
|
||||||
|
decision: LearningDecision,
|
||||||
|
signals: list[LearningSignal],
|
||||||
|
) -> LearningDecision:
|
||||||
|
bridge_summary = build_learning_bridge_summary(signals)
|
||||||
|
metadata = dict(decision.metadata or {})
|
||||||
|
metadata["bridge"] = bridge_summary
|
||||||
|
decision.metadata = metadata
|
||||||
|
return decision
|
||||||
222
backend/app/agents/learning/jobs.py
Normal file
222
backend/app/agents/learning/jobs.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import async_session
|
||||||
|
from app.agents.learning.bridge import update_learning_decision_with_bridge
|
||||||
|
from app.agents.learning.pattern_miner import LearningPatternMiner
|
||||||
|
from app.agents.learning.audit import build_learning_audit_entry
|
||||||
|
from app.agents.learning.retrospector import build_session_retrospective
|
||||||
|
from app.agents.learning.signal_extractor import RetrospectiveSignalExtractor
|
||||||
|
from app.agents.learning.skill_candidate_builder import SkillCandidateBuilder
|
||||||
|
from app.agents.learning.store import LearningArtifactStore, SessionRetrospectiveStore
|
||||||
|
from app.agents.schemas.learning import LearningDecision, SessionRetrospective
|
||||||
|
from app.agents.skills.evaluator import SkillPromotionEvaluator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_retrospective(retrospective: SessionRetrospective) -> SessionRetrospective:
|
||||||
|
signals = RetrospectiveSignalExtractor().extract(retrospective)
|
||||||
|
patterns = LearningPatternMiner().mine(signals)
|
||||||
|
skill_candidates = SkillCandidateBuilder().build(patterns)
|
||||||
|
|
||||||
|
decision = LearningDecision(
|
||||||
|
decision="create_candidate" if skill_candidates else ("reinforce_memory" if signals else "defer"),
|
||||||
|
explanation=(
|
||||||
|
"Retrospective produced reusable candidate skills."
|
||||||
|
if skill_candidates
|
||||||
|
else "Retrospective only reinforces memory-like evidence."
|
||||||
|
if signals
|
||||||
|
else "No stable signal was extracted from this retrospective."
|
||||||
|
),
|
||||||
|
evidence_refs=(skill_candidates[0].evidence_refs if skill_candidates else retrospective.evidence_refs[:3]),
|
||||||
|
metadata={
|
||||||
|
"signal_count": len(signals),
|
||||||
|
"pattern_count": len(patterns),
|
||||||
|
"skill_candidate_count": len(skill_candidates),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
retrospective.learning_signals = signals
|
||||||
|
retrospective.pattern_candidates = patterns
|
||||||
|
retrospective.skill_candidates = skill_candidates
|
||||||
|
retrospective.learning_decision = update_learning_decision_with_bridge(decision, signals)
|
||||||
|
return retrospective
|
||||||
|
|
||||||
|
|
||||||
|
def _build_learning_artifacts(retrospective: SessionRetrospective) -> list[dict[str, object]]:
|
||||||
|
artifacts: list[dict[str, object]] = []
|
||||||
|
for signal in retrospective.learning_signals:
|
||||||
|
artifacts.append(
|
||||||
|
{
|
||||||
|
"artifact_type": "signal",
|
||||||
|
"artifact_key": signal.signal_type,
|
||||||
|
"summary_text": signal.explanation or signal.signal_type,
|
||||||
|
"payload": signal.model_dump(mode="json"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for pattern in retrospective.pattern_candidates:
|
||||||
|
artifacts.append(
|
||||||
|
{
|
||||||
|
"artifact_type": "pattern_candidate",
|
||||||
|
"artifact_key": pattern.pattern_type,
|
||||||
|
"summary_text": pattern.description,
|
||||||
|
"payload": pattern.model_dump(mode="json"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for candidate in retrospective.skill_candidates:
|
||||||
|
artifacts.append(
|
||||||
|
{
|
||||||
|
"artifact_type": "skill_candidate",
|
||||||
|
"artifact_key": candidate.name,
|
||||||
|
"summary_text": candidate.summary,
|
||||||
|
"payload": candidate.model_dump(mode="json"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if retrospective.learning_decision is not None:
|
||||||
|
artifacts.append(
|
||||||
|
{
|
||||||
|
"artifact_type": "learning_decision",
|
||||||
|
"artifact_key": retrospective.learning_decision.decision,
|
||||||
|
"summary_text": retrospective.learning_decision.explanation,
|
||||||
|
"payload": retrospective.learning_decision.model_dump(mode="json"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
artifacts.append(
|
||||||
|
{
|
||||||
|
"artifact_type": "learning_audit",
|
||||||
|
"artifact_key": retrospective.retrospective_id or "retrospective",
|
||||||
|
"summary_text": retrospective.learning_decision.explanation,
|
||||||
|
"payload": build_learning_audit_entry(retrospective),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return artifacts
|
||||||
|
|
||||||
|
|
||||||
|
def _build_lifecycle_artifacts(decisions: list) -> list[dict[str, object]]:
|
||||||
|
artifacts: list[dict[str, object]] = []
|
||||||
|
for decision in decisions:
|
||||||
|
artifacts.append(
|
||||||
|
{
|
||||||
|
"artifact_type": "skill_lifecycle_decision",
|
||||||
|
"artifact_key": getattr(decision, "skill_name", None) or "skill",
|
||||||
|
"summary_text": getattr(decision, "reason", ""),
|
||||||
|
"payload": decision.model_dump(mode="json"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return artifacts
|
||||||
|
|
||||||
|
|
||||||
|
async def persist_retrospective(
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
conversation_id: str,
|
||||||
|
request_message_id: str | None,
|
||||||
|
response_message_id: str | None,
|
||||||
|
query_text: str,
|
||||||
|
final_response: str | None,
|
||||||
|
state: dict[str, Any] | None,
|
||||||
|
) -> None:
|
||||||
|
retrospective = build_session_retrospective(
|
||||||
|
request_id=response_message_id or request_message_id or conversation_id,
|
||||||
|
session_id=conversation_id,
|
||||||
|
user_query=query_text,
|
||||||
|
state=state,
|
||||||
|
runtime_context={"user_id": user_id},
|
||||||
|
)
|
||||||
|
retrospective = _enrich_retrospective(retrospective)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
saved = await SessionRetrospectiveStore(session).save(retrospective)
|
||||||
|
lifecycle_decisions = []
|
||||||
|
if settings.ENABLE_SKILL_PROMOTION:
|
||||||
|
lifecycle_decisions = await SkillPromotionEvaluator(session).sync_retrospective(
|
||||||
|
user_id=user_id,
|
||||||
|
retrospective=retrospective,
|
||||||
|
)
|
||||||
|
if settings.ENABLE_LEARNING_SIGNALS:
|
||||||
|
await LearningArtifactStore(session).save_batch(
|
||||||
|
user_id=user_id,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
retrospective_id=saved.id,
|
||||||
|
artifacts=[
|
||||||
|
*_build_learning_artifacts(retrospective),
|
||||||
|
*_build_lifecycle_artifacts(lifecycle_decisions),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_retrospective_job(**kwargs) -> asyncio.Task[None] | None:
|
||||||
|
if not settings.ENABLE_RETROSPECTIVE:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
task = asyncio.create_task(persist_retrospective(**kwargs))
|
||||||
|
except RuntimeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _handle_completion(done_task: asyncio.Task[None]) -> None:
|
||||||
|
try:
|
||||||
|
done_task.result()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("retrospective_job_failed")
|
||||||
|
|
||||||
|
task.add_done_callback(_handle_completion)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_retrospective_learning_event(
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
conversation_id: str,
|
||||||
|
retrospective: SessionRetrospective,
|
||||||
|
session_factory=async_session,
|
||||||
|
) -> asyncio.Task[None] | None:
|
||||||
|
if not settings.ENABLE_RETROSPECTIVE:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _persist_existing() -> None:
|
||||||
|
async with session_factory() as session:
|
||||||
|
enriched = _enrich_retrospective(retrospective)
|
||||||
|
saved = await SessionRetrospectiveStore(session).save(enriched)
|
||||||
|
lifecycle_decisions = []
|
||||||
|
if settings.ENABLE_SKILL_PROMOTION:
|
||||||
|
lifecycle_decisions = await SkillPromotionEvaluator(session).sync_retrospective(
|
||||||
|
user_id=user_id,
|
||||||
|
retrospective=enriched,
|
||||||
|
)
|
||||||
|
if settings.ENABLE_LEARNING_SIGNALS:
|
||||||
|
await LearningArtifactStore(session).save_batch(
|
||||||
|
user_id=user_id,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
retrospective_id=saved.id,
|
||||||
|
artifacts=[
|
||||||
|
*_build_learning_artifacts(enriched),
|
||||||
|
*_build_lifecycle_artifacts(lifecycle_decisions),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
task = asyncio.create_task(_persist_existing())
|
||||||
|
except RuntimeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _handle_completion(done_task: asyncio.Task[None]) -> None:
|
||||||
|
try:
|
||||||
|
done_task.result()
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"retrospective_learning_event_failed",
|
||||||
|
extra={
|
||||||
|
"details": {
|
||||||
|
"user_id": user_id,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
task.add_done_callback(_handle_completion)
|
||||||
|
return task
|
||||||
42
backend/app/agents/learning/pattern_miner.py
Normal file
42
backend/app/agents/learning/pattern_miner.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.agents.schemas.learning import LearningSignal, PatternCandidate
|
||||||
|
|
||||||
|
|
||||||
|
class LearningPatternMiner:
|
||||||
|
def mine(self, signals: list[LearningSignal]) -> list[PatternCandidate]:
|
||||||
|
patterns: list[PatternCandidate] = []
|
||||||
|
|
||||||
|
for signal in signals:
|
||||||
|
if signal.signal_type not in {"workflow", "decomposition", "preference"}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
description = self._build_description(signal)
|
||||||
|
patterns.append(
|
||||||
|
PatternCandidate(
|
||||||
|
pattern_id=f"pattern-{uuid4().hex[:10]}",
|
||||||
|
pattern_type=signal.signal_type,
|
||||||
|
description=description,
|
||||||
|
confidence=signal.confidence,
|
||||||
|
evidence_refs=signal.evidence_refs[:4],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return patterns
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_description(signal: LearningSignal) -> str:
|
||||||
|
payload = signal.payload or {}
|
||||||
|
if signal.signal_type == "workflow":
|
||||||
|
task_type = payload.get("task_type") or "general"
|
||||||
|
execution_mode = payload.get("execution_mode") or "direct"
|
||||||
|
return f"Completed {task_type} requests worked under {execution_mode} execution."
|
||||||
|
if signal.signal_type == "decomposition":
|
||||||
|
task_count = payload.get("task_count") or 0
|
||||||
|
return f"Requests with {task_count} concrete task refs benefit from structured decomposition."
|
||||||
|
if signal.signal_type == "preference":
|
||||||
|
preference = payload.get("preference") or "structured response"
|
||||||
|
return f"User preference repeatedly points to {preference}."
|
||||||
|
return signal.explanation or signal.signal_type
|
||||||
115
backend/app/agents/learning/retrospector.py
Normal file
115
backend/app/agents/learning/retrospector.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.schemas.learning import SessionRetrospective
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_task_type(query_text: str) -> str:
|
||||||
|
normalized = (query_text or "").lower()
|
||||||
|
if any(token in normalized for token in ("总结", "分析", "对比", "report", "analyze")):
|
||||||
|
return "analysis"
|
||||||
|
if any(token in normalized for token in ("安排", "提醒", "日程", "todo", "task")):
|
||||||
|
return "planning_or_execution"
|
||||||
|
if any(token in normalized for token in ("文档", "资料", "年报", "search", "查")):
|
||||||
|
return "retrieval"
|
||||||
|
return "general"
|
||||||
|
|
||||||
|
|
||||||
|
def build_session_retrospective(
|
||||||
|
*,
|
||||||
|
request_id: str,
|
||||||
|
session_id: str,
|
||||||
|
user_query: str,
|
||||||
|
state: dict[str, Any] | None,
|
||||||
|
runtime_context: dict[str, Any] | None = None,
|
||||||
|
) -> SessionRetrospective:
|
||||||
|
state = state or {}
|
||||||
|
if hasattr(runtime_context, "model_dump"):
|
||||||
|
runtime_context = runtime_context.model_dump(mode="json")
|
||||||
|
runtime_context = runtime_context or {}
|
||||||
|
skill_shortlist = state.get("skill_shortlist") or []
|
||||||
|
used_skill_names = [
|
||||||
|
item.get("skill_name")
|
||||||
|
for item in skill_shortlist
|
||||||
|
if isinstance(item, dict) and item.get("skill_name")
|
||||||
|
]
|
||||||
|
|
||||||
|
task_refs = []
|
||||||
|
for task in (state.get("completed_tasks") or [])[:4]:
|
||||||
|
if isinstance(task, dict):
|
||||||
|
task_refs.append(
|
||||||
|
{
|
||||||
|
"task_id": task.get("task_id"),
|
||||||
|
"title": task.get("title"),
|
||||||
|
"status": task.get("status"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
event_refs = []
|
||||||
|
for event in (state.get("event_trace") or [])[:8]:
|
||||||
|
if isinstance(event, dict):
|
||||||
|
event_refs.append(
|
||||||
|
{
|
||||||
|
"event_type": event.get("event_type"),
|
||||||
|
"task_id": event.get("task_id"),
|
||||||
|
"agent_id": event.get("agent_id"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
verification_evidence = []
|
||||||
|
for evidence in (state.get("verification_evidence") or [])[:6]:
|
||||||
|
if isinstance(evidence, dict):
|
||||||
|
verification_evidence.append(evidence)
|
||||||
|
|
||||||
|
verification_status = state.get("verification_status")
|
||||||
|
execution_mode = state.get("execution_mode")
|
||||||
|
primary_agent = state.get("current_agent") or "master"
|
||||||
|
retrospective_shortlist = state.get("retrospective_shortlist") or []
|
||||||
|
|
||||||
|
summary_parts = [
|
||||||
|
f"本轮请求按 {execution_mode or 'unknown'} 模式处理",
|
||||||
|
f"主要负责 agent 为 {primary_agent}",
|
||||||
|
]
|
||||||
|
if verification_status:
|
||||||
|
summary_parts.append(f"验证结果为 {verification_status}")
|
||||||
|
if used_skill_names:
|
||||||
|
summary_parts.append(f"命中技能候选 {', '.join(used_skill_names[:3])}")
|
||||||
|
if retrospective_shortlist:
|
||||||
|
summary_parts.append(f"参考了 {len(retrospective_shortlist)} 条历史复盘")
|
||||||
|
|
||||||
|
final_response = state.get("final_response")
|
||||||
|
outcome = "completed" if final_response else "failed"
|
||||||
|
if not final_response and verification_status == "passed":
|
||||||
|
outcome = "completed"
|
||||||
|
if final_response and verification_status == "skipped":
|
||||||
|
outcome = "partial"
|
||||||
|
|
||||||
|
return SessionRetrospective(
|
||||||
|
retrospective_id=request_id,
|
||||||
|
user_id=str(runtime_context.get("user_id") or ""),
|
||||||
|
conversation_id=session_id,
|
||||||
|
response_message_id=request_id,
|
||||||
|
query_text=user_query,
|
||||||
|
final_response=final_response,
|
||||||
|
summary=";".join(summary_parts) + "。",
|
||||||
|
task_type=_classify_task_type(user_query),
|
||||||
|
execution_mode=execution_mode,
|
||||||
|
primary_agent=primary_agent,
|
||||||
|
verification_status=verification_status,
|
||||||
|
verification_summary=state.get("verification_summary"),
|
||||||
|
used_skill_names=used_skill_names,
|
||||||
|
evidence_refs=verification_evidence,
|
||||||
|
task_refs=task_refs,
|
||||||
|
event_refs=event_refs,
|
||||||
|
context_snapshot={
|
||||||
|
"runtime_request_context": runtime_context,
|
||||||
|
"recommended_runtime_mode": runtime_context.get("recommended_runtime_mode"),
|
||||||
|
"parallel_worthiness": state.get("parallel_worthiness"),
|
||||||
|
"retrospective_shortlist_count": len(retrospective_shortlist),
|
||||||
|
"scheduled_subtask_count": len(state.get("scheduled_subtasks") or []),
|
||||||
|
"merge_report": dict(state.get("merge_report") or {}),
|
||||||
|
"verification_report": dict(state.get("verification_report") or {}),
|
||||||
|
},
|
||||||
|
outcome=outcome,
|
||||||
|
)
|
||||||
95
backend/app/agents/learning/session_search.py
Normal file
95
backend/app/agents/learning/session_search.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.schemas.learning import SessionRetrospective
|
||||||
|
from app.agents.skills.matcher import score_text_match
|
||||||
|
from app.agents.learning.store import SessionRetrospectiveStore
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRetrospectiveSearch:
|
||||||
|
def __init__(self, db):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def shortlist(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
query_text: str,
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
task_type: str | None = None,
|
||||||
|
skill_name: str | None = None,
|
||||||
|
limit: int = 3,
|
||||||
|
) -> list[SessionRetrospective]:
|
||||||
|
records = await SessionRetrospectiveStore(self.db).list_recent(user_id=user_id, limit=25)
|
||||||
|
scored: list[tuple[float, SessionRetrospective]] = []
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
if task_type and record.task_type != task_type:
|
||||||
|
continue
|
||||||
|
if skill_name and skill_name not in (record.skill_names or []):
|
||||||
|
continue
|
||||||
|
score, _matched_terms = score_text_match(
|
||||||
|
query_text,
|
||||||
|
record.query_text,
|
||||||
|
record.summary_text,
|
||||||
|
" ".join(record.skill_names or []),
|
||||||
|
)
|
||||||
|
if conversation_id and record.conversation_id == conversation_id:
|
||||||
|
score = min(1.0, score + 0.1)
|
||||||
|
if score <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload = dict(record.payload or {})
|
||||||
|
payload["retrospective_id"] = record.id
|
||||||
|
retrospective = SessionRetrospective.model_validate(payload)
|
||||||
|
scored.append((score, retrospective))
|
||||||
|
|
||||||
|
scored.sort(key=lambda item: item[0], reverse=True)
|
||||||
|
return [item for _score, item in scored[:limit]]
|
||||||
|
|
||||||
|
|
||||||
|
async def search_recent_retrospectives(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
query: str,
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
task_type: str | None = None,
|
||||||
|
skill_name: str | None = None,
|
||||||
|
limit: int = 3,
|
||||||
|
) -> list[SessionRetrospective]:
|
||||||
|
if not settings.ENABLE_SESSION_RETROSPECTIVE_SEARCH:
|
||||||
|
return []
|
||||||
|
return await SessionRetrospectiveSearch(db).shortlist(
|
||||||
|
user_id=user_id,
|
||||||
|
query_text=query,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
task_type=task_type,
|
||||||
|
skill_name=skill_name,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_retrospective(retrospective: SessionRetrospective) -> dict[str, object]:
|
||||||
|
verification_status = retrospective.verification_status or retrospective.outcome
|
||||||
|
success_score = 1.0 if verification_status == "passed" else 0.6 if verification_status == "skipped" else 0.2
|
||||||
|
reusable_patterns = []
|
||||||
|
if retrospective.used_skill_names:
|
||||||
|
reusable_patterns.append("skill_shortlist_hit")
|
||||||
|
if retrospective.execution_mode:
|
||||||
|
reusable_patterns.append(f"mode:{retrospective.execution_mode}")
|
||||||
|
|
||||||
|
avoid_patterns = []
|
||||||
|
if retrospective.outcome == "failed":
|
||||||
|
avoid_patterns.append("failed_outcome")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"retrospective_id": retrospective.retrospective_id,
|
||||||
|
"task_type": retrospective.task_type,
|
||||||
|
"request_summary": retrospective.query_text[:120],
|
||||||
|
"summary": retrospective.summary,
|
||||||
|
"execution_mode": retrospective.execution_mode,
|
||||||
|
"success_score": round(success_score, 2),
|
||||||
|
"reusable_patterns": reusable_patterns,
|
||||||
|
"avoid_patterns": avoid_patterns,
|
||||||
|
}
|
||||||
72
backend/app/agents/learning/signal_extractor.py
Normal file
72
backend/app/agents/learning/signal_extractor.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.schemas.learning import LearningSignal, SessionRetrospective
|
||||||
|
|
||||||
|
|
||||||
|
class RetrospectiveSignalExtractor:
|
||||||
|
def extract(self, retrospective: SessionRetrospective) -> list[LearningSignal]:
|
||||||
|
signals: list[LearningSignal] = []
|
||||||
|
|
||||||
|
if retrospective.outcome == "completed":
|
||||||
|
signals.append(
|
||||||
|
LearningSignal(
|
||||||
|
signal_type="workflow",
|
||||||
|
confidence=0.8,
|
||||||
|
evidence_refs=retrospective.evidence_refs[:3],
|
||||||
|
explanation="Completed runs can be mined as workflow hints later.",
|
||||||
|
payload={
|
||||||
|
"task_type": retrospective.task_type,
|
||||||
|
"execution_mode": retrospective.execution_mode,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(retrospective.task_refs) > 1:
|
||||||
|
context_snapshot = retrospective.context_snapshot or {}
|
||||||
|
merge_report = dict(context_snapshot.get("merge_report") or {})
|
||||||
|
verification_report = dict(context_snapshot.get("verification_report") or {})
|
||||||
|
effectiveness_score = 1.0
|
||||||
|
if merge_report.get("status") == "conflicted":
|
||||||
|
effectiveness_score = 0.45
|
||||||
|
elif merge_report.get("status") == "fallback":
|
||||||
|
effectiveness_score = 0.25
|
||||||
|
elif verification_report.get("status") == "failed":
|
||||||
|
effectiveness_score = 0.3
|
||||||
|
signals.append(
|
||||||
|
LearningSignal(
|
||||||
|
signal_type="decomposition",
|
||||||
|
confidence=0.7,
|
||||||
|
evidence_refs=retrospective.task_refs[:3],
|
||||||
|
explanation="Multiple completed task refs indicate a decomposition pattern.",
|
||||||
|
payload={
|
||||||
|
"task_count": len(retrospective.task_refs),
|
||||||
|
"scheduled_subtask_count": context_snapshot.get("scheduled_subtask_count", 0),
|
||||||
|
"effectiveness_score": effectiveness_score,
|
||||||
|
"merge_status": merge_report.get("status"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if retrospective.used_skill_names:
|
||||||
|
signals.append(
|
||||||
|
LearningSignal(
|
||||||
|
signal_type="tool_success",
|
||||||
|
confidence=0.65 if retrospective.outcome == "completed" else 0.35,
|
||||||
|
evidence_refs=retrospective.evidence_refs[:2],
|
||||||
|
explanation="Task-scoped skill shortlist was available during this run.",
|
||||||
|
payload={"skills": retrospective.used_skill_names[:3]},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if retrospective.outcome == "failed":
|
||||||
|
signals.append(
|
||||||
|
LearningSignal(
|
||||||
|
signal_type="correction",
|
||||||
|
confidence=0.75,
|
||||||
|
evidence_refs=retrospective.evidence_refs[:2],
|
||||||
|
explanation="Failed retrospectives should remain auditable before any promotion.",
|
||||||
|
payload={"verification_status": retrospective.verification_status},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return signals
|
||||||
54
backend/app/agents/learning/skill_candidate_builder.py
Normal file
54
backend/app/agents/learning/skill_candidate_builder.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from app.agents.schemas.learning import PatternCandidate, SkillCandidate
|
||||||
|
|
||||||
|
|
||||||
|
class SkillCandidateBuilder:
|
||||||
|
def build(self, patterns: list[PatternCandidate]) -> list[SkillCandidate]:
|
||||||
|
candidates: list[SkillCandidate] = []
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
if pattern.confidence < 0.55:
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = self._build_name(pattern)
|
||||||
|
candidates.append(
|
||||||
|
SkillCandidate(
|
||||||
|
candidate_id=f"candidate-{self._stable_suffix(pattern)}",
|
||||||
|
name=name,
|
||||||
|
summary=pattern.description,
|
||||||
|
candidate_type=self._map_candidate_type(pattern.pattern_type),
|
||||||
|
source_pattern_ids=[pattern.pattern_id],
|
||||||
|
confidence=pattern.confidence,
|
||||||
|
evidence_refs=pattern.evidence_refs[:4],
|
||||||
|
recommended_status="candidate",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_name(pattern: PatternCandidate) -> str:
|
||||||
|
prefix = {
|
||||||
|
"workflow": "workflow",
|
||||||
|
"decomposition": "decomposition",
|
||||||
|
"preference": "preference",
|
||||||
|
}.get(pattern.pattern_type, "learned")
|
||||||
|
stable_suffix = SkillCandidateBuilder._stable_suffix(pattern)
|
||||||
|
return f"{prefix}-{stable_suffix}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _map_candidate_type(pattern_type: str) -> str:
|
||||||
|
mapping = {
|
||||||
|
"workflow": "workflow_skill",
|
||||||
|
"decomposition": "decomposition_skill",
|
||||||
|
"preference": "preference_skill",
|
||||||
|
}
|
||||||
|
return mapping.get(pattern_type, "workflow_skill")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _stable_suffix(pattern: PatternCandidate) -> str:
|
||||||
|
raw = f"{pattern.pattern_type}:{pattern.description}".encode("utf-8")
|
||||||
|
return hashlib.sha1(raw).hexdigest()[:10]
|
||||||
129
backend/app/agents/learning/store.py
Normal file
129
backend/app/agents/learning/store.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import desc, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.agents.schemas.learning import SessionRetrospective
|
||||||
|
from app.models.learning import LearningArtifactRecord, SessionRetrospectiveRecord
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRetrospectiveStore:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def save(self, retrospective: SessionRetrospective) -> SessionRetrospectiveRecord:
|
||||||
|
payload = retrospective.model_dump(mode="json")
|
||||||
|
record = SessionRetrospectiveRecord(
|
||||||
|
user_id=retrospective.user_id,
|
||||||
|
conversation_id=retrospective.conversation_id,
|
||||||
|
request_message_id=retrospective.request_message_id,
|
||||||
|
response_message_id=retrospective.response_message_id,
|
||||||
|
query_text=retrospective.query_text,
|
||||||
|
final_response=retrospective.final_response,
|
||||||
|
summary_text=retrospective.summary,
|
||||||
|
task_type=retrospective.task_type,
|
||||||
|
execution_mode=retrospective.execution_mode,
|
||||||
|
primary_agent=retrospective.primary_agent,
|
||||||
|
verification_status=retrospective.verification_status,
|
||||||
|
verification_summary=retrospective.verification_summary,
|
||||||
|
skill_names=retrospective.used_skill_names,
|
||||||
|
evidence=retrospective.evidence_refs,
|
||||||
|
task_refs=retrospective.task_refs,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
self.db.add(record)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
async def list_recent(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> list[SessionRetrospectiveRecord]:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(SessionRetrospectiveRecord)
|
||||||
|
.where(SessionRetrospectiveRecord.user_id == user_id)
|
||||||
|
.order_by(desc(SessionRetrospectiveRecord.recorded_at), desc(SessionRetrospectiveRecord.created_at))
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
class LearningArtifactStore:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def save_batch(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
conversation_id: str,
|
||||||
|
retrospective_id: str | None,
|
||||||
|
artifacts: list[dict[str, object]],
|
||||||
|
) -> list[LearningArtifactRecord]:
|
||||||
|
records: list[LearningArtifactRecord] = []
|
||||||
|
for artifact in artifacts:
|
||||||
|
record = LearningArtifactRecord(
|
||||||
|
user_id=user_id,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
retrospective_id=retrospective_id,
|
||||||
|
artifact_type=str(artifact.get("artifact_type") or "unknown"),
|
||||||
|
artifact_key=str(artifact.get("artifact_key") or "") or None,
|
||||||
|
summary_text=str(artifact.get("summary_text") or ""),
|
||||||
|
payload=dict(artifact.get("payload") or {}),
|
||||||
|
)
|
||||||
|
self.db.add(record)
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
await self.db.commit()
|
||||||
|
for record in records:
|
||||||
|
await self.db.refresh(record)
|
||||||
|
return records
|
||||||
|
|
||||||
|
async def list_recent(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
artifact_type: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[LearningArtifactRecord]:
|
||||||
|
query = select(LearningArtifactRecord).where(LearningArtifactRecord.user_id == user_id)
|
||||||
|
if artifact_type:
|
||||||
|
query = query.where(LearningArtifactRecord.artifact_type == artifact_type)
|
||||||
|
result = await self.db.execute(
|
||||||
|
query.order_by(
|
||||||
|
desc(LearningArtifactRecord.recorded_at),
|
||||||
|
desc(LearningArtifactRecord.created_at),
|
||||||
|
).limit(limit)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def aggregate_counts_by_key(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
artifact_type: str,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
records = await self.list_recent(user_id=user_id, artifact_type=artifact_type, limit=limit)
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for record in records:
|
||||||
|
key = record.artifact_key or "unknown"
|
||||||
|
counts[key] = counts.get(key, 0) + 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def append_retrospective_attachment(
|
||||||
|
attachments: list[dict] | None,
|
||||||
|
retrospective: SessionRetrospective,
|
||||||
|
) -> list[dict]:
|
||||||
|
next_attachments = list(attachments or [])
|
||||||
|
next_attachments.append(
|
||||||
|
{
|
||||||
|
"kind": "session_retrospective",
|
||||||
|
"payload": retrospective.model_dump(mode="json"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return next_attachments
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
"""高级编排系统 - Phase 10"""
|
"""高级编排系统 - Phase 10"""
|
||||||
|
|
||||||
|
from app.agents.orchestration.budget import build_subtask_budget
|
||||||
|
from app.agents.orchestration.result_merge import merge_task_results
|
||||||
|
from app.agents.orchestration.scheduler import (
|
||||||
|
ParallelExecutionScheduler,
|
||||||
|
build_subtask_specs,
|
||||||
|
ensure_child_links,
|
||||||
|
)
|
||||||
|
from app.agents.orchestration.subagent_runtime import subtask_spec_to_agent_task
|
||||||
from app.agents.team.leader import TeamLeader, TeamTask, TaskStatus
|
from app.agents.team.leader import TeamLeader, TeamTask, TaskStatus
|
||||||
from app.agents.transport.remote import RemoteTransport, StructuredMessage
|
from app.agents.transport.remote import RemoteTransport, StructuredMessage
|
||||||
|
from app.agents.orchestration.task_graph import build_bounded_task_graph, render_task_graph_summary
|
||||||
from app.agents.background.manager import (
|
from app.agents.background.manager import (
|
||||||
BackgroundTaskManager,
|
BackgroundTaskManager,
|
||||||
BackgroundTask,
|
BackgroundTask,
|
||||||
@@ -14,7 +23,15 @@ __all__ = [
|
|||||||
"TaskStatus",
|
"TaskStatus",
|
||||||
"RemoteTransport",
|
"RemoteTransport",
|
||||||
"StructuredMessage",
|
"StructuredMessage",
|
||||||
|
"ParallelExecutionScheduler",
|
||||||
|
"build_bounded_task_graph",
|
||||||
|
"build_subtask_budget",
|
||||||
|
"build_subtask_specs",
|
||||||
"BackgroundTaskManager",
|
"BackgroundTaskManager",
|
||||||
"BackgroundTask",
|
"BackgroundTask",
|
||||||
|
"ensure_child_links",
|
||||||
"get_background_task_manager",
|
"get_background_task_manager",
|
||||||
|
"merge_task_results",
|
||||||
|
"render_task_graph_summary",
|
||||||
|
"subtask_spec_to_agent_task",
|
||||||
]
|
]
|
||||||
|
|||||||
24
backend/app/agents/orchestration/budget.py
Normal file
24
backend/app/agents/orchestration/budget.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.schemas.task import CollaborationBudget
|
||||||
|
|
||||||
|
|
||||||
|
def build_subtask_budget(
|
||||||
|
*,
|
||||||
|
execution_mode: str,
|
||||||
|
max_parallel_tasks: int,
|
||||||
|
max_tool_calls: int = 2,
|
||||||
|
max_iterations: int = 2,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> CollaborationBudget:
|
||||||
|
return CollaborationBudget(
|
||||||
|
mode="collaboration" if execution_mode != "direct" else "direct",
|
||||||
|
max_parallel_tasks=max_parallel_tasks,
|
||||||
|
remaining_parallel_tasks=max_parallel_tasks,
|
||||||
|
max_tool_calls=max_tool_calls,
|
||||||
|
remaining_tool_calls=max_tool_calls,
|
||||||
|
max_iterations=max_iterations,
|
||||||
|
remaining_iterations=max_iterations,
|
||||||
|
escalation_threshold=1,
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
31
backend/app/agents/orchestration/monitor.py
Normal file
31
backend/app/agents/orchestration/monitor.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def build_parallel_runtime_metrics(
|
||||||
|
*,
|
||||||
|
task_graph: dict[str, Any] | None,
|
||||||
|
scheduled_subtasks: list[dict[str, Any]] | None,
|
||||||
|
task_results: list[dict[str, Any]] | None,
|
||||||
|
merge_report: dict[str, Any] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
task_graph = task_graph or {}
|
||||||
|
scheduled_subtasks = list(scheduled_subtasks or [])
|
||||||
|
task_results = list(task_results or [])
|
||||||
|
merge_report = merge_report or {}
|
||||||
|
|
||||||
|
completed = sum(1 for item in task_results if item.get("status") == "completed")
|
||||||
|
failed = sum(1 for item in task_results if item.get("status") == "failed")
|
||||||
|
blocked = sum(1 for item in task_results if item.get("status") == "blocked")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"task_graph_node_count": len(task_graph.get("nodes") or []),
|
||||||
|
"scheduled_subtask_count": len(scheduled_subtasks),
|
||||||
|
"completed_subtask_count": completed,
|
||||||
|
"failed_subtask_count": failed,
|
||||||
|
"blocked_subtask_count": blocked,
|
||||||
|
"merge_status": merge_report.get("status"),
|
||||||
|
"merge_conflict_count": len(merge_report.get("conflict_flags") or []),
|
||||||
|
"fallback_used": bool(merge_report.get("fallback_used") or False),
|
||||||
|
}
|
||||||
69
backend/app/agents/orchestration/result_merge.py
Normal file
69
backend/app/agents/orchestration/result_merge.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.schemas.orchestration import MergeReport
|
||||||
|
from app.agents.verifier import normalize_task_result
|
||||||
|
|
||||||
|
|
||||||
|
def merge_task_results(task_results: list[dict] | list[object]) -> MergeReport:
|
||||||
|
normalized = [normalize_task_result(item) for item in (task_results or [])]
|
||||||
|
completed = [item for item in normalized if item.status == "completed"]
|
||||||
|
failed_or_blocked = [item for item in normalized if item.status in {"failed", "blocked"}]
|
||||||
|
|
||||||
|
evidence_union: list[dict] = []
|
||||||
|
summaries = []
|
||||||
|
for item in normalized:
|
||||||
|
evidence_union.extend(list(item.evidence or []))
|
||||||
|
if item.summary:
|
||||||
|
summaries.append(item.summary.strip())
|
||||||
|
|
||||||
|
unique_summaries = list(dict.fromkeys(summary for summary in summaries if summary))
|
||||||
|
conflict_flags: list[str] = []
|
||||||
|
status = "merged"
|
||||||
|
fallback_used = False
|
||||||
|
|
||||||
|
if failed_or_blocked:
|
||||||
|
status = "fallback"
|
||||||
|
fallback_used = True
|
||||||
|
conflict_flags.append(
|
||||||
|
"failed_or_blocked_tasks:" + ",".join(item.task_id for item in failed_or_blocked)
|
||||||
|
)
|
||||||
|
resolution_strategy = "serial_recovery"
|
||||||
|
resolved_summary = (
|
||||||
|
completed[-1].summary
|
||||||
|
if completed and completed[-1].summary
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
elif len(unique_summaries) > 1 and len(completed) > 1:
|
||||||
|
status = "conflicted"
|
||||||
|
conflict_flags.append("multiple_distinct_completed_summaries")
|
||||||
|
resolution_strategy = "rank_by_evidence_count"
|
||||||
|
ranked = sorted(
|
||||||
|
completed,
|
||||||
|
key=lambda item: (len(item.evidence or []), bool(item.summary)),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
resolved_summary = ranked[0].summary if ranked and ranked[0].summary else None
|
||||||
|
else:
|
||||||
|
resolution_strategy = "evidence_union"
|
||||||
|
resolved_summary = unique_summaries[-1] if unique_summaries else None
|
||||||
|
|
||||||
|
if status == "merged":
|
||||||
|
summary = (
|
||||||
|
unique_summaries[-1]
|
||||||
|
if unique_summaries
|
||||||
|
else f"已收敛 {len(normalized)} 个子任务结果。"
|
||||||
|
)
|
||||||
|
elif status == "conflicted":
|
||||||
|
summary = "并行子任务摘要存在冲突,需要 verifier 或串行收敛。"
|
||||||
|
else:
|
||||||
|
summary = "存在失败或阻塞子任务,需要回退到更保守的收敛路径。"
|
||||||
|
|
||||||
|
return MergeReport(
|
||||||
|
status=status,
|
||||||
|
summary=summary,
|
||||||
|
evidence_union=evidence_union,
|
||||||
|
conflict_flags=conflict_flags,
|
||||||
|
resolution_strategy=resolution_strategy,
|
||||||
|
resolved_summary=resolved_summary,
|
||||||
|
fallback_used=fallback_used,
|
||||||
|
)
|
||||||
93
backend/app/agents/orchestration/scheduler.py
Normal file
93
backend/app/agents/orchestration/scheduler.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.agents.orchestration.budget import build_subtask_budget
|
||||||
|
from app.agents.schemas.orchestration import SubTaskSpec, TaskGraph, TaskNode
|
||||||
|
|
||||||
|
|
||||||
|
class ParallelExecutionScheduler:
|
||||||
|
def plan(self, task_graph: TaskGraph, *, query_text: str) -> list[SubTaskSpec]:
|
||||||
|
ordered_nodes = _topological_nodes(task_graph)
|
||||||
|
specs: list[SubTaskSpec] = []
|
||||||
|
for node in ordered_nodes:
|
||||||
|
budget = build_subtask_budget(
|
||||||
|
execution_mode=node.execution_mode,
|
||||||
|
max_parallel_tasks=max(1, task_graph.max_parallelism),
|
||||||
|
metadata={
|
||||||
|
"task_graph_id": task_graph.graph_id,
|
||||||
|
"depends_on": node.depends_on,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
specs.append(
|
||||||
|
SubTaskSpec(
|
||||||
|
subtask_id=node.node_id,
|
||||||
|
parent_run_id=task_graph.graph_id,
|
||||||
|
title=node.title,
|
||||||
|
role=node.role or "master",
|
||||||
|
goal=node.goal or query_text,
|
||||||
|
context_slice=_build_context_slice(node, query_text),
|
||||||
|
allowed_tools=[],
|
||||||
|
budget_tokens=1200,
|
||||||
|
budget_tool_calls=budget.max_tool_calls or 2,
|
||||||
|
expected_output_schema={
|
||||||
|
"summary": "string",
|
||||||
|
"evidence": "list",
|
||||||
|
"status": "completed|failed|blocked",
|
||||||
|
},
|
||||||
|
expected_evidence=node.expected_evidence,
|
||||||
|
dependencies=node.depends_on,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return specs
|
||||||
|
|
||||||
|
|
||||||
|
def build_subtask_specs(task_graph: TaskGraph, *, query_text: str) -> list[SubTaskSpec]:
|
||||||
|
return ParallelExecutionScheduler().plan(task_graph, query_text=query_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_context_slice(node: TaskNode, query_text: str) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"query": query_text,
|
||||||
|
"role": node.role,
|
||||||
|
"title": node.title,
|
||||||
|
"goal": node.goal,
|
||||||
|
"depends_on": node.depends_on,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _topological_nodes(task_graph: TaskGraph) -> list[TaskNode]:
|
||||||
|
by_id = {node.node_id: node for node in task_graph.nodes}
|
||||||
|
indegree = {node.node_id: 0 for node in task_graph.nodes}
|
||||||
|
edges: dict[str, list[str]] = defaultdict(list)
|
||||||
|
|
||||||
|
for node in task_graph.nodes:
|
||||||
|
for dep in node.depends_on:
|
||||||
|
if dep not in by_id:
|
||||||
|
continue
|
||||||
|
edges[dep].append(node.node_id)
|
||||||
|
indegree[node.node_id] += 1
|
||||||
|
|
||||||
|
ready = deque(node_id for node_id, count in indegree.items() if count == 0)
|
||||||
|
ordered: list[TaskNode] = []
|
||||||
|
|
||||||
|
while ready:
|
||||||
|
node_id = ready.popleft()
|
||||||
|
ordered.append(by_id[node_id])
|
||||||
|
for target in edges.get(node_id, []):
|
||||||
|
indegree[target] -= 1
|
||||||
|
if indegree[target] == 0:
|
||||||
|
ready.append(target)
|
||||||
|
|
||||||
|
if len(ordered) != len(task_graph.nodes):
|
||||||
|
return list(task_graph.nodes)
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_child_links(specs: list[SubTaskSpec]) -> dict[str, list[str]]:
|
||||||
|
graph: dict[str, list[str]] = defaultdict(list)
|
||||||
|
for spec in specs:
|
||||||
|
for dep in spec.dependencies:
|
||||||
|
graph[dep].append(spec.subtask_id)
|
||||||
|
return dict(graph)
|
||||||
17
backend/app/agents/orchestration/subagent_runtime.py
Normal file
17
backend/app/agents/orchestration/subagent_runtime.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.schemas.orchestration import SubTaskSpec
|
||||||
|
from app.agents.schemas.task import AgentTask
|
||||||
|
|
||||||
|
|
||||||
|
def subtask_spec_to_agent_task(spec: SubTaskSpec) -> AgentTask:
|
||||||
|
return AgentTask(
|
||||||
|
task_id=spec.subtask_id,
|
||||||
|
title=spec.title,
|
||||||
|
owner_agent_id=spec.role,
|
||||||
|
role=spec.role,
|
||||||
|
goal=spec.goal,
|
||||||
|
parent_task_id=spec.parent_run_id,
|
||||||
|
child_task_ids=[],
|
||||||
|
expected_evidence=spec.expected_evidence,
|
||||||
|
)
|
||||||
128
backend/app/agents/orchestration/task_graph.py
Normal file
128
backend/app/agents/orchestration/task_graph.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.agents.schemas.orchestration import ParallelWorthiness, TaskGraph, TaskNode
|
||||||
|
|
||||||
|
|
||||||
|
ROLE_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [
|
||||||
|
("librarian", ("查", "检索", "资料", "文档", "知识库", "年报", "forum", "search")),
|
||||||
|
("analyst", ("分析", "判断", "风险", "总结", "对比", "洞察", "结论")),
|
||||||
|
("schedule_planner", ("计划", "安排", "下周", "日程", "提醒", "优先级")),
|
||||||
|
("executor", ("执行", "创建", "更新", "落库", "提交", "发帖")),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_bounded_task_graph(
|
||||||
|
*,
|
||||||
|
query_text: str,
|
||||||
|
parallel_worthiness: ParallelWorthiness,
|
||||||
|
max_nodes: int = 4,
|
||||||
|
) -> TaskGraph | None:
|
||||||
|
roles = _infer_roles(query_text)
|
||||||
|
if not roles:
|
||||||
|
return None
|
||||||
|
|
||||||
|
independent_roles = roles[: min(max_nodes - 1, max(1, parallel_worthiness.estimated_subtasks))]
|
||||||
|
nodes: list[TaskNode] = []
|
||||||
|
|
||||||
|
for index, role in enumerate(independent_roles, start=1):
|
||||||
|
node_id = f"task-{index}-{uuid4().hex[:6]}"
|
||||||
|
nodes.append(
|
||||||
|
TaskNode(
|
||||||
|
node_id=node_id,
|
||||||
|
title=_build_title(role),
|
||||||
|
role=role,
|
||||||
|
goal=_build_goal(role, query_text),
|
||||||
|
depends_on=[],
|
||||||
|
execution_mode=(
|
||||||
|
"parallel"
|
||||||
|
if parallel_worthiness.preferred_mode in {"collaboration", "parallel"}
|
||||||
|
and len(independent_roles) > 1
|
||||||
|
else "serial"
|
||||||
|
),
|
||||||
|
expected_evidence=_build_expected_evidence(role),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(nodes) > 1:
|
||||||
|
merge_id = f"merge-{uuid4().hex[:6]}"
|
||||||
|
nodes.append(
|
||||||
|
TaskNode(
|
||||||
|
node_id=merge_id,
|
||||||
|
title="汇总并收敛最终结论",
|
||||||
|
role="master",
|
||||||
|
goal="汇总前置子任务结果,形成统一可验证的输出。",
|
||||||
|
depends_on=[node.node_id for node in nodes],
|
||||||
|
execution_mode="serial",
|
||||||
|
expected_evidence=[{"type": "merge", "detail": "merged summary and conflict notes"}],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return TaskGraph(
|
||||||
|
nodes=nodes,
|
||||||
|
entry_node_ids=[node.node_id for node in nodes if not node.depends_on],
|
||||||
|
max_parallelism=max(1, len(independent_roles)),
|
||||||
|
rationale=_build_rationale(parallel_worthiness, independent_roles),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_task_graph_summary(task_graph: TaskGraph | None) -> str | None:
|
||||||
|
if task_graph is None or not task_graph.nodes:
|
||||||
|
return None
|
||||||
|
|
||||||
|
lines = ["- 任务图:"]
|
||||||
|
for node in task_graph.nodes[:4]:
|
||||||
|
deps = f" deps={','.join(node.depends_on)}" if node.depends_on else ""
|
||||||
|
lines.append(f" - [{node.execution_mode}] {node.title} ({node.role}){deps}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_roles(query_text: str) -> list[str]:
|
||||||
|
selected: list[str] = []
|
||||||
|
text = query_text or ""
|
||||||
|
for role, keywords in ROLE_KEYWORDS:
|
||||||
|
if any(keyword in text for keyword in keywords):
|
||||||
|
selected.append(role)
|
||||||
|
|
||||||
|
if not selected:
|
||||||
|
return ["analyst"]
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def _build_title(role: str) -> str:
|
||||||
|
mapping = {
|
||||||
|
"librarian": "收集事实与外部/内部证据",
|
||||||
|
"analyst": "形成判断与风险分析",
|
||||||
|
"schedule_planner": "整理计划和优先级",
|
||||||
|
"executor": "执行必要操作并回收结果",
|
||||||
|
}
|
||||||
|
return mapping.get(role, "处理子任务")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_goal(role: str, query_text: str) -> str:
|
||||||
|
mapping = {
|
||||||
|
"librarian": f"围绕请求收集支持结论的事实和资料:{query_text}",
|
||||||
|
"analyst": f"基于当前请求输出结构化判断:{query_text}",
|
||||||
|
"schedule_planner": f"把当前请求收束为计划、安排或优先级:{query_text}",
|
||||||
|
"executor": f"基于请求执行必要动作并返回结果:{query_text}",
|
||||||
|
}
|
||||||
|
return mapping.get(role, query_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_expected_evidence(role: str) -> list[dict[str, str]]:
|
||||||
|
mapping = {
|
||||||
|
"librarian": [{"type": "evidence", "detail": "retrieval findings"}],
|
||||||
|
"analyst": [{"type": "analysis", "detail": "structured judgment"}],
|
||||||
|
"schedule_planner": [{"type": "plan", "detail": "explicit schedule or priorities"}],
|
||||||
|
"executor": [{"type": "execution", "detail": "tool output or mutation result"}],
|
||||||
|
}
|
||||||
|
return mapping.get(role, [{"type": "summary", "detail": "task summary"}])
|
||||||
|
|
||||||
|
|
||||||
|
def _build_rationale(parallel_worthiness: ParallelWorthiness, roles: list[str]) -> str:
|
||||||
|
return (
|
||||||
|
f"preferred_mode={parallel_worthiness.preferred_mode}; "
|
||||||
|
f"score={parallel_worthiness.score:.2f}; "
|
||||||
|
f"roles={','.join(roles)}"
|
||||||
|
)
|
||||||
@@ -309,14 +309,14 @@ ANALYST_INSIGHTS_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
|||||||
|
|
||||||
你是 analyst 体系下的洞察建议官,负责从任务、论坛和知识线索里提炼趋势、风险与建议。
|
你是 analyst 体系下的洞察建议官,负责从任务、论坛和知识线索里提炼趋势、风险与建议。
|
||||||
|
|
||||||
## 允许使用的工具:
|
## 你的允许使用的工具:
|
||||||
- get_tasks
|
- get_tasks
|
||||||
- get_forum_posts
|
- get_forum_posts
|
||||||
- search_knowledge
|
- search_knowledge
|
||||||
- hybrid_search
|
- hybrid_search
|
||||||
- web_search
|
- web_search
|
||||||
|
|
||||||
## 要求:
|
## 你的要求:
|
||||||
- 先给结论与判断
|
- 先给结论与判断
|
||||||
- 再说明依据与建议
|
- 再说明依据与建议
|
||||||
- 当需要外部/最新信息时,可使用 `web_search`
|
- 当需要外部/最新信息时,可使用 `web_search`
|
||||||
@@ -324,6 +324,38 @@ ANALYST_INSIGHTS_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
CODE_COMMANDER_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||||
|
|
||||||
|
你是代码指挥官,负责协调 AI 写代码助手。
|
||||||
|
|
||||||
|
## 你的职责:
|
||||||
|
1. 接收用户选择的 AI 提供商(Claude/Gemini/Codex/OpenCode)
|
||||||
|
2. 接收用户的写代码需求
|
||||||
|
3. 进行安全分级判定
|
||||||
|
4. 路由到合适的执行器
|
||||||
|
|
||||||
|
## 安全分级规则:
|
||||||
|
- 低风险:demo、示例、贪食蛇游戏等独立项目
|
||||||
|
- 高风险:修改现有项目、涉及 Jarvis 项目、路径操作等
|
||||||
|
|
||||||
|
## 执行模式:
|
||||||
|
- 直接执行:低风险任务,直接运行
|
||||||
|
- 沙盒执行:高风险任务,在临时目录隔离执行
|
||||||
|
|
||||||
|
## 你的输出:
|
||||||
|
- 简洁汇报执行结果
|
||||||
|
- 如果需要用户交互(如确认 "y"),明确提示
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
SANDBOX_EXECUTION_PROMPT = """将在隔离的临时目录中执行任务。
|
||||||
|
任务完成后,工作目录会被保留供下载。"""
|
||||||
|
|
||||||
|
|
||||||
|
DIRECT_EXECUTION_PROMPT = """将直接执行任务。
|
||||||
|
如果需要交互,请等待用户输入。"""
|
||||||
|
|
||||||
|
|
||||||
COORDINATOR_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
COORDINATOR_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||||
|
|
||||||
你是 Jarvis 的协作协调官,负责把复杂请求收束成最小受控协作,而不是放任系统进入自由 swarm。
|
你是 Jarvis 的协作协调官,负责把复杂请求收束成最小受控协作,而不是放任系统进入自由 swarm。
|
||||||
@@ -382,6 +414,7 @@ TOP_LEVEL_SYSTEM_PROMPTS_BY_KEY = {
|
|||||||
"executor": EXECUTOR_SYSTEM_PROMPT,
|
"executor": EXECUTOR_SYSTEM_PROMPT,
|
||||||
"librarian": LIBRARIAN_SYSTEM_PROMPT,
|
"librarian": LIBRARIAN_SYSTEM_PROMPT,
|
||||||
"analyst": ANALYST_SYSTEM_PROMPT,
|
"analyst": ANALYST_SYSTEM_PROMPT,
|
||||||
|
"code_commander": CODE_COMMANDER_SYSTEM_PROMPT,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ TOP_LEVEL_AGENT_DEFAULT_SUB_COMMANDERS: dict[str, tuple[str, ...]] = {
|
|||||||
"analyst_progress",
|
"analyst_progress",
|
||||||
"analyst_insights",
|
"analyst_insights",
|
||||||
),
|
),
|
||||||
|
AgentRole.CODE_COMMANDER.value: (),
|
||||||
}
|
}
|
||||||
|
|
||||||
TOP_LEVEL_AGENT_DISPLAY_NAMES: dict[str, str] = {
|
TOP_LEVEL_AGENT_DISPLAY_NAMES: dict[str, str] = {
|
||||||
@@ -37,6 +38,7 @@ TOP_LEVEL_AGENT_DISPLAY_NAMES: dict[str, str] = {
|
|||||||
AgentRole.EXECUTOR.value: "Executor",
|
AgentRole.EXECUTOR.value: "Executor",
|
||||||
AgentRole.LIBRARIAN.value: "Librarian",
|
AgentRole.LIBRARIAN.value: "Librarian",
|
||||||
AgentRole.ANALYST.value: "Analyst",
|
AgentRole.ANALYST.value: "Analyst",
|
||||||
|
AgentRole.CODE_COMMANDER.value: "Code Commander",
|
||||||
}
|
}
|
||||||
|
|
||||||
TOP_LEVEL_AGENT_ROUTING_HINTS: dict[str, tuple[str, ...]] = {
|
TOP_LEVEL_AGENT_ROUTING_HINTS: dict[str, tuple[str, ...]] = {
|
||||||
@@ -55,6 +57,9 @@ TOP_LEVEL_AGENT_ROUTING_HINTS: dict[str, tuple[str, ...]] = {
|
|||||||
AgentRole.ANALYST.value: (
|
AgentRole.ANALYST.value: (
|
||||||
"Handle reporting and insight requests using analyst sub-commanders.",
|
"Handle reporting and insight requests using analyst sub-commanders.",
|
||||||
),
|
),
|
||||||
|
AgentRole.CODE_COMMANDER.value: (
|
||||||
|
"Handle code writing and execution tasks using AI CLI adapters.",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
TOP_LEVEL_AGENT_ALLOWED_SPAWN_ROLES: dict[str, tuple[str, ...]] = {
|
TOP_LEVEL_AGENT_ALLOWED_SPAWN_ROLES: dict[str, tuple[str, ...]] = {
|
||||||
@@ -63,11 +68,13 @@ TOP_LEVEL_AGENT_ALLOWED_SPAWN_ROLES: dict[str, tuple[str, ...]] = {
|
|||||||
AgentRole.EXECUTOR.value,
|
AgentRole.EXECUTOR.value,
|
||||||
AgentRole.LIBRARIAN.value,
|
AgentRole.LIBRARIAN.value,
|
||||||
AgentRole.ANALYST.value,
|
AgentRole.ANALYST.value,
|
||||||
|
AgentRole.CODE_COMMANDER.value,
|
||||||
),
|
),
|
||||||
AgentRole.SCHEDULE_PLANNER.value: (AgentRole.SCHEDULE_PLANNER.value,),
|
AgentRole.SCHEDULE_PLANNER.value: (AgentRole.SCHEDULE_PLANNER.value,),
|
||||||
AgentRole.EXECUTOR.value: (AgentRole.EXECUTOR.value,),
|
AgentRole.EXECUTOR.value: (AgentRole.EXECUTOR.value,),
|
||||||
AgentRole.LIBRARIAN.value: (AgentRole.LIBRARIAN.value,),
|
AgentRole.LIBRARIAN.value: (AgentRole.LIBRARIAN.value,),
|
||||||
AgentRole.ANALYST.value: (AgentRole.ANALYST.value,),
|
AgentRole.ANALYST.value: (AgentRole.ANALYST.value,),
|
||||||
|
AgentRole.CODE_COMMANDER.value: (),
|
||||||
}
|
}
|
||||||
|
|
||||||
SUB_COMMANDER_PARENT_AGENT_IDS: dict[str, str] = {
|
SUB_COMMANDER_PARENT_AGENT_IDS: dict[str, str] = {
|
||||||
@@ -99,11 +106,7 @@ BUILTIN_AGENT_MANIFESTS: tuple[AgentManifest, ...] = tuple(
|
|||||||
|
|
||||||
|
|
||||||
_capability_tool_names = tuple(
|
_capability_tool_names = tuple(
|
||||||
dict.fromkeys(
|
dict.fromkeys(tool.name for tools in SUB_COMMANDER_TOOLSETS.values() for tool in tools)
|
||||||
tool.name
|
|
||||||
for tools in SUB_COMMANDER_TOOLSETS.values()
|
|
||||||
for tool in tools
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_CAPABILITY_METADATA_BY_TOOL_NAME: dict[str, dict[str, object]] = {
|
_CAPABILITY_METADATA_BY_TOOL_NAME: dict[str, dict[str, object]] = {
|
||||||
@@ -260,9 +263,7 @@ BUILTIN_SUB_COMMANDER_MANIFESTS: tuple[SubCommanderManifest, ...] = tuple(
|
|||||||
sub_commander_id=sub_commander_id,
|
sub_commander_id=sub_commander_id,
|
||||||
parent_agent_id=SUB_COMMANDER_PARENT_AGENT_IDS[sub_commander_id],
|
parent_agent_id=SUB_COMMANDER_PARENT_AGENT_IDS[sub_commander_id],
|
||||||
prompt_text=SUB_COMMANDER_PROMPTS_BY_KEY[sub_commander_id],
|
prompt_text=SUB_COMMANDER_PROMPTS_BY_KEY[sub_commander_id],
|
||||||
capability_ids=list(
|
capability_ids=list(dict.fromkeys(tool.name for tool in tools)),
|
||||||
dict.fromkeys(tool.name for tool in tools)
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
for sub_commander_id, tools in SUB_COMMANDER_TOOLSETS.items()
|
for sub_commander_id, tools in SUB_COMMANDER_TOOLSETS.items()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
from app.agents.schemas.event import AgentEvent
|
from app.agents.schemas.event import AgentEvent
|
||||||
|
from app.agents.schemas.learning import (
|
||||||
|
LearningDecision,
|
||||||
|
LearningSignal,
|
||||||
|
PatternCandidate,
|
||||||
|
SessionRetrospective,
|
||||||
|
SkillCandidate,
|
||||||
|
)
|
||||||
from app.agents.schemas.message import AgentMessage
|
from app.agents.schemas.message import AgentMessage
|
||||||
|
from app.agents.schemas.orchestration import (
|
||||||
|
ExecutionDecision,
|
||||||
|
MergeReport,
|
||||||
|
ParallelWorthiness,
|
||||||
|
RuntimeRequestContext,
|
||||||
|
SubTaskResult,
|
||||||
|
SubTaskSpec,
|
||||||
|
TaskGraph,
|
||||||
|
TaskNode,
|
||||||
|
VerificationReport,
|
||||||
|
)
|
||||||
|
from app.agents.schemas.skills import SkillActivationRecord, SkillShortlistEntry
|
||||||
from app.agents.schemas.task import (
|
from app.agents.schemas.task import (
|
||||||
AgentTask,
|
AgentTask,
|
||||||
CollaborationBudget,
|
CollaborationBudget,
|
||||||
@@ -14,12 +33,28 @@ from app.agents.schemas.task import (
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"AgentEvent",
|
"AgentEvent",
|
||||||
"AgentMessage",
|
"AgentMessage",
|
||||||
|
"ExecutionDecision",
|
||||||
"AgentTask",
|
"AgentTask",
|
||||||
"CollaborationBudget",
|
"CollaborationBudget",
|
||||||
"InterruptRecord",
|
"InterruptRecord",
|
||||||
|
"LearningDecision",
|
||||||
|
"LearningSignal",
|
||||||
|
"MergeReport",
|
||||||
|
"ParallelWorthiness",
|
||||||
|
"PatternCandidate",
|
||||||
"RecoveryRecord",
|
"RecoveryRecord",
|
||||||
|
"RuntimeRequestContext",
|
||||||
|
"SessionRetrospective",
|
||||||
|
"SkillActivationRecord",
|
||||||
|
"SkillCandidate",
|
||||||
|
"SkillShortlistEntry",
|
||||||
|
"SubTaskResult",
|
||||||
|
"SubTaskSpec",
|
||||||
|
"TaskGraph",
|
||||||
|
"TaskNode",
|
||||||
"TaskLifecycleStatus",
|
"TaskLifecycleStatus",
|
||||||
"TaskResult",
|
"TaskResult",
|
||||||
"TaskResultStatus",
|
"TaskResultStatus",
|
||||||
|
"VerificationReport",
|
||||||
"VerificationStatus",
|
"VerificationStatus",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,10 +7,21 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
|
|
||||||
AgentEventType = Literal[
|
AgentEventType = Literal[
|
||||||
|
"agent.execution.decided",
|
||||||
|
"agent.parallel.assessed",
|
||||||
|
"agent.skill.shortlisted",
|
||||||
|
"agent.task_graph.built",
|
||||||
|
"agent.subtask.started",
|
||||||
|
"agent.subtask.completed",
|
||||||
|
"agent.merge.completed",
|
||||||
"agent.tool.start",
|
"agent.tool.start",
|
||||||
"agent.tool.result",
|
"agent.tool.result",
|
||||||
"agent.verify.started",
|
"agent.verify.started",
|
||||||
"agent.verify.completed",
|
"agent.verify.completed",
|
||||||
|
"agent.retrospective.created",
|
||||||
|
"agent.learning.decision",
|
||||||
|
"agent.skill.lifecycle.changed",
|
||||||
|
"agent.rollback.triggered",
|
||||||
"agent.created",
|
"agent.created",
|
||||||
"agent.spawn.blocked",
|
"agent.spawn.blocked",
|
||||||
"agent.message.sent",
|
"agent.message.sent",
|
||||||
|
|||||||
76
backend/app/agents/schemas/learning.py
Normal file
76
backend/app/agents/schemas/learning.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
LearningSignalType = Literal[
|
||||||
|
"preference",
|
||||||
|
"workflow",
|
||||||
|
"decomposition",
|
||||||
|
"tool_success",
|
||||||
|
"correction",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRetrospective(BaseModel):
|
||||||
|
retrospective_id: str | None = None
|
||||||
|
user_id: str
|
||||||
|
conversation_id: str
|
||||||
|
request_message_id: str | None = None
|
||||||
|
response_message_id: str | None = None
|
||||||
|
query_text: str
|
||||||
|
final_response: str | None = None
|
||||||
|
summary: str
|
||||||
|
task_type: str | None = None
|
||||||
|
execution_mode: str | None = None
|
||||||
|
primary_agent: str | None = None
|
||||||
|
verification_status: str | None = None
|
||||||
|
verification_summary: str | None = None
|
||||||
|
used_skill_names: list[str] = Field(default_factory=list)
|
||||||
|
evidence_refs: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
task_refs: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
event_refs: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
context_snapshot: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
learning_signals: list["LearningSignal"] = Field(default_factory=list)
|
||||||
|
pattern_candidates: list["PatternCandidate"] = Field(default_factory=list)
|
||||||
|
skill_candidates: list["SkillCandidate"] = Field(default_factory=list)
|
||||||
|
learning_decision: "LearningDecision | None" = None
|
||||||
|
outcome: Literal["completed", "partial", "failed"] = "completed"
|
||||||
|
captured_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class LearningSignal(BaseModel):
|
||||||
|
signal_type: LearningSignalType
|
||||||
|
confidence: float = 0.0
|
||||||
|
evidence_refs: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
explanation: str | None = None
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class PatternCandidate(BaseModel):
|
||||||
|
pattern_id: str
|
||||||
|
pattern_type: str
|
||||||
|
description: str
|
||||||
|
confidence: float = 0.0
|
||||||
|
evidence_refs: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillCandidate(BaseModel):
|
||||||
|
candidate_id: str
|
||||||
|
name: str
|
||||||
|
summary: str
|
||||||
|
candidate_type: Literal["workflow_skill", "preference_skill", "decomposition_skill"] = "workflow_skill"
|
||||||
|
source_pattern_ids: list[str] = Field(default_factory=list)
|
||||||
|
confidence: float = 0.0
|
||||||
|
evidence_refs: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
recommended_status: Literal["candidate", "shadow"] = "candidate"
|
||||||
|
|
||||||
|
|
||||||
|
class LearningDecision(BaseModel):
|
||||||
|
decision: Literal["reinforce_memory", "create_candidate", "promote_skill", "defer", "reject"]
|
||||||
|
explanation: str
|
||||||
|
evidence_refs: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
211
backend/app/agents/schemas/orchestration.py
Normal file
211
backend/app/agents/schemas/orchestration.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Literal
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.agents.schemas.skills import SkillShortlistEntry
|
||||||
|
|
||||||
|
|
||||||
|
ExecutionMode = Literal["direct", "collaboration", "parallel", "delegated"]
|
||||||
|
ParallelPreference = Literal["direct", "collaboration", "parallel"]
|
||||||
|
|
||||||
|
|
||||||
|
class ParallelWorthiness(BaseModel):
|
||||||
|
should_parallelize: bool = False
|
||||||
|
score: float = 0.0
|
||||||
|
estimated_subtasks: int = 1
|
||||||
|
preferred_mode: ParallelPreference = "direct"
|
||||||
|
reasons: list[str] = Field(default_factory=list)
|
||||||
|
risk_flags: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskNode(BaseModel):
|
||||||
|
node_id: str
|
||||||
|
title: str
|
||||||
|
role: str | None = None
|
||||||
|
goal: str | None = None
|
||||||
|
depends_on: list[str] = Field(default_factory=list)
|
||||||
|
execution_mode: Literal["serial", "parallel"] = "serial"
|
||||||
|
expected_evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskGraph(BaseModel):
|
||||||
|
graph_id: str = Field(default_factory=lambda: str(uuid4()))
|
||||||
|
nodes: list[TaskNode] = Field(default_factory=list)
|
||||||
|
entry_node_ids: list[str] = Field(default_factory=list)
|
||||||
|
max_parallelism: int = 1
|
||||||
|
rationale: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SubTaskSpec(BaseModel):
|
||||||
|
subtask_id: str
|
||||||
|
parent_run_id: str
|
||||||
|
title: str
|
||||||
|
role: str
|
||||||
|
goal: str
|
||||||
|
context_slice: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
allowed_tools: list[str] = Field(default_factory=list)
|
||||||
|
budget_tokens: int = 1200
|
||||||
|
budget_tool_calls: int = 2
|
||||||
|
expected_output_schema: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
expected_evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
dependencies: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SubTaskResult(BaseModel):
|
||||||
|
subtask_id: str
|
||||||
|
status: Literal["completed", "failed", "blocked"]
|
||||||
|
summary: str | None = None
|
||||||
|
evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
output: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class MergeReport(BaseModel):
|
||||||
|
merge_id: str = Field(default_factory=lambda: str(uuid4()))
|
||||||
|
status: Literal["merged", "conflicted", "fallback"]
|
||||||
|
summary: str | None = None
|
||||||
|
evidence_union: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
conflict_flags: list[str] = Field(default_factory=list)
|
||||||
|
resolution_strategy: str | None = None
|
||||||
|
resolved_summary: str | None = None
|
||||||
|
fallback_used: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class VerificationReport(BaseModel):
|
||||||
|
status: Literal["passed", "failed", "skipped"]
|
||||||
|
summary: str | None = None
|
||||||
|
evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionDecision(BaseModel):
|
||||||
|
request_id: str = Field(default_factory=lambda: str(uuid4()))
|
||||||
|
mode: ExecutionMode = "direct"
|
||||||
|
reason: str
|
||||||
|
complexity_score: float = 0.0
|
||||||
|
parallel_worthiness_score: float | None = None
|
||||||
|
selected_roles: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeRequestContext(BaseModel):
|
||||||
|
request_id: str = Field(default_factory=lambda: str(uuid4()))
|
||||||
|
session_id: str | None = None
|
||||||
|
user_id: str
|
||||||
|
conversation_id: str | None = None
|
||||||
|
query_text: str | None = None
|
||||||
|
raw_user_query: str | None = None
|
||||||
|
recalled_memories: list[str] = Field(default_factory=list)
|
||||||
|
retrospective_shortlist: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
recalled_retrospectives: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
skill_shortlist: list[SkillShortlistEntry] = Field(default_factory=list)
|
||||||
|
shortlisted_skills: list[str] = Field(default_factory=list)
|
||||||
|
parallel_worthiness: ParallelWorthiness = Field(default_factory=ParallelWorthiness)
|
||||||
|
task_graph: TaskGraph | None = None
|
||||||
|
recommended_runtime_mode: Literal["direct", "collaboration"] = "direct"
|
||||||
|
execution_mode: Literal["direct", "collaboration"] | None = None
|
||||||
|
current_agent_role: str | None = None
|
||||||
|
conversation_state_ref: str | None = None
|
||||||
|
assembly_metrics: dict[str, float] = Field(default_factory=dict)
|
||||||
|
assembled_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
def assess_parallel_worthiness(
|
||||||
|
query_text: str,
|
||||||
|
*,
|
||||||
|
retrospective_count: int = 0,
|
||||||
|
skill_count: int = 0,
|
||||||
|
) -> ParallelWorthiness:
|
||||||
|
normalized = (query_text or "").strip().lower()
|
||||||
|
reasons: list[str] = []
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
multi_step_markers = ("然后", "接着", "同时", "并且", "最后", "汇总", "对比", "分析", "research")
|
||||||
|
artifact_markers = ("文档", "代码", "文件", "数据库", "论坛", "知识库", "计划")
|
||||||
|
|
||||||
|
if any(marker in normalized for marker in multi_step_markers):
|
||||||
|
score += 0.35
|
||||||
|
reasons.append("multi_step_request")
|
||||||
|
|
||||||
|
if sum(1 for marker in artifact_markers if marker in normalized) >= 2:
|
||||||
|
score += 0.25
|
||||||
|
reasons.append("multi_source_context")
|
||||||
|
|
||||||
|
if len(re.findall(r"[,,、;;]", query_text or "")) >= 2:
|
||||||
|
score += 0.15
|
||||||
|
reasons.append("compound_instruction")
|
||||||
|
|
||||||
|
if retrospective_count > 0:
|
||||||
|
score += 0.1
|
||||||
|
reasons.append("historical_support")
|
||||||
|
|
||||||
|
if skill_count > 0:
|
||||||
|
score += 0.1
|
||||||
|
reasons.append("skill_candidates_available")
|
||||||
|
|
||||||
|
score = min(score, 1.0)
|
||||||
|
should_parallelize = score >= 0.55
|
||||||
|
preferred_mode: ParallelPreference = "parallel" if should_parallelize else "direct"
|
||||||
|
if not should_parallelize and score >= 0.3:
|
||||||
|
preferred_mode = "collaboration"
|
||||||
|
|
||||||
|
estimated_subtasks = 1
|
||||||
|
if preferred_mode == "parallel":
|
||||||
|
estimated_subtasks = 3 if score >= 0.8 else 2
|
||||||
|
elif preferred_mode == "collaboration":
|
||||||
|
estimated_subtasks = 2
|
||||||
|
|
||||||
|
return ParallelWorthiness(
|
||||||
|
should_parallelize=should_parallelize,
|
||||||
|
score=round(score, 3),
|
||||||
|
estimated_subtasks=estimated_subtasks,
|
||||||
|
preferred_mode=preferred_mode,
|
||||||
|
reasons=reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_runtime_request_context_summary(context: RuntimeRequestContext) -> str:
|
||||||
|
lines = ["【Runtime Request Context】"]
|
||||||
|
lines.append(f"- 推荐运行模式: {context.recommended_runtime_mode}")
|
||||||
|
lines.append(
|
||||||
|
f"- 并行潜力: score={context.parallel_worthiness.score:.2f}, "
|
||||||
|
f"preferred={context.parallel_worthiness.preferred_mode}, "
|
||||||
|
f"estimated_subtasks={context.parallel_worthiness.estimated_subtasks}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if context.parallel_worthiness.reasons:
|
||||||
|
lines.append(f"- 并行判断依据: {', '.join(context.parallel_worthiness.reasons)}")
|
||||||
|
if context.assembly_metrics:
|
||||||
|
total_ms = context.assembly_metrics.get("total_ms")
|
||||||
|
if total_ms is not None:
|
||||||
|
lines.append(f"- 上下文装配耗时: {total_ms:.1f} ms")
|
||||||
|
|
||||||
|
if context.task_graph and context.task_graph.nodes:
|
||||||
|
lines.append(
|
||||||
|
f"- 任务图: nodes={len(context.task_graph.nodes)}, max_parallelism={context.task_graph.max_parallelism}"
|
||||||
|
)
|
||||||
|
for node in context.task_graph.nodes[:4]:
|
||||||
|
deps = f", deps={len(node.depends_on)}" if node.depends_on else ""
|
||||||
|
lines.append(f" - [{node.execution_mode}] {node.title} ({node.role}{deps})")
|
||||||
|
|
||||||
|
if context.retrospective_shortlist:
|
||||||
|
lines.append("- 历史复盘命中:")
|
||||||
|
for item in context.retrospective_shortlist[:3]:
|
||||||
|
summary = (item.get("summary") or item.get("summary_text") or "").strip()
|
||||||
|
task_type = item.get("task_type") or "unknown"
|
||||||
|
lines.append(f" - [{task_type}] {summary[:160]}")
|
||||||
|
|
||||||
|
if context.skill_shortlist:
|
||||||
|
lines.append("- 技能候选:")
|
||||||
|
for item in context.skill_shortlist[:3]:
|
||||||
|
lines.append(
|
||||||
|
f" - {item.skill_name} ({item.injection_mode}, score={item.score:.2f})"
|
||||||
|
+ (f": {item.rationale}" if item.rationale else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
if context.recalled_memories:
|
||||||
|
lines.append("- 记忆上下文已装配,可在回答中按需引用。")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
38
backend/app/agents/schemas/skills.py
Normal file
38
backend/app/agents/schemas/skills.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
SkillStatus = Literal["candidate", "shadow", "active", "deprecated", "retired"]
|
||||||
|
SkillInjectionMode = Literal["metadata_only", "summary", "full"]
|
||||||
|
|
||||||
|
|
||||||
|
class SkillShortlistEntry(BaseModel):
|
||||||
|
skill_name: str
|
||||||
|
source: str = "runtime"
|
||||||
|
source_id: str | None = None
|
||||||
|
status: SkillStatus = "active"
|
||||||
|
scope: list[str] = Field(default_factory=list)
|
||||||
|
effectiveness: float | None = None
|
||||||
|
score: float = 0.0
|
||||||
|
rationale: str | None = None
|
||||||
|
summary: str | None = None
|
||||||
|
matched_terms: list[str] = Field(default_factory=list)
|
||||||
|
injection_mode: SkillInjectionMode = "metadata_only"
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillActivationRecord(BaseModel):
|
||||||
|
skill_name: str
|
||||||
|
source: str = "runtime"
|
||||||
|
source_id: str | None = None
|
||||||
|
status: SkillStatus = "active"
|
||||||
|
injection_mode: SkillInjectionMode = "metadata_only"
|
||||||
|
matched_terms: list[str] = Field(default_factory=list)
|
||||||
|
rationale: str | None = None
|
||||||
|
activated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
outcome: str | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -13,6 +15,18 @@ InterruptStatus = Literal["requested", "acknowledged", "resolved"]
|
|||||||
BudgetMode = Literal["direct", "collaboration"]
|
BudgetMode = Literal["direct", "collaboration"]
|
||||||
|
|
||||||
|
|
||||||
|
class CodeProviderType(str, Enum):
|
||||||
|
CLAUDE = "claude"
|
||||||
|
GEMINI = "gemini"
|
||||||
|
CODEX = "codex"
|
||||||
|
OPENCODE = "opencode"
|
||||||
|
|
||||||
|
|
||||||
|
class RiskLevelType(str, Enum):
|
||||||
|
LOW = "low"
|
||||||
|
HIGH = "high"
|
||||||
|
|
||||||
|
|
||||||
class InterruptRecord(BaseModel):
|
class InterruptRecord(BaseModel):
|
||||||
interrupt_id: str
|
interrupt_id: str
|
||||||
reason: str
|
reason: str
|
||||||
@@ -83,3 +97,37 @@ class TaskResult(BaseModel):
|
|||||||
budget_snapshot: CollaborationBudget | dict[str, Any] | None = None
|
budget_snapshot: CollaborationBudget | dict[str, Any] | None = None
|
||||||
next_action: str | None = None
|
next_action: str | None = None
|
||||||
output_data: dict[str, Any] | None = None
|
output_data: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CodeTaskType(str, Enum):
|
||||||
|
DEMO = "demo"
|
||||||
|
PROJECT = "project"
|
||||||
|
MODIFICATION = "modification"
|
||||||
|
|
||||||
|
|
||||||
|
class CodeTask(BaseModel):
|
||||||
|
"""代码任务请求模型"""
|
||||||
|
|
||||||
|
task_id: str = Field(default_factory=lambda: str(uuid4()))
|
||||||
|
task_type: CodeTaskType
|
||||||
|
ai_provider: CodeProviderType
|
||||||
|
sandbox_mode: bool = False
|
||||||
|
workspace_path: str | None = None
|
||||||
|
user_prompt: str
|
||||||
|
parent_task_id: str | None = None
|
||||||
|
thread_id: str | None = None
|
||||||
|
message_id: str | None = None
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class CodeExecutionResultSchema(BaseModel):
|
||||||
|
"""代码执行结果模型 (API 响应用)"""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
files_created: list[str] = Field(default_factory=list)
|
||||||
|
output: str = ""
|
||||||
|
error: str | None = None
|
||||||
|
exit_code: int = 0
|
||||||
|
execution_time: float | None = None
|
||||||
|
sandbox_session_id: str | None = None
|
||||||
|
|||||||
@@ -1,16 +1 @@
|
|||||||
"""Skills 注册表 - Phase 9"""
|
"""Skill package."""
|
||||||
|
|
||||||
from app.agents.skills.registry import SkillRegistry, get_skill_registry
|
|
||||||
from app.agents.skills.metadata import SkillMetadata
|
|
||||||
from app.agents.skills.loaders.local_loader import LocalSkillLoader
|
|
||||||
from app.agents.skills.loaders.plugin_loader import PluginSkillLoader
|
|
||||||
from app.agents.skills.mcp_builder import MCPSkillBuilder
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"SkillRegistry",
|
|
||||||
"SkillMetadata",
|
|
||||||
"LocalSkillLoader",
|
|
||||||
"PluginSkillLoader",
|
|
||||||
"MCPSkillBuilder",
|
|
||||||
"get_skill_registry",
|
|
||||||
]
|
|
||||||
|
|||||||
14
backend/app/agents/skills/effectiveness.py
Normal file
14
backend/app/agents/skills/effectiveness.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.models.skill import Skill
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_skill_effectiveness(skill: Skill) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"name": skill.name,
|
||||||
|
"status": skill.status,
|
||||||
|
"effectiveness": skill.effectiveness,
|
||||||
|
"activation_count": skill.activation_count,
|
||||||
|
"candidate_count": getattr(skill, "candidate_count", 0),
|
||||||
|
"last_activated_at": skill.last_activated_at.isoformat() if skill.last_activated_at else None,
|
||||||
|
}
|
||||||
58
backend/app/agents/skills/evaluator.py
Normal file
58
backend/app/agents/skills/evaluator.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from app.agents.schemas.learning import SessionRetrospective, SkillCandidate
|
||||||
|
from app.agents.skills.models import SkillLifecycleDecision
|
||||||
|
from app.services.skill_service import SkillService
|
||||||
|
|
||||||
|
|
||||||
|
class SkillPromotionEvaluator:
|
||||||
|
def __init__(self, db):
|
||||||
|
self.db = db
|
||||||
|
self.skill_service = SkillService(db)
|
||||||
|
|
||||||
|
async def sync_retrospective(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
retrospective: SessionRetrospective,
|
||||||
|
) -> list[SkillLifecycleDecision]:
|
||||||
|
decisions: list[SkillLifecycleDecision] = []
|
||||||
|
|
||||||
|
for candidate in retrospective.skill_candidates:
|
||||||
|
decisions.append(
|
||||||
|
await self.skill_service.upsert_learned_candidate(
|
||||||
|
user_id=user_id,
|
||||||
|
candidate=candidate,
|
||||||
|
primary_agent=retrospective.primary_agent,
|
||||||
|
evidence_refs=candidate.evidence_refs,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
outcome_score = self._derive_outcome_score(retrospective)
|
||||||
|
for skill_name in retrospective.used_skill_names:
|
||||||
|
decision = await self.skill_service.record_activation_feedback(
|
||||||
|
user_id=user_id,
|
||||||
|
skill_name=skill_name,
|
||||||
|
outcome_score=outcome_score,
|
||||||
|
evidence_refs=retrospective.evidence_refs,
|
||||||
|
)
|
||||||
|
if decision is not None:
|
||||||
|
decisions.append(decision)
|
||||||
|
|
||||||
|
return decisions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _derive_outcome_score(retrospective: SessionRetrospective) -> float:
|
||||||
|
if retrospective.verification_status == "passed":
|
||||||
|
return 0.9
|
||||||
|
if retrospective.verification_status == "skipped":
|
||||||
|
return 0.55
|
||||||
|
if retrospective.verification_status == "failed":
|
||||||
|
return 0.15
|
||||||
|
return 0.7 if retrospective.outcome == "completed" else 0.2
|
||||||
|
|
||||||
|
|
||||||
|
def next_review_after(days: int = 7) -> datetime:
|
||||||
|
return datetime.now(UTC) + timedelta(days=days)
|
||||||
32
backend/app/agents/skills/matcher.py
Normal file
32
backend/app/agents/skills/matcher.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def extract_match_terms(text: str | None) -> list[str]:
|
||||||
|
source = (text or "").lower()
|
||||||
|
terms = [token for token in re.findall(r"[a-z0-9_]+", source) if len(token) >= 3]
|
||||||
|
|
||||||
|
for chunk in re.findall(r"[\u4e00-\u9fff]+", text or ""):
|
||||||
|
if len(chunk) >= 2:
|
||||||
|
terms.append(chunk)
|
||||||
|
if len(chunk) > 4:
|
||||||
|
for index in range(len(chunk) - 1):
|
||||||
|
terms.append(chunk[index : index + 2])
|
||||||
|
|
||||||
|
return list(dict.fromkeys(terms))
|
||||||
|
|
||||||
|
|
||||||
|
def score_text_match(query_text: str, *corpus_parts: str | None) -> tuple[float, list[str]]:
|
||||||
|
query_terms = extract_match_terms(query_text)
|
||||||
|
if not query_terms:
|
||||||
|
return 0.0, []
|
||||||
|
|
||||||
|
corpus = " ".join(part for part in corpus_parts if part).lower()
|
||||||
|
matched_terms = [term for term in query_terms if term and term in corpus]
|
||||||
|
if not matched_terms:
|
||||||
|
return 0.0, []
|
||||||
|
|
||||||
|
coverage = len(matched_terms) / max(len(query_terms), 1)
|
||||||
|
density = min(len(matched_terms), 4) / 4
|
||||||
|
return round(min(1.0, coverage * 0.7 + density * 0.3), 3), matched_terms
|
||||||
@@ -20,6 +20,10 @@ class SkillMetadata:
|
|||||||
source_id: str = "" # 来源 ID
|
source_id: str = "" # 来源 ID
|
||||||
enabled: bool = True # 是否启用
|
enabled: bool = True # 是否启用
|
||||||
tools: list[str] = field(default_factory=list) # 关联的工具
|
tools: list[str] = field(default_factory=list) # 关联的工具
|
||||||
|
status: str = "active" # candidate/shadow/active/deprecated/retired
|
||||||
|
scope: list[str] = field(default_factory=list)
|
||||||
|
effectiveness: float | None = None
|
||||||
|
review_after: str | None = None
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -35,6 +39,10 @@ class SkillMetadata:
|
|||||||
"source_id": self.source_id,
|
"source_id": self.source_id,
|
||||||
"enabled": self.enabled,
|
"enabled": self.enabled,
|
||||||
"tools": self.tools,
|
"tools": self.tools,
|
||||||
|
"status": self.status,
|
||||||
|
"scope": self.scope,
|
||||||
|
"effectiveness": self.effectiveness,
|
||||||
|
"review_after": self.review_after,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
29
backend/app/agents/skills/models.py
Normal file
29
backend/app/agents/skills/models.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
SkillLifecycleAction = Literal[
|
||||||
|
"created_candidate",
|
||||||
|
"promoted_to_shadow",
|
||||||
|
"promoted_to_active",
|
||||||
|
"degraded_to_deprecated",
|
||||||
|
"retired",
|
||||||
|
"reactivated",
|
||||||
|
"feedback_recorded",
|
||||||
|
"no_change",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SkillLifecycleDecision(BaseModel):
|
||||||
|
skill_name: str
|
||||||
|
action: SkillLifecycleAction
|
||||||
|
previous_status: str | None = None
|
||||||
|
new_status: str
|
||||||
|
reason: str
|
||||||
|
evidence_refs: list[dict[str, object]] = Field(default_factory=list)
|
||||||
|
confidence: float | None = None
|
||||||
|
review_after: datetime | None = None
|
||||||
27
backend/app/agents/skills/policy.py
Normal file
27
backend/app/agents/skills/policy.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.schemas.skills import SkillInjectionMode, SkillShortlistEntry
|
||||||
|
|
||||||
|
MAX_SUMMARY_CHARS = 120
|
||||||
|
|
||||||
|
|
||||||
|
def choose_injection_mode(score: float, summary_available: bool) -> SkillInjectionMode:
|
||||||
|
if score >= 0.75 and summary_available:
|
||||||
|
return "summary"
|
||||||
|
return "metadata_only"
|
||||||
|
|
||||||
|
|
||||||
|
def render_skill_shortlist_context(entries: list[SkillShortlistEntry]) -> str:
|
||||||
|
if not entries:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lines = ["[Task-Scoped Skills]"]
|
||||||
|
for entry in entries[:3]:
|
||||||
|
detail = entry.summary or "Relevant to the current request."
|
||||||
|
detail = detail[:MAX_SUMMARY_CHARS]
|
||||||
|
lines.append(f"- {entry.skill_name} | mode={entry.injection_mode} | score={entry.score:.2f}")
|
||||||
|
lines.append(f" {detail}")
|
||||||
|
if entry.matched_terms:
|
||||||
|
lines.append(f" matched_terms={', '.join(entry.matched_terms[:6])}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
153
backend/app/agents/skills/retriever.py
Normal file
153
backend/app/agents/skills/retriever.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
from app.agents.schemas.skills import SkillShortlistEntry
|
||||||
|
from app.agents.skills.matcher import score_text_match
|
||||||
|
from app.agents.skills.policy import choose_injection_mode, render_skill_shortlist_context
|
||||||
|
from app.agents.skills.registry import get_skill_registry
|
||||||
|
from app.services.skill_service import SkillService
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeSkillRetriever:
|
||||||
|
def __init__(self, db):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def shortlist(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
query_text: str,
|
||||||
|
memory_context: str | None = None,
|
||||||
|
retrospectives: list[dict] | None = None,
|
||||||
|
include_learned: bool = True,
|
||||||
|
limit: int = 3,
|
||||||
|
) -> list[SkillShortlistEntry]:
|
||||||
|
deduped: "OrderedDict[str, SkillShortlistEntry]" = OrderedDict()
|
||||||
|
retrospective_text = "\n".join(
|
||||||
|
(item.get("summary") or item.get("summary_text") or "")
|
||||||
|
for item in (retrospectives or [])
|
||||||
|
if isinstance(item, dict)
|
||||||
|
)
|
||||||
|
|
||||||
|
service = SkillService(self.db)
|
||||||
|
for skill in await service.list_runtime_candidates(user_id, include_learned=include_learned):
|
||||||
|
score, matched_terms = score_text_match(
|
||||||
|
query_text,
|
||||||
|
skill.name,
|
||||||
|
skill.description,
|
||||||
|
skill.instructions,
|
||||||
|
retrospective_text,
|
||||||
|
memory_context,
|
||||||
|
)
|
||||||
|
if score <= 0:
|
||||||
|
continue
|
||||||
|
entry = SkillShortlistEntry(
|
||||||
|
skill_name=skill.name,
|
||||||
|
source="database",
|
||||||
|
source_id=skill.id,
|
||||||
|
scope=[skill.agent_type, skill.visibility],
|
||||||
|
status=skill.status,
|
||||||
|
effectiveness=skill.effectiveness,
|
||||||
|
score=score,
|
||||||
|
matched_terms=matched_terms,
|
||||||
|
rationale=(
|
||||||
|
"Shadow skill matched current request; keep metadata-only injection."
|
||||||
|
if skill.status == "shadow"
|
||||||
|
else "Matched against DB skill metadata and instructions."
|
||||||
|
),
|
||||||
|
summary=skill.description or (skill.instructions[:160] if skill.instructions else None),
|
||||||
|
injection_mode=(
|
||||||
|
"metadata_only"
|
||||||
|
if skill.status == "shadow"
|
||||||
|
else choose_injection_mode(score, bool(skill.description or skill.instructions))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._upsert(deduped, entry)
|
||||||
|
|
||||||
|
registry = get_skill_registry()
|
||||||
|
if not registry.list_all():
|
||||||
|
try:
|
||||||
|
registry.load_all()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for skill in registry.list_all():
|
||||||
|
score, matched_terms = score_text_match(
|
||||||
|
query_text,
|
||||||
|
skill.name,
|
||||||
|
skill.description,
|
||||||
|
" ".join(skill.tags),
|
||||||
|
" ".join(skill.triggers),
|
||||||
|
skill.content[:400],
|
||||||
|
retrospective_text,
|
||||||
|
memory_context,
|
||||||
|
)
|
||||||
|
if score <= 0:
|
||||||
|
continue
|
||||||
|
entry = SkillShortlistEntry(
|
||||||
|
skill_name=skill.name,
|
||||||
|
source=skill.source,
|
||||||
|
source_id=skill.source_id or skill.id,
|
||||||
|
scope=skill.scope or list(skill.tags),
|
||||||
|
status=skill.status,
|
||||||
|
effectiveness=skill.effectiveness,
|
||||||
|
score=score,
|
||||||
|
matched_terms=matched_terms,
|
||||||
|
rationale="Matched against local or external skill metadata.",
|
||||||
|
summary=skill.description or skill.content[:160],
|
||||||
|
injection_mode=choose_injection_mode(
|
||||||
|
score,
|
||||||
|
bool(skill.description or skill.content),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._upsert(deduped, entry)
|
||||||
|
|
||||||
|
return sorted(deduped.values(), key=lambda item: item.score, reverse=True)[:limit]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _upsert(
|
||||||
|
deduped: "OrderedDict[str, SkillShortlistEntry]",
|
||||||
|
entry: SkillShortlistEntry,
|
||||||
|
) -> None:
|
||||||
|
existing = deduped.get(entry.skill_name)
|
||||||
|
if existing is None or existing.score < entry.score:
|
||||||
|
deduped[entry.skill_name] = entry
|
||||||
|
|
||||||
|
|
||||||
|
def build_shortlisted_skill_context(
|
||||||
|
shortlist: list[dict] | list[SkillShortlistEntry] | None,
|
||||||
|
*,
|
||||||
|
agent_type: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
if not shortlist:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
entries: list[SkillShortlistEntry] = []
|
||||||
|
for item in shortlist:
|
||||||
|
entry = item if isinstance(item, SkillShortlistEntry) else SkillShortlistEntry.model_validate(item)
|
||||||
|
if agent_type and entry.scope and agent_type not in entry.scope:
|
||||||
|
continue
|
||||||
|
entries.append(entry)
|
||||||
|
|
||||||
|
return render_skill_shortlist_context(entries)
|
||||||
|
|
||||||
|
|
||||||
|
async def shortlist_skills_for_request(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
user_query: str,
|
||||||
|
memory_context: str | None = None,
|
||||||
|
retrospectives: list[dict] | None = None,
|
||||||
|
include_learned: bool = True,
|
||||||
|
limit: int = 3,
|
||||||
|
) -> list[SkillShortlistEntry]:
|
||||||
|
return await RuntimeSkillRetriever(db).shortlist(
|
||||||
|
user_id=user_id,
|
||||||
|
query_text=user_query,
|
||||||
|
memory_context=memory_context,
|
||||||
|
retrospectives=retrospectives,
|
||||||
|
include_learned=include_learned,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
@@ -4,7 +4,14 @@ from typing import Annotated, Any, Literal, TypedDict
|
|||||||
|
|
||||||
from app.agents.schemas.event import AgentEvent
|
from app.agents.schemas.event import AgentEvent
|
||||||
from app.agents.schemas.message import AgentMessage
|
from app.agents.schemas.message import AgentMessage
|
||||||
from app.agents.schemas.task import AgentTask, CollaborationBudget, InterruptRecord, RecoveryRecord, TaskResult, VerificationStatus
|
from app.agents.schemas.task import (
|
||||||
|
AgentTask,
|
||||||
|
CollaborationBudget,
|
||||||
|
InterruptRecord,
|
||||||
|
RecoveryRecord,
|
||||||
|
TaskResult,
|
||||||
|
VerificationStatus,
|
||||||
|
)
|
||||||
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
||||||
from langgraph.graph.message import add_messages
|
from langgraph.graph.message import add_messages
|
||||||
|
|
||||||
@@ -23,6 +30,7 @@ class AgentRole(str, Enum):
|
|||||||
EXECUTOR = "executor"
|
EXECUTOR = "executor"
|
||||||
LIBRARIAN = "librarian"
|
LIBRARIAN = "librarian"
|
||||||
ANALYST = "analyst"
|
ANALYST = "analyst"
|
||||||
|
CODE_COMMANDER = "code_commander"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -130,6 +138,18 @@ class AgentState(TypedDict):
|
|||||||
memory_context: str | None
|
memory_context: str | None
|
||||||
current_datetime_context: str | None
|
current_datetime_context: str | None
|
||||||
current_datetime_reference: dict[str, str] | None
|
current_datetime_reference: dict[str, str] | None
|
||||||
|
runtime_request_context: dict[str, Any] | None
|
||||||
|
task_graph: dict[str, Any] | None
|
||||||
|
scheduled_subtasks: list[dict[str, Any]]
|
||||||
|
recalled_retrospectives: list[dict[str, Any]]
|
||||||
|
retrospective_shortlist: list[dict[str, Any]]
|
||||||
|
skill_shortlist: list[dict[str, Any]]
|
||||||
|
skill_activation_records: list[dict[str, Any]]
|
||||||
|
execution_decision: dict[str, Any] | None
|
||||||
|
merge_report: dict[str, Any] | None
|
||||||
|
verification_report: dict[str, Any] | None
|
||||||
|
feature_flags: dict[str, bool]
|
||||||
|
observability_report: dict[str, Any] | None
|
||||||
|
|
||||||
turn_context: dict[str, Any] | None
|
turn_context: dict[str, Any] | None
|
||||||
routing_decision: dict[str, Any] | None
|
routing_decision: dict[str, Any] | None
|
||||||
@@ -141,6 +161,14 @@ class AgentState(TypedDict):
|
|||||||
user_llm_config: dict[str, Any] | None
|
user_llm_config: dict[str, Any] | None
|
||||||
provider_capabilities: dict[str, Any] | None
|
provider_capabilities: dict[str, Any] | None
|
||||||
|
|
||||||
|
# Code Commander state
|
||||||
|
code_task_type: Literal["demo", "project", "modification"] | None
|
||||||
|
code_ai_provider: Literal["claude", "gemini", "codex", "opencode"] | None
|
||||||
|
code_sandbox_mode: bool | None
|
||||||
|
code_workspace_path: str | None
|
||||||
|
code_execution_session_id: str | None
|
||||||
|
code_execution_result: dict[str, Any] | None
|
||||||
|
|
||||||
|
|
||||||
def initial_state(user_id: str, conversation_id: str) -> AgentState:
|
def initial_state(user_id: str, conversation_id: str) -> AgentState:
|
||||||
return AgentState(
|
return AgentState(
|
||||||
@@ -238,6 +266,18 @@ def initial_state(user_id: str, conversation_id: str) -> AgentState:
|
|||||||
memory_context=None,
|
memory_context=None,
|
||||||
current_datetime_context=None,
|
current_datetime_context=None,
|
||||||
current_datetime_reference=None,
|
current_datetime_reference=None,
|
||||||
|
runtime_request_context=None,
|
||||||
|
task_graph=None,
|
||||||
|
scheduled_subtasks=[],
|
||||||
|
recalled_retrospectives=[],
|
||||||
|
retrospective_shortlist=[],
|
||||||
|
skill_shortlist=[],
|
||||||
|
skill_activation_records=[],
|
||||||
|
execution_decision=None,
|
||||||
|
merge_report=None,
|
||||||
|
verification_report=None,
|
||||||
|
feature_flags={},
|
||||||
|
observability_report=None,
|
||||||
turn_context=None,
|
turn_context=None,
|
||||||
routing_decision=None,
|
routing_decision=None,
|
||||||
continuity_state=None,
|
continuity_state=None,
|
||||||
|
|||||||
@@ -138,3 +138,12 @@ SUB_COMMANDER_TOOLSETS = {
|
|||||||
"analyst_progress": ANALYST_PROGRESS_TOOLS,
|
"analyst_progress": ANALYST_PROGRESS_TOOLS,
|
||||||
"analyst_insights": ANALYST_INSIGHT_TOOLS,
|
"analyst_insights": ANALYST_INSIGHT_TOOLS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Code Commander toolset (tools implemented in later phases)
|
||||||
|
CODE_COMMANDER_TOOLSET_NAMES = [
|
||||||
|
"execute_code_task",
|
||||||
|
"get_execution_status",
|
||||||
|
"send_interactive_input",
|
||||||
|
"download_workspace",
|
||||||
|
"cleanup_workspace",
|
||||||
|
]
|
||||||
|
|||||||
196
backend/app/agents/tools/ai_adapter.py
Normal file
196
backend/app/agents/tools/ai_adapter.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"""
|
||||||
|
AI CLI Adapter - 统一接口适配不同 AI CLI (Claude/Gemini/Codex/OpenCode)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CodeExecutionResult:
|
||||||
|
"""代码执行结果"""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
files_created: list[str] = field(default_factory=list)
|
||||||
|
output: str = ""
|
||||||
|
error: str | None = None
|
||||||
|
exit_code: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class AICLIAdapter(ABC):
|
||||||
|
"""AI CLI 适配器抽象基类"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def cli_name(self) -> str:
|
||||||
|
"""CLI 命令名称,如 'claude', 'gemini'"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def requires_workspace(self) -> bool:
|
||||||
|
"""是否需要工作目录"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def provider(self) -> Literal["claude", "gemini", "codex", "opencode"]:
|
||||||
|
"""AI 提供商标识"""
|
||||||
|
return self.cli_name
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def build_command(self, prompt: str, workspace: Path | None) -> list[str]:
|
||||||
|
"""构建 CLI 命令"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def parse_output(self, output: str) -> CodeExecutionResult:
|
||||||
|
"""解析 CLI 输出"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def is_installed(self) -> bool:
|
||||||
|
"""检查 CLI 是否已安装"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ClaudeAdapter(AICLIAdapter):
|
||||||
|
"""Claude CLI 适配器"""
|
||||||
|
|
||||||
|
cli_name = "claude"
|
||||||
|
requires_workspace = True
|
||||||
|
|
||||||
|
def build_command(self, prompt: str, workspace: Path | None) -> list[str]:
|
||||||
|
cmd = ["claude", "-p", prompt]
|
||||||
|
if workspace:
|
||||||
|
cmd.extend(["--output-format", "stream-json"])
|
||||||
|
cmd.append("--dangerously-skip-permissions")
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def parse_output(self, output: str) -> CodeExecutionResult:
|
||||||
|
# Claude CLI 输出可能是纯文本或 JSON
|
||||||
|
# 简化处理:直接返回输出
|
||||||
|
if not output.strip():
|
||||||
|
return CodeExecutionResult(
|
||||||
|
success=False,
|
||||||
|
message="No output from Claude CLI",
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
return CodeExecutionResult(
|
||||||
|
success=True,
|
||||||
|
message="Execution completed",
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_installed(self) -> bool:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
return shutil.which("claude") is not None
|
||||||
|
|
||||||
|
|
||||||
|
class GeminiAdapter(AICLIAdapter):
|
||||||
|
"""Gemini CLI 适配器"""
|
||||||
|
|
||||||
|
cli_name = "gemini"
|
||||||
|
requires_workspace = False
|
||||||
|
|
||||||
|
def build_command(self, prompt: str, workspace: Path | None) -> list[str]:
|
||||||
|
cmd = ["gemini", "-p", prompt]
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def parse_output(self, output: str) -> CodeExecutionResult:
|
||||||
|
if not output.strip():
|
||||||
|
return CodeExecutionResult(
|
||||||
|
success=False,
|
||||||
|
message="No output from Gemini CLI",
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
return CodeExecutionResult(
|
||||||
|
success=True,
|
||||||
|
message="Execution completed",
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_installed(self) -> bool:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
return shutil.which("gemini") is not None
|
||||||
|
|
||||||
|
|
||||||
|
class CodexAdapter(AICLIAdapter):
|
||||||
|
"""Codex CLI 适配器"""
|
||||||
|
|
||||||
|
cli_name = "codex"
|
||||||
|
requires_workspace = True
|
||||||
|
|
||||||
|
def build_command(self, prompt: str, workspace: Path | None) -> list[str]:
|
||||||
|
cmd = ["codex", "-p", prompt]
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def parse_output(self, output: str) -> CodeExecutionResult:
|
||||||
|
if not output.strip():
|
||||||
|
return CodeExecutionResult(
|
||||||
|
success=False,
|
||||||
|
message="No output from Codex CLI",
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
return CodeExecutionResult(
|
||||||
|
success=True,
|
||||||
|
message="Execution completed",
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_installed(self) -> bool:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
return shutil.which("codex") is not None
|
||||||
|
|
||||||
|
|
||||||
|
class OpenCodeAdapter(AICLIAdapter):
|
||||||
|
"""OpenCode CLI 适配器"""
|
||||||
|
|
||||||
|
cli_name = "opencode"
|
||||||
|
requires_workspace = True
|
||||||
|
|
||||||
|
def build_command(self, prompt: str, workspace: Path | None) -> list[str]:
|
||||||
|
cmd = ["opencode", "-p", prompt]
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def parse_output(self, output: str) -> CodeExecutionResult:
|
||||||
|
if not output.strip():
|
||||||
|
return CodeExecutionResult(
|
||||||
|
success=False,
|
||||||
|
message="No output from OpenCode CLI",
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
return CodeExecutionResult(
|
||||||
|
success=True,
|
||||||
|
message="Execution completed",
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_installed(self) -> bool:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
return shutil.which("opencode") is not None
|
||||||
|
|
||||||
|
|
||||||
|
# 提供商注册表
|
||||||
|
ADAPTER_REGISTRY: dict[str, AICLIAdapter] = {
|
||||||
|
"claude": ClaudeAdapter(),
|
||||||
|
"gemini": GeminiAdapter(),
|
||||||
|
"codex": CodexAdapter(),
|
||||||
|
"opencode": OpenCodeAdapter(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_adapter(provider: str) -> AICLIAdapter:
|
||||||
|
"""获取指定提供商的适配器"""
|
||||||
|
adapter = ADAPTER_REGISTRY.get(provider.lower())
|
||||||
|
if adapter is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown AI provider: {provider}. Available: {list(ADAPTER_REGISTRY.keys())}"
|
||||||
|
)
|
||||||
|
return adapter
|
||||||
217
backend/app/agents/tools/collaboration.py
Normal file
217
backend/app/agents/tools/collaboration.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
"""
|
||||||
|
Agent Collaboration Protocol
|
||||||
|
|
||||||
|
Inter-agent tool collaboration messaging system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class MessageType(str, Enum):
|
||||||
|
"""Collaboration message types"""
|
||||||
|
|
||||||
|
REQUEST = "request" # Request collaboration
|
||||||
|
RESPONSE = "response" # Response result
|
||||||
|
PROGRESS = "progress" # Progress update
|
||||||
|
CANCEL = "cancel" # Cancel request
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationMessage(BaseModel):
|
||||||
|
"""Collaboration message model"""
|
||||||
|
|
||||||
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
type: MessageType
|
||||||
|
from_agent: str
|
||||||
|
to_agent: str
|
||||||
|
content: Dict[str, Any]
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
def is_request(self) -> bool:
|
||||||
|
return self.type == MessageType.REQUEST
|
||||||
|
|
||||||
|
def is_response(self) -> bool:
|
||||||
|
return self.type == MessageType.RESPONSE
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationProtocol:
|
||||||
|
"""Agent collaboration protocol for inter-agent tool requests"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._pending_requests: Dict[str, CollaborationMessage] = {}
|
||||||
|
self._handlers: Dict[str, Callable] = {}
|
||||||
|
self._response_futures: Dict[str, asyncio.Future] = {}
|
||||||
|
|
||||||
|
def register_handler(self, tool_name: str, handler: Callable) -> None:
|
||||||
|
"""Register a tool handler for collaboration
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: Name of the tool
|
||||||
|
handler: Async callable to handle the tool execution
|
||||||
|
"""
|
||||||
|
self._handlers[tool_name] = handler
|
||||||
|
|
||||||
|
async def request_collaboration(
|
||||||
|
self,
|
||||||
|
from_agent: str,
|
||||||
|
to_agent: str,
|
||||||
|
tool_name: str,
|
||||||
|
parameters: Dict[str, Any],
|
||||||
|
timeout_ms: int = 30000,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Request collaboration from another agent
|
||||||
|
|
||||||
|
Args:
|
||||||
|
from_agent: Source agent name
|
||||||
|
to_agent: Target agent name
|
||||||
|
tool_name: Tool to execute
|
||||||
|
parameters: Tool parameters
|
||||||
|
timeout_ms: Timeout in milliseconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Execution result dict with status and result/error
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
request_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
message = CollaborationMessage(
|
||||||
|
id=request_id,
|
||||||
|
type=MessageType.REQUEST,
|
||||||
|
from_agent=from_agent,
|
||||||
|
to_agent=to_agent,
|
||||||
|
content={
|
||||||
|
"tool": tool_name,
|
||||||
|
"parameters": parameters,
|
||||||
|
},
|
||||||
|
metadata={"timeout": timeout_ms},
|
||||||
|
)
|
||||||
|
|
||||||
|
self._pending_requests[request_id] = message
|
||||||
|
|
||||||
|
# Create future for response
|
||||||
|
future = asyncio.get_event_loop().create_future()
|
||||||
|
self._response_futures[request_id] = future
|
||||||
|
|
||||||
|
# Send the message
|
||||||
|
await self._send_message(message)
|
||||||
|
|
||||||
|
# Wait for response with timeout
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(future, timeout=timeout_ms / 1000)
|
||||||
|
return result
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "Collaboration request timed out",
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
self._pending_requests.pop(request_id, None)
|
||||||
|
self._response_futures.pop(request_id, None)
|
||||||
|
|
||||||
|
async def handle_request(self, message: CollaborationMessage) -> CollaborationMessage:
|
||||||
|
"""Handle an incoming collaboration request
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: The collaboration message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response message with result or error
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
tool_name = message.content.get("tool")
|
||||||
|
parameters = message.content.get("parameters", {})
|
||||||
|
|
||||||
|
handler = self._handlers.get(tool_name)
|
||||||
|
if not handler:
|
||||||
|
return CollaborationMessage(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
type=MessageType.RESPONSE,
|
||||||
|
from_agent=message.to_agent,
|
||||||
|
to_agent=message.from_agent,
|
||||||
|
content={
|
||||||
|
"status": "error",
|
||||||
|
"error": f"Unknown tool: {tool_name}",
|
||||||
|
},
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await handler(**parameters)
|
||||||
|
return CollaborationMessage(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
type=MessageType.RESPONSE,
|
||||||
|
from_agent=message.to_agent,
|
||||||
|
to_agent=message.from_agent,
|
||||||
|
content={"status": "success", "result": result},
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return CollaborationMessage(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
type=MessageType.RESPONSE,
|
||||||
|
from_agent=message.to_agent,
|
||||||
|
to_agent=message.from_agent,
|
||||||
|
content={"status": "error", "error": str(e)},
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_response(self, message: CollaborationMessage) -> None:
|
||||||
|
"""Handle an incoming response message
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: The response message
|
||||||
|
"""
|
||||||
|
request_id = None
|
||||||
|
for req_id, pending in self._pending_requests.items():
|
||||||
|
if pending.id == message.id:
|
||||||
|
request_id = req_id
|
||||||
|
break
|
||||||
|
|
||||||
|
if request_id and request_id in self._response_futures:
|
||||||
|
future = self._response_futures[request_id]
|
||||||
|
if not future.done():
|
||||||
|
future.set_result(message.content)
|
||||||
|
|
||||||
|
async def _send_message(self, message: CollaborationMessage) -> None:
|
||||||
|
"""Send a collaboration message
|
||||||
|
|
||||||
|
This is a placeholder for actual transport implementation.
|
||||||
|
In production, this would use WebSocket, message queue, or shared storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: The message to send
|
||||||
|
"""
|
||||||
|
# TODO: Implement actual message transport
|
||||||
|
# Options: WebSocket, Redis pub/sub, shared database
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_pending_requests(self) -> list:
|
||||||
|
"""Get list of pending requests"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": msg.id,
|
||||||
|
"from": msg.from_agent,
|
||||||
|
"to": msg.to_agent,
|
||||||
|
"tool": msg.content.get("tool"),
|
||||||
|
}
|
||||||
|
for msg in self._pending_requests.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Global collaboration protocol instance
|
||||||
|
_collaboration_protocol: Optional[CollaborationProtocol] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_collaboration_protocol() -> CollaborationProtocol:
|
||||||
|
"""Get the global collaboration protocol instance"""
|
||||||
|
global _collaboration_protocol
|
||||||
|
if _collaboration_protocol is None:
|
||||||
|
_collaboration_protocol = CollaborationProtocol()
|
||||||
|
return _collaboration_protocol
|
||||||
112
backend/app/agents/tools/direct_executor.py
Normal file
112
backend/app/agents/tools/direct_executor.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Direct Executor - 直接执行器
|
||||||
|
用于低风险任务,直接执行不隔离
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
from app.agents.tools.ai_adapter import AICLIAdapter
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionResult:
|
||||||
|
"""执行结果"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
success: bool,
|
||||||
|
exit_code: int,
|
||||||
|
stdout: str,
|
||||||
|
stderr: str,
|
||||||
|
):
|
||||||
|
self.success = success
|
||||||
|
self.exit_code = exit_code
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = stderr
|
||||||
|
|
||||||
|
|
||||||
|
class DirectExecutor:
|
||||||
|
"""直接执行器(用于低风险任务)"""
|
||||||
|
|
||||||
|
def __init__(self, adapter: AICLIAdapter, timeout: int = 60):
|
||||||
|
self.adapter = adapter
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""
|
||||||
|
直接执行,不需要沙盒
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: 任务描述
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
str: 实时输出
|
||||||
|
"""
|
||||||
|
# 1. 检查 CLI 是否安装
|
||||||
|
if not self.adapter.is_installed():
|
||||||
|
yield f"[ERROR] {self.adapter.cli_name} is not installed\n"
|
||||||
|
yield f"[ERROR] Please install {self.adapter.cli_name} first\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. 构建命令
|
||||||
|
cmd = self.adapter.build_command(prompt, None)
|
||||||
|
|
||||||
|
# 3. 异步执行,实时 yield 输出
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env={**os.environ, "TERM": "xterm-256color"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. 实时读取输出
|
||||||
|
stdout_lines = []
|
||||||
|
stderr_lines = []
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
line_bytes = await asyncio.wait_for(
|
||||||
|
process.stdout.readline(),
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
if not line_bytes:
|
||||||
|
break
|
||||||
|
line = line_bytes.decode("utf-8", errors="replace")
|
||||||
|
stdout_lines.append(line)
|
||||||
|
yield line
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
yield f"\n[ERROR] Execution timed out after {self.timeout}s\n"
|
||||||
|
break
|
||||||
|
|
||||||
|
# 5. 读取 stderr
|
||||||
|
stderr_bytes = await process.communicate()
|
||||||
|
if stderr_bytes[1]:
|
||||||
|
stderr = stderr_bytes[1].decode("utf-8", errors="replace")
|
||||||
|
stderr_lines.append(stderr)
|
||||||
|
yield f"\n[STDERR]\n{stderr}\n"
|
||||||
|
|
||||||
|
# 6. 完成标记
|
||||||
|
yield f"\n[EXIT_CODE] {process.returncode or 0}\n"
|
||||||
|
yield f"\n[COMPLETE] success={process.returncode == 0}\n"
|
||||||
|
|
||||||
|
async def execute_sync(self, prompt: str) -> ExecutionResult:
|
||||||
|
"""同步执行并返回完整结果"""
|
||||||
|
output_parts = []
|
||||||
|
async for line in self.execute(prompt):
|
||||||
|
output_parts.append(line)
|
||||||
|
|
||||||
|
output = "".join(output_parts)
|
||||||
|
return ExecutionResult(
|
||||||
|
success="[COMPLETE] success=True" in output,
|
||||||
|
exit_code=0,
|
||||||
|
stdout=output,
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
58
backend/app/agents/tools/interactive_input.py
Normal file
58
backend/app/agents/tools/interactive_input.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""
|
||||||
|
InteractiveInputHandler - 交互输入处理
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.tools.terminal_engine import PTYManager
|
||||||
|
|
||||||
|
|
||||||
|
class InteractiveInputHandler:
|
||||||
|
"""交互输入处理器"""
|
||||||
|
|
||||||
|
def __init__(self, pty_manager: PTYManager):
|
||||||
|
self.pty_manager = pty_manager
|
||||||
|
self._pending_inputs: dict[str, asyncio.Event] = {}
|
||||||
|
self._input_cache: dict[str, str] = {}
|
||||||
|
|
||||||
|
async def wait_for_input(self, session_id: str, prompt: str) -> str:
|
||||||
|
"""等待用户输入(如 "y" 确认)"""
|
||||||
|
event = asyncio.Event()
|
||||||
|
self._pending_inputs[session_id] = event
|
||||||
|
|
||||||
|
# 发送提示
|
||||||
|
from app.routers.terminal import manager
|
||||||
|
|
||||||
|
try:
|
||||||
|
await manager.send(session_id, f"\n{prompt}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 等待输入完成
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(event.wait(), timeout=60.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
del self._pending_inputs[session_id]
|
||||||
|
return self._input_cache.get(session_id, "")
|
||||||
|
|
||||||
|
del self._pending_inputs[session_id]
|
||||||
|
|
||||||
|
return self._input_cache.get(session_id, "")
|
||||||
|
|
||||||
|
async def send_input(self, session_id: str, data: str):
|
||||||
|
"""用户发送输入"""
|
||||||
|
self._input_cache[session_id] = data
|
||||||
|
if session_id in self._pending_inputs:
|
||||||
|
self._pending_inputs[session_id].set()
|
||||||
|
|
||||||
|
# 同时写入 PTY
|
||||||
|
await self.pty_manager.write(session_id, data + "\n")
|
||||||
|
|
||||||
|
def clear_input(self, session_id: str):
|
||||||
|
"""清除输入缓存"""
|
||||||
|
if session_id in self._input_cache:
|
||||||
|
del self._input_cache[session_id]
|
||||||
|
if session_id in self._pending_inputs:
|
||||||
|
self._pending_inputs[session_id].set() # 取消等待
|
||||||
|
del self._pending_inputs[session_id]
|
||||||
173
backend/app/agents/tools/sandbox_executor.py
Normal file
173
backend/app/agents/tools/sandbox_executor.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""
|
||||||
|
Sandbox Executor - 沙盒执行器
|
||||||
|
在高风险任务在隔离的临时目录中执行
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
from app.agents.tools.ai_adapter import AICLIAdapter, CodeExecutionResult
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SandboxEnvironment:
|
||||||
|
"""沙盒环境"""
|
||||||
|
|
||||||
|
workspace_path: Path
|
||||||
|
session_id: str
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def create(prefix: str = "jarvis_code_") -> "SandboxEnvironment":
|
||||||
|
"""创建新的沙盒环境"""
|
||||||
|
temp_dir = tempfile.mkdtemp(prefix=prefix)
|
||||||
|
session_id = Path(temp_dir).name
|
||||||
|
return SandboxEnvironment(
|
||||||
|
workspace_path=Path(temp_dir),
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def cleanup(self) -> None:
|
||||||
|
"""清理沙盒环境"""
|
||||||
|
if self.workspace_path.exists():
|
||||||
|
shutil.rmtree(self.workspace_path)
|
||||||
|
|
||||||
|
def list_created_files(self) -> list[str]:
|
||||||
|
"""列出沙盒中创建的文件"""
|
||||||
|
if not self.workspace_path.exists():
|
||||||
|
return []
|
||||||
|
files = []
|
||||||
|
for root, dirs, filenames in os.walk(self.workspace_path):
|
||||||
|
for filename in filenames:
|
||||||
|
full_path = os.path.join(root, filename)
|
||||||
|
rel_path = os.path.relpath(full_path, self.workspace_path)
|
||||||
|
files.append(rel_path)
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExecutionResult:
|
||||||
|
"""执行结果"""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
exit_code: int
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
files_created: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxExecutor:
|
||||||
|
"""沙盒执行器"""
|
||||||
|
|
||||||
|
def __init__(self, adapter: AICLIAdapter, timeout: int = 300):
|
||||||
|
self.adapter = adapter
|
||||||
|
self.timeout = timeout
|
||||||
|
self._sessions: dict[str, SandboxEnvironment] = {}
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""
|
||||||
|
执行代码任务,yield 实时输出
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: 任务描述
|
||||||
|
session_id: 会话 ID(可选,用于复用沙盒)
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
str: 实时输出
|
||||||
|
"""
|
||||||
|
# 1. 创建或复用沙盒环境
|
||||||
|
if session_id and session_id in self._sessions:
|
||||||
|
env = self._sessions[session_id]
|
||||||
|
else:
|
||||||
|
env = await SandboxEnvironment.create()
|
||||||
|
self._sessions[env.session_id] = env
|
||||||
|
session_id = env.session_id
|
||||||
|
|
||||||
|
# 2. 构建命令
|
||||||
|
cmd = self.adapter.build_command(prompt, env.workspace_path)
|
||||||
|
|
||||||
|
# 3. 异步执行,实时 yield 输出
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
cwd=str(env.workspace_path),
|
||||||
|
env={**os.environ, "TERM": "xterm-256color"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. 实时读取输出
|
||||||
|
stdout_lines = []
|
||||||
|
stderr_lines = []
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
line_bytes = await asyncio.wait_for(
|
||||||
|
process.stdout.readline(),
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
if not line_bytes:
|
||||||
|
break
|
||||||
|
line = line_bytes.decode("utf-8", errors="replace")
|
||||||
|
stdout_lines.append(line)
|
||||||
|
yield line
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
yield f"\n[ERROR] Execution timed out after {self.timeout}s\n"
|
||||||
|
break
|
||||||
|
|
||||||
|
# 5. 读取 stderr
|
||||||
|
stderr_bytes = await process.communicate()
|
||||||
|
if stderr_bytes[1]:
|
||||||
|
stderr = stderr_bytes[1].decode("utf-8", errors="replace")
|
||||||
|
stderr_lines.append(stderr)
|
||||||
|
yield f"\n[STDERR]\n{stderr}\n"
|
||||||
|
|
||||||
|
# 6. 返回结果(通过 yield 完成标记)
|
||||||
|
result = ExecutionResult(
|
||||||
|
success=process.returncode == 0,
|
||||||
|
exit_code=process.returncode or 0,
|
||||||
|
stdout="".join(stdout_lines),
|
||||||
|
stderr="".join(stderr_lines),
|
||||||
|
files_created=env.list_created_files(),
|
||||||
|
)
|
||||||
|
yield f"\n[EXIT_CODE] {result.exit_code}\n"
|
||||||
|
yield f"\n[COMPLETE] success={result.success}\n"
|
||||||
|
|
||||||
|
async def get_result(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> ExecutionResult:
|
||||||
|
"""同步执行并返回完整结果"""
|
||||||
|
output_parts = []
|
||||||
|
async for line in self.execute(prompt, session_id):
|
||||||
|
output_parts.append(line)
|
||||||
|
# 解析最后的结果
|
||||||
|
# 实际使用中可能需要更复杂的结果收集
|
||||||
|
return ExecutionResult(
|
||||||
|
success="[COMPLETE] success=True" in "".join(output_parts),
|
||||||
|
exit_code=0,
|
||||||
|
stdout="".join(output_parts),
|
||||||
|
stderr="",
|
||||||
|
files_created=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def cleanup_session(self, session_id: str) -> bool:
|
||||||
|
"""清理指定会话"""
|
||||||
|
if session_id in self._sessions:
|
||||||
|
await self._sessions[session_id].cleanup()
|
||||||
|
del self._sessions[session_id]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_session(self, session_id: str) -> SandboxEnvironment | None:
|
||||||
|
"""获取会话环境"""
|
||||||
|
return self._sessions.get(session_id)
|
||||||
129
backend/app/agents/tools/security_classifier.py
Normal file
129
backend/app/agents/tools/security_classifier.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""
|
||||||
|
Security Classifier - 安全分级判定
|
||||||
|
低风险任务直接执行,高风险任务沙盒执行
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class RiskLevel(Enum):
|
||||||
|
LOW = "low" # 直接执行
|
||||||
|
HIGH = "high" # 沙盒执行
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityClassifier:
|
||||||
|
"""安全分级器"""
|
||||||
|
|
||||||
|
HIGH_RISK_KEYWORDS = [
|
||||||
|
# 路径/项目操作
|
||||||
|
"修改",
|
||||||
|
"编辑",
|
||||||
|
"删除",
|
||||||
|
"移动",
|
||||||
|
"重命名",
|
||||||
|
"Jarvis",
|
||||||
|
"backend",
|
||||||
|
"frontend",
|
||||||
|
"git",
|
||||||
|
"config",
|
||||||
|
".env",
|
||||||
|
"生产环境",
|
||||||
|
# 文件操作
|
||||||
|
"写入",
|
||||||
|
"创建文件在",
|
||||||
|
"移动到",
|
||||||
|
"提交",
|
||||||
|
"push",
|
||||||
|
"pull",
|
||||||
|
"merge",
|
||||||
|
# 系统操作
|
||||||
|
"sudo",
|
||||||
|
"rm ",
|
||||||
|
"chmod",
|
||||||
|
"chown",
|
||||||
|
]
|
||||||
|
|
||||||
|
LOW_RISK_KEYWORDS = [
|
||||||
|
# demo/示例类
|
||||||
|
"demo",
|
||||||
|
"示例",
|
||||||
|
"贪食蛇",
|
||||||
|
"俄罗斯方块",
|
||||||
|
"小游戏",
|
||||||
|
"独立项目",
|
||||||
|
"新项目",
|
||||||
|
"创建一个",
|
||||||
|
"写一个",
|
||||||
|
"帮我写一个",
|
||||||
|
# 明确无害的请求
|
||||||
|
"生成代码",
|
||||||
|
"代码示例",
|
||||||
|
"练习项目",
|
||||||
|
]
|
||||||
|
|
||||||
|
def classify(self, task_description: str, target_path: str | None = None) -> RiskLevel:
|
||||||
|
"""
|
||||||
|
判断任务风险等级
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_description: 任务描述
|
||||||
|
target_path: 目标路径(如果有)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RiskLevel: LOW 或 HIGH
|
||||||
|
"""
|
||||||
|
# 1. 检查高风险关键词
|
||||||
|
task_lower = task_description.lower()
|
||||||
|
if any(kw.lower() in task_lower for kw in self.HIGH_RISK_KEYWORDS):
|
||||||
|
return RiskLevel.HIGH
|
||||||
|
|
||||||
|
# 2. 检查目标路径
|
||||||
|
if target_path and self._is_project_path(target_path):
|
||||||
|
return RiskLevel.HIGH
|
||||||
|
|
||||||
|
# 3. 检查低风险关键词
|
||||||
|
if any(kw.lower() in task_lower for kw in self.LOW_RISK_KEYWORDS):
|
||||||
|
return RiskLevel.LOW
|
||||||
|
|
||||||
|
# 4. 默认高风险(保守策略)
|
||||||
|
return RiskLevel.HIGH
|
||||||
|
|
||||||
|
def _is_project_path(self, path: str) -> bool:
|
||||||
|
"""
|
||||||
|
检查路径是否指向项目目录
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: 文件路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 如果是项目路径返回 True
|
||||||
|
"""
|
||||||
|
path_lower = path.lower()
|
||||||
|
project_indicators = [
|
||||||
|
"jarvis",
|
||||||
|
"backend/app",
|
||||||
|
"frontend/src",
|
||||||
|
".git",
|
||||||
|
"package.json",
|
||||||
|
"pyproject.toml",
|
||||||
|
"requirements.txt",
|
||||||
|
]
|
||||||
|
return any(indicator in path_lower for indicator in project_indicators)
|
||||||
|
|
||||||
|
def get_risk_factors(
|
||||||
|
self, task_description: str, target_path: str | None = None
|
||||||
|
) -> dict[str, bool]:
|
||||||
|
"""
|
||||||
|
获取详细的风险因素分析
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 各风险因素及其状态
|
||||||
|
"""
|
||||||
|
task_lower = task_description.lower()
|
||||||
|
return {
|
||||||
|
"has_high_risk_keywords": any(
|
||||||
|
kw.lower() in task_lower for kw in self.HIGH_RISK_KEYWORDS
|
||||||
|
),
|
||||||
|
"has_low_risk_keywords": any(kw.lower() in task_lower for kw in self.LOW_RISK_KEYWORDS),
|
||||||
|
"is_project_path": bool(target_path and self._is_project_path(target_path)),
|
||||||
|
}
|
||||||
86
backend/app/agents/tools/stream_output.py
Normal file
86
backend/app/agents/tools/stream_output.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
StreamOutput - 流式输出封装
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, AsyncGenerator, Callable
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StreamEvent:
|
||||||
|
"""流式事件"""
|
||||||
|
|
||||||
|
type: str # "output" | "error" | "status" | "complete"
|
||||||
|
session_id: str
|
||||||
|
data: str
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
class StreamOutput:
|
||||||
|
"""流式输出处理器"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
websocket_sender: Callable[[str, str], Any] | None = None,
|
||||||
|
):
|
||||||
|
self.session_id = session_id
|
||||||
|
self.websocket_sender = websocket_sender
|
||||||
|
self._listeners: list[Callable[[StreamEvent], Any]] = []
|
||||||
|
|
||||||
|
def add_listener(self, listener: Callable[[StreamEvent], Any]):
|
||||||
|
"""添加事件监听器"""
|
||||||
|
self._listeners.append(listener)
|
||||||
|
|
||||||
|
def remove_listener(self, listener: Callable[[StreamEvent], Any]):
|
||||||
|
"""移除事件监听器"""
|
||||||
|
if listener in self._listeners:
|
||||||
|
self._listeners.remove(listener)
|
||||||
|
|
||||||
|
async def push(self, event_type: str, data: str):
|
||||||
|
"""推送事件到 WebSocket 和监听器"""
|
||||||
|
event = StreamEvent(
|
||||||
|
type=event_type,
|
||||||
|
session_id=self.session_id,
|
||||||
|
data=data,
|
||||||
|
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 发送到 WebSocket
|
||||||
|
if self.websocket_sender:
|
||||||
|
try:
|
||||||
|
await self.websocket_sender(self.session_id, json.dumps(event.__dict__))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 通知监听器
|
||||||
|
for listener in self._listeners:
|
||||||
|
try:
|
||||||
|
result = listener(event)
|
||||||
|
if hasattr(result, "__await__"):
|
||||||
|
await result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def stream_execution(
|
||||||
|
self,
|
||||||
|
executor,
|
||||||
|
prompt: str,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""包装执行器,实现流式输出"""
|
||||||
|
async for line in executor.execute(prompt):
|
||||||
|
await self.push("output", line)
|
||||||
|
yield line
|
||||||
|
|
||||||
|
await self.push("complete", "")
|
||||||
|
|
||||||
|
async def push_status(self, status: str):
|
||||||
|
"""推送状态消息"""
|
||||||
|
await self.push("status", status)
|
||||||
|
|
||||||
|
async def push_error(self, error: str):
|
||||||
|
"""推送错误消息"""
|
||||||
|
await self.push("error", error)
|
||||||
160
backend/app/agents/tools/terminal_engine.py
Normal file
160
backend/app/agents/tools/terminal_engine.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"""
|
||||||
|
PTY Terminal Engine - 跨平台 PTY 终端管理
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PTYSession:
|
||||||
|
"""PTY 会话"""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
process: asyncio.subprocess.Process
|
||||||
|
workspace_path: str
|
||||||
|
|
||||||
|
|
||||||
|
class PTYManager:
|
||||||
|
"""PTY 会话管理器"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._sessions: dict[str, PTYSession] = {}
|
||||||
|
self._output_queues: dict[str, asyncio.Queue] = {}
|
||||||
|
|
||||||
|
async def spawn(
|
||||||
|
self,
|
||||||
|
cli: str,
|
||||||
|
args: list[str],
|
||||||
|
cwd: str,
|
||||||
|
session_id: str | None = None,
|
||||||
|
env: dict | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""启动 PTY 会话"""
|
||||||
|
if session_id is None:
|
||||||
|
session_id = f"pty_{uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
# 构建环境变量
|
||||||
|
process_env = {**os.environ, "TERM": "xterm-256color"}
|
||||||
|
if env:
|
||||||
|
process_env.update(env)
|
||||||
|
|
||||||
|
# 创建 PTY 进程
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
cli,
|
||||||
|
*args,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
cwd=cwd,
|
||||||
|
env=process_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
session = PTYSession(
|
||||||
|
session_id=session_id,
|
||||||
|
process=process,
|
||||||
|
workspace_path=cwd,
|
||||||
|
)
|
||||||
|
self._sessions[session_id] = session
|
||||||
|
self._output_queues[session_id] = asyncio.Queue()
|
||||||
|
|
||||||
|
# 启动输出读取协程
|
||||||
|
asyncio.create_task(self._read_output(session_id))
|
||||||
|
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
async def _read_output(self, session_id: str):
|
||||||
|
"""读取 PTY 输出并放入队列"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return
|
||||||
|
|
||||||
|
queue = self._output_queues[session_id]
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
line = await session.process.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
decoded_line = line.decode(errors="replace")
|
||||||
|
await queue.put(decoded_line)
|
||||||
|
|
||||||
|
# 广播到 WebSocket
|
||||||
|
await self._broadcast(session_id, decoded_line)
|
||||||
|
|
||||||
|
# 读取 stderr
|
||||||
|
stderr_line = await session.process.stderr.readline()
|
||||||
|
if stderr_line:
|
||||||
|
decoded_err = stderr_line.decode(errors="replace")
|
||||||
|
await queue.put(decoded_err)
|
||||||
|
await self._broadcast(session_id, decoded_err)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
await queue.put(None) # 结束标记
|
||||||
|
|
||||||
|
async def write(self, session_id: str, data: str):
|
||||||
|
"""写入 PTY(用户输入)"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if session and session.process.stdin:
|
||||||
|
session.process.stdin.write(data)
|
||||||
|
await session.process.stdin.drain()
|
||||||
|
|
||||||
|
async def read(self, session_id: str) -> AsyncGenerator[str, None]:
|
||||||
|
"""读取 PTY 输出"""
|
||||||
|
queue = self._output_queues.get(session_id)
|
||||||
|
if not queue:
|
||||||
|
return
|
||||||
|
|
||||||
|
while True:
|
||||||
|
line = await queue.get()
|
||||||
|
if line is None:
|
||||||
|
break
|
||||||
|
yield line
|
||||||
|
|
||||||
|
async def resize(self, session_id: str, rows: int, cols: int):
|
||||||
|
"""调整终端大小"""
|
||||||
|
# TODO: 实现 resize (需要平台特定实现)
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def kill(self, session_id: str):
|
||||||
|
"""终止 PTY 会话"""
|
||||||
|
if session_id in self._sessions:
|
||||||
|
session = self._sessions[session_id]
|
||||||
|
try:
|
||||||
|
session.process.terminate()
|
||||||
|
await asyncio.wait_for(session.process.wait(), timeout=3.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
session.process.kill()
|
||||||
|
await session.process.wait()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
del self._sessions[session_id]
|
||||||
|
if session_id in self._output_queues:
|
||||||
|
del self._output_queues[session_id]
|
||||||
|
|
||||||
|
async def _broadcast(self, session_id: str, data: str):
|
||||||
|
"""广播输出到 WebSocket"""
|
||||||
|
from app.routers.terminal import manager
|
||||||
|
|
||||||
|
try:
|
||||||
|
await manager.send(session_id, data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_session(self, session_id: str) -> PTYSession | None:
|
||||||
|
"""获取会话"""
|
||||||
|
return self._sessions.get(session_id)
|
||||||
|
|
||||||
|
def list_sessions(self) -> list[str]:
|
||||||
|
"""列出所有会话 ID"""
|
||||||
|
return list(self._sessions.keys())
|
||||||
|
|
||||||
|
|
||||||
|
# 全局单例
|
||||||
|
pty_manager = PTYManager()
|
||||||
@@ -61,6 +61,9 @@ class Settings(BaseSettings):
|
|||||||
DAILY_PLAN_TIME: str = "00:00"
|
DAILY_PLAN_TIME: str = "00:00"
|
||||||
FORUM_SCAN_INTERVAL_MINUTES: int = 30
|
FORUM_SCAN_INTERVAL_MINUTES: int = 30
|
||||||
|
|
||||||
|
# === 位置配置 ===
|
||||||
|
LOCATION: str = "Location"
|
||||||
|
|
||||||
# === CORS ===
|
# === CORS ===
|
||||||
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
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_DEFAULT_LIMIT: int = 5
|
||||||
WEB_SEARCH_TIMEOUT_SECONDS: int = 10
|
WEB_SEARCH_TIMEOUT_SECONDS: int = 10
|
||||||
|
|
||||||
|
# === Hermes 风格升级开关 ===
|
||||||
|
ENABLE_RETROSPECTIVE: bool = True
|
||||||
|
ENABLE_SESSION_RETROSPECTIVE_SEARCH: bool = True
|
||||||
|
ENABLE_RUNTIME_SKILL_SHORTLIST: bool = True
|
||||||
|
ENABLE_LEARNING_SIGNALS: bool = True
|
||||||
|
ENABLE_SKILL_PROMOTION: bool = True
|
||||||
|
ENABLE_LEARNED_SKILL_LOADING: bool = True
|
||||||
|
ENABLE_PARALLEL_TASK_GRAPH: bool = True
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
settings.DATABASE_URL = settings.DATABASE_URL.replace("./data", _resolve_path("./data"), 1)
|
settings.DATABASE_URL = settings.DATABASE_URL.replace("./data", _resolve_path("./data"), 1)
|
||||||
|
|||||||
@@ -35,14 +35,206 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
async def init_db():
|
async def init_db():
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
await ensure_task_columns(conn)
|
||||||
await ensure_log_columns(conn)
|
await ensure_log_columns(conn)
|
||||||
await ensure_message_columns(conn)
|
await ensure_message_columns(conn)
|
||||||
await ensure_conversation_columns(conn)
|
await ensure_conversation_columns(conn)
|
||||||
await ensure_document_columns(conn)
|
await ensure_document_columns(conn)
|
||||||
|
await ensure_memory_columns(conn)
|
||||||
await ensure_user_columns(conn)
|
await ensure_user_columns(conn)
|
||||||
await ensure_forum_columns(conn)
|
await ensure_forum_columns(conn)
|
||||||
await ensure_agent_columns(conn)
|
await ensure_agent_columns(conn)
|
||||||
await ensure_skill_columns(conn)
|
await ensure_skill_columns(conn)
|
||||||
|
await ensure_learning_artifact_tables(conn)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_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):
|
async def ensure_log_columns(conn):
|
||||||
@@ -115,6 +307,28 @@ async def ensure_document_columns(conn):
|
|||||||
await conn.execute(text(ddl))
|
await conn.execute(text(ddl))
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_memory_columns(conn):
|
||||||
|
rows = await _get_table_info(conn, 'user_memories')
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
columns = {row[1] for row in rows}
|
||||||
|
required_columns = {
|
||||||
|
'frequency_count': "ALTER TABLE user_memories ADD COLUMN frequency_count INTEGER DEFAULT 0",
|
||||||
|
'emotion_tags': "ALTER TABLE user_memories ADD COLUMN emotion_tags JSON",
|
||||||
|
'importance_score': "ALTER TABLE user_memories ADD COLUMN importance_score FLOAT DEFAULT 0.5",
|
||||||
|
'importance_level': "ALTER TABLE user_memories ADD COLUMN importance_level VARCHAR(20) DEFAULT 'medium'",
|
||||||
|
'associated_topics': "ALTER TABLE user_memories ADD COLUMN associated_topics JSON",
|
||||||
|
'decay_score': "ALTER TABLE user_memories ADD COLUMN decay_score FLOAT DEFAULT 1.0",
|
||||||
|
'is_archived': "ALTER TABLE user_memories ADD COLUMN is_archived BOOLEAN DEFAULT 0",
|
||||||
|
'last_accessed_at': "ALTER TABLE user_memories ADD COLUMN last_accessed_at DATETIME",
|
||||||
|
'archive_at': "ALTER TABLE user_memories ADD COLUMN archive_at DATETIME",
|
||||||
|
}
|
||||||
|
for column, ddl in required_columns.items():
|
||||||
|
if column not in columns:
|
||||||
|
await conn.execute(text(ddl))
|
||||||
|
|
||||||
|
|
||||||
async def ensure_user_columns(conn):
|
async def ensure_user_columns(conn):
|
||||||
rows = await _get_table_info(conn, 'users')
|
rows = await _get_table_info(conn, 'users')
|
||||||
if not rows:
|
if not rows:
|
||||||
@@ -181,6 +395,14 @@ async def ensure_skill_columns(conn):
|
|||||||
'output_format': "ALTER TABLE skills ADD COLUMN output_format TEXT",
|
'output_format': "ALTER TABLE skills ADD COLUMN output_format TEXT",
|
||||||
'is_builtin': "ALTER TABLE skills ADD COLUMN is_builtin BOOLEAN DEFAULT 0 NOT NULL",
|
'is_builtin': "ALTER TABLE skills ADD COLUMN is_builtin BOOLEAN DEFAULT 0 NOT NULL",
|
||||||
'team_id': "ALTER TABLE skills ADD COLUMN team_id VARCHAR(36)",
|
'team_id': "ALTER TABLE skills ADD COLUMN team_id VARCHAR(36)",
|
||||||
|
'status': "ALTER TABLE skills ADD COLUMN status VARCHAR(20) DEFAULT 'active' NOT NULL",
|
||||||
|
'scope': "ALTER TABLE skills ADD COLUMN scope JSON DEFAULT '[]' NOT NULL",
|
||||||
|
'effectiveness': "ALTER TABLE skills ADD COLUMN effectiveness FLOAT DEFAULT 0.0 NOT NULL",
|
||||||
|
'review_after': "ALTER TABLE skills ADD COLUMN review_after DATETIME",
|
||||||
|
'candidate_count': "ALTER TABLE skills ADD COLUMN candidate_count INTEGER DEFAULT 0 NOT NULL",
|
||||||
|
'candidate_source_hashes': "ALTER TABLE skills ADD COLUMN candidate_source_hashes JSON DEFAULT '[]' NOT NULL",
|
||||||
|
'activation_count': "ALTER TABLE skills ADD COLUMN activation_count INTEGER DEFAULT 0 NOT NULL",
|
||||||
|
'last_activated_at': "ALTER TABLE skills ADD COLUMN last_activated_at DATETIME",
|
||||||
}
|
}
|
||||||
for column, ddl in required_columns.items():
|
for column, ddl in required_columns.items():
|
||||||
if column not in columns:
|
if column not in columns:
|
||||||
@@ -205,6 +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):
|
async def _backfill_usernames(conn):
|
||||||
result = await conn.execute(text("SELECT id, email, username FROM users ORDER BY created_at, id"))
|
result = await conn.execute(text("SELECT id, email, username FROM users ORDER BY created_at, id"))
|
||||||
users = result.fetchall()
|
users = result.fetchall()
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ from app.routers import (
|
|||||||
marketplace_router,
|
marketplace_router,
|
||||||
agent_skills_router,
|
agent_skills_router,
|
||||||
agent_sessions_router,
|
agent_sessions_router,
|
||||||
|
terminal_router,
|
||||||
|
tools_router,
|
||||||
|
remote_mount_router,
|
||||||
|
office_router,
|
||||||
)
|
)
|
||||||
from app.routers.scheduler import router as scheduler_router
|
from app.routers.scheduler import router as scheduler_router
|
||||||
from app.services.scheduler_service import start_scheduler, stop_scheduler, get_scheduler_status
|
from app.services.scheduler_service import start_scheduler, stop_scheduler, get_scheduler_status
|
||||||
@@ -127,6 +131,10 @@ app.include_router(plugins_router)
|
|||||||
app.include_router(marketplace_router)
|
app.include_router(marketplace_router)
|
||||||
app.include_router(agent_skills_router)
|
app.include_router(agent_skills_router)
|
||||||
app.include_router(agent_sessions_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")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -2,11 +2,22 @@ from app.models.base import Base
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.folder import Folder
|
from app.models.folder import Folder
|
||||||
from app.models.document import Document, DocumentChunk
|
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.forum import ForumPost, ForumReply
|
||||||
from app.models.agent import Agent, AgentMessage
|
from app.models.agent import Agent, AgentMessage
|
||||||
from app.models.conversation import Conversation, Message
|
from app.models.conversation import Conversation, Message
|
||||||
from app.models.knowledge_graph import KGNode, KGEdge
|
from app.models.knowledge_graph import KGNode, KGEdge
|
||||||
|
from app.models.learning import LearningArtifactRecord, SessionRetrospectiveRecord
|
||||||
from app.models.memory import MemorySummary, UserMemory
|
from app.models.memory import MemorySummary, UserMemory
|
||||||
from app.models.brain import (
|
from app.models.brain import (
|
||||||
BrainEvent,
|
BrainEvent,
|
||||||
@@ -20,7 +31,9 @@ from app.models.brain import (
|
|||||||
from app.models.todo import DailyTodo, TodoSource
|
from app.models.todo import DailyTodo, TodoSource
|
||||||
from app.models.reminder import Reminder, ReminderStatus
|
from app.models.reminder import Reminder, ReminderStatus
|
||||||
from app.models.goal import Goal, GoalStatus
|
from app.models.goal import Goal, GoalStatus
|
||||||
|
from app.models.skill import Skill
|
||||||
from app.models.log import Log, LogType, LogLevel
|
from app.models.log import Log, LogType, LogLevel
|
||||||
|
from app.models.remote_mount import RemoteMount, RemoteSyncItem
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
@@ -29,7 +42,14 @@ __all__ = [
|
|||||||
"Document",
|
"Document",
|
||||||
"DocumentChunk",
|
"DocumentChunk",
|
||||||
"Task",
|
"Task",
|
||||||
|
"TaskSubTask",
|
||||||
"TaskHistory",
|
"TaskHistory",
|
||||||
|
"TaskStatus",
|
||||||
|
"TaskPriority",
|
||||||
|
"TaskSource",
|
||||||
|
"TaskQuadrant",
|
||||||
|
"TaskAssigneeType",
|
||||||
|
"TaskDispatchStatus",
|
||||||
"ForumPost",
|
"ForumPost",
|
||||||
"ForumReply",
|
"ForumReply",
|
||||||
"Agent",
|
"Agent",
|
||||||
@@ -38,6 +58,8 @@ __all__ = [
|
|||||||
"Message",
|
"Message",
|
||||||
"KGNode",
|
"KGNode",
|
||||||
"KGEdge",
|
"KGEdge",
|
||||||
|
"LearningArtifactRecord",
|
||||||
|
"SessionRetrospectiveRecord",
|
||||||
"MemorySummary",
|
"MemorySummary",
|
||||||
"UserMemory",
|
"UserMemory",
|
||||||
"BrainEvent",
|
"BrainEvent",
|
||||||
@@ -53,7 +75,10 @@ __all__ = [
|
|||||||
"ReminderStatus",
|
"ReminderStatus",
|
||||||
"Goal",
|
"Goal",
|
||||||
"GoalStatus",
|
"GoalStatus",
|
||||||
|
"Skill",
|
||||||
"Log",
|
"Log",
|
||||||
"LogType",
|
"LogType",
|
||||||
"LogLevel",
|
"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)
|
||||||
@@ -1,4 +1,15 @@
|
|||||||
from sqlalchemy import Column, String, Text, Integer, ForeignKey, Boolean, DateTime, Enum as SQLEnum
|
from sqlalchemy import (
|
||||||
|
Column,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
Integer,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
Enum as SQLEnum,
|
||||||
|
JSON,
|
||||||
|
)
|
||||||
from app.models.base import BaseModel, utc_now
|
from app.models.base import BaseModel, utc_now
|
||||||
|
|
||||||
|
|
||||||
@@ -7,6 +18,7 @@ class MemorySummary(BaseModel):
|
|||||||
对话摘要 — 中期记忆
|
对话摘要 — 中期记忆
|
||||||
当一段对话超过阈值轮数时,自动生成摘要存入此表
|
当一段对话超过阈值轮数时,自动生成摘要存入此表
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "memory_summaries"
|
__tablename__ = "memory_summaries"
|
||||||
|
|
||||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||||
@@ -21,14 +33,28 @@ class UserMemory(BaseModel):
|
|||||||
用户画像记忆 — 长期记忆
|
用户画像记忆 — 长期记忆
|
||||||
从对话中提取的用户事实、偏好、目标
|
从对话中提取的用户事实、偏好、目标
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "user_memories"
|
__tablename__ = "user_memories"
|
||||||
|
|
||||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||||
memory_type = Column(String(50), nullable=False) # fact | preference | goal | habit | other
|
memory_type = Column(String(50), nullable=False) # fact | preference | goal | habit | other
|
||||||
content = Column(Text, nullable=False) # 记忆内容
|
content = Column(Text, nullable=False) # 记忆内容
|
||||||
importance = Column(Integer, default=5) # 重要程度 1-10
|
importance = Column(Integer, default=5) # 重要程度 1-10 (legacy, replaced by importance_score)
|
||||||
is_recalled = Column(Boolean, default=False) # 是否在当前对话中被召回
|
is_recalled = Column(Boolean, default=False) # 是否在当前对话中被召回
|
||||||
recall_count = Column(Integer, default=0) # 被召回次数
|
recall_count = Column(Integer, default=0) # 被召回次数
|
||||||
source_conversation_id = Column(String(36), nullable=True) # 来源对话
|
source_conversation_id = Column(String(36), nullable=True) # 来源对话
|
||||||
extracted_at = Column(DateTime, default=utc_now, nullable=False)
|
extracted_at = Column(DateTime, default=utc_now, nullable=False)
|
||||||
last_recalled_at = Column(DateTime, nullable=True)
|
last_recalled_at = Column(DateTime, nullable=True)
|
||||||
|
# M.1: 重要性评分系统
|
||||||
|
frequency_count = Column(
|
||||||
|
Integer, default=0
|
||||||
|
) # 被召回次数 (duplicate of recall_count, for scoring clarity)
|
||||||
|
emotion_tags = Column(JSON, nullable=True) # List of emotion keywords
|
||||||
|
importance_score = Column(Float, default=0.5) # 重要性分数 0.0-1.0
|
||||||
|
importance_level = Column(String(20), default="medium") # high | medium | low
|
||||||
|
associated_topics = Column(JSON, nullable=True) # List of topic strings
|
||||||
|
# M.2: 遗忘曲线系统
|
||||||
|
decay_score = Column(Float, default=1.0) # 0.0-1.0, higher=more remembered
|
||||||
|
is_archived = Column(Boolean, default=False) # 是否已归档到冷存储
|
||||||
|
last_accessed_at = Column(DateTime, nullable=True) # 上次访问时间(用于遗忘计算)
|
||||||
|
archive_at = Column(DateTime, nullable=True) # 归档时间
|
||||||
|
|||||||
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 sqlalchemy.orm import relationship
|
||||||
from app.models.base import BaseModel
|
from app.models.base import BaseModel
|
||||||
|
|
||||||
@@ -17,6 +17,14 @@ class Skill(BaseModel):
|
|||||||
is_builtin = Column(Boolean, default=False, nullable=False)
|
is_builtin = Column(Boolean, default=False, nullable=False)
|
||||||
team_id = Column(String(36), ForeignKey("users.id"), nullable=True)
|
team_id = Column(String(36), ForeignKey("users.id"), nullable=True)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
|
status = Column(String(20), default="active", nullable=False, index=True) # candidate/shadow/active/deprecated/retired
|
||||||
|
scope = Column(JSON, default=list, nullable=False)
|
||||||
|
effectiveness = Column(Float, default=0.0, nullable=False)
|
||||||
|
review_after = Column(DateTime, nullable=True)
|
||||||
|
candidate_count = Column(Integer, default=0, nullable=False)
|
||||||
|
candidate_source_hashes = Column(JSON, default=list, nullable=False)
|
||||||
|
activation_count = Column(Integer, default=0, nullable=False)
|
||||||
|
last_activated_at = Column(DateTime, nullable=True)
|
||||||
owner_id = Column(String(36), ForeignKey("users.id"), nullable=False)
|
owner_id = Column(String(36), ForeignKey("users.id"), nullable=False)
|
||||||
|
|
||||||
owner = relationship("User", foreign_keys=[owner_id])
|
owner = relationship("User", foreign_keys=[owner_id])
|
||||||
|
|||||||
@@ -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 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
|
from app.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
@@ -19,26 +20,144 @@ class TaskPriority(str, PyEnum):
|
|||||||
URGENT = "urgent"
|
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):
|
class Task(BaseModel):
|
||||||
__tablename__ = "tasks"
|
__tablename__ = "tasks"
|
||||||
|
|
||||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||||
title = Column(String(500), nullable=False)
|
title = Column(String(500), nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
status = Column(Enum(TaskStatus), default=TaskStatus.TODO, nullable=False, index=True)
|
status = Column(TASK_STATUS_ENUM, default=TaskStatus.TODO, nullable=False, index=True)
|
||||||
priority = Column(Enum(TaskPriority), default=TaskPriority.MEDIUM, nullable=False)
|
priority = Column(TASK_PRIORITY_ENUM, default=TaskPriority.MEDIUM, nullable=False)
|
||||||
due_date = Column(DateTime, nullable=True)
|
due_date = Column(DateTime, nullable=True, index=True)
|
||||||
completed_at = Column(DateTime, nullable=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):
|
class TaskHistory(BaseModel):
|
||||||
__tablename__ = "task_histories"
|
__tablename__ = "task_histories"
|
||||||
|
|
||||||
task_id = Column(String(36), ForeignKey("tasks.id"), nullable=False, index=True)
|
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)
|
old_value = Column(Text, nullable=True)
|
||||||
new_value = Column(Text, nullable=True)
|
new_value = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
|||||||
@@ -20,3 +20,7 @@ from app.routers.plugins import router as plugins_router
|
|||||||
from app.routers.plugins import _marketplace_router as marketplace_router
|
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_skills import router as agent_skills_router
|
||||||
from app.routers.agent_sessions import router as agent_sessions_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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.agents.learning.store import LearningArtifactStore, SessionRetrospectiveStore
|
||||||
from app.agents.registry import load_builtin_registry_indexes
|
from app.agents.registry import load_builtin_registry_indexes
|
||||||
from app.agents.runtime_metrics import coerce_cost_thresholds, estimate_token_cost, is_cost_budget_warning
|
from app.agents.runtime_metrics import coerce_cost_thresholds, estimate_token_cost, is_cost_budget_warning
|
||||||
from app.models.agent import Agent
|
from app.models.agent import Agent
|
||||||
@@ -37,6 +38,7 @@ from app.schemas.agent import (
|
|||||||
AgentVisibilityVerifierOut,
|
AgentVisibilityVerifierOut,
|
||||||
)
|
)
|
||||||
from app.services.agent_service import _extract_continuity_snapshot
|
from app.services.agent_service import _extract_continuity_snapshot
|
||||||
|
from app.services.runtime_observability import build_runtime_observability_report
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/agents", tags=["Agent"])
|
router = APIRouter(prefix="/api/agents", tags=["Agent"])
|
||||||
|
|
||||||
@@ -662,6 +664,59 @@ async def get_visibility_tools(
|
|||||||
return _build_tool_governance(state, conversation_id=conversation_id)
|
return _build_tool_governance(state, conversation_id=conversation_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/visibility/debug")
|
||||||
|
async def get_visibility_debug(
|
||||||
|
conversation_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
state = await _get_visibility_state(conversation_id, current_user=current_user, db=db)
|
||||||
|
observability = build_runtime_observability_report(
|
||||||
|
state=state,
|
||||||
|
feature_flags=dict(state.get("feature_flags") or {}),
|
||||||
|
)
|
||||||
|
retrospective_store = SessionRetrospectiveStore(db)
|
||||||
|
artifact_store = LearningArtifactStore(db)
|
||||||
|
recent_retrospectives = await retrospective_store.list_recent(
|
||||||
|
user_id=current_user.id,
|
||||||
|
limit=5,
|
||||||
|
)
|
||||||
|
recent_artifacts = await artifact_store.list_recent(
|
||||||
|
user_id=current_user.id,
|
||||||
|
limit=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"observability": observability,
|
||||||
|
"skill_shortlist": list(state.get("skill_shortlist") or []),
|
||||||
|
"retrospective_shortlist": list(state.get("retrospective_shortlist") or []),
|
||||||
|
"merge_report": state.get("merge_report"),
|
||||||
|
"verification_report": state.get("verification_report"),
|
||||||
|
"recent_retrospectives": [
|
||||||
|
{
|
||||||
|
"id": item.id,
|
||||||
|
"task_type": item.task_type,
|
||||||
|
"summary": item.summary_text,
|
||||||
|
"execution_mode": item.execution_mode,
|
||||||
|
"verification_status": item.verification_status,
|
||||||
|
"recorded_at": item.recorded_at.isoformat() if item.recorded_at else None,
|
||||||
|
}
|
||||||
|
for item in recent_retrospectives
|
||||||
|
],
|
||||||
|
"recent_learning_artifacts": [
|
||||||
|
{
|
||||||
|
"id": item.id,
|
||||||
|
"artifact_type": item.artifact_type,
|
||||||
|
"artifact_key": item.artifact_key,
|
||||||
|
"summary": item.summary_text,
|
||||||
|
"recorded_at": item.recorded_at.isoformat() if item.recorded_at else None,
|
||||||
|
}
|
||||||
|
for item in recent_artifacts
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=AgentOut, status_code=201)
|
@router.post("", response_model=AgentOut, status_code=201)
|
||||||
async def create_agent(
|
async def create_agent(
|
||||||
data: AgentCreate,
|
data: AgentCreate,
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ async def chat(
|
|||||||
conversation_id=data.conversation_id,
|
conversation_id=data.conversation_id,
|
||||||
file_ids=data.file_ids,
|
file_ids=data.file_ids,
|
||||||
model_name=data.model_name,
|
model_name=data.model_name,
|
||||||
|
runtime=data.runtime,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
@@ -115,7 +116,7 @@ async def chat(
|
|||||||
conversation_id=conv_id,
|
conversation_id=conv_id,
|
||||||
message_id=msg_id,
|
message_id=msg_id,
|
||||||
content=content,
|
content=content,
|
||||||
agent_name="jarvis",
|
agent_name=data.runtime or "jarvis",
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -141,10 +142,14 @@ async def chat_stream(
|
|||||||
conversation_id=data.conversation_id,
|
conversation_id=data.conversation_id,
|
||||||
file_ids=data.file_ids,
|
file_ids=data.file_ids,
|
||||||
model_name=data.model_name,
|
model_name=data.model_name,
|
||||||
|
runtime=data.runtime,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
yield f"event: error\ndata: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n"
|
yield f"event: error\ndata: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n"
|
||||||
return
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
yield f"event: error\ndata: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
yield f"event: metadata\ndata: {json.dumps({'conversation_id': conv_id, 'message_id': msg_id})}\n\n"
|
yield f"event: metadata\ndata: {json.dumps({'conversation_id': conv_id, 'message_id': msg_id})}\n\n"
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import and_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, and_
|
|
||||||
from typing import List
|
from typing import List
|
||||||
|
import shutil
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.folder import Folder
|
from app.models.folder import Folder
|
||||||
from app.models.user import User
|
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.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=["文件夹"])
|
router = APIRouter(prefix="/api/folders", tags=["文件夹"])
|
||||||
|
|
||||||
|
|
||||||
def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[FolderTreeOut]:
|
def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[FolderTreeOut]:
|
||||||
"""递归构建文件夹树"""
|
|
||||||
tree = []
|
tree = []
|
||||||
for folder in folders:
|
for folder in folders:
|
||||||
if folder.parent_id == parent_id:
|
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,
|
id=folder.id,
|
||||||
name=folder.name,
|
name=folder.name,
|
||||||
parent_id=folder.parent_id,
|
parent_id=folder.parent_id,
|
||||||
children=children
|
children=children,
|
||||||
))
|
))
|
||||||
return tree
|
return tree
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=List[FolderTreeOut])
|
@router.get("", response_model=List[FolderTreeOut])
|
||||||
async def get_folders(
|
async def get_folders(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""获取用户的完整文件夹树"""
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Folder).where(Folder.user_id == current_user.id)
|
select(Folder).where(Folder.user_id == current_user.id)
|
||||||
)
|
)
|
||||||
folders = result.scalars().all()
|
folders = result.scalars().all()
|
||||||
return build_folder_tree(list(folders))
|
return build_folder_tree(list(folders))
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=FolderOut, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=FolderOut, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_folder(
|
async def create_folder(
|
||||||
folder_data: FolderCreate,
|
folder_data: FolderCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""创建文件夹"""
|
|
||||||
# 验证父文件夹存在且属于当前用户
|
|
||||||
if folder_data.parent_id:
|
if folder_data.parent_id:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Folder).where(
|
select(Folder).where(
|
||||||
@@ -53,13 +55,12 @@ async def create_folder(
|
|||||||
if not result.scalar_one_or_none():
|
if not result.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=404, detail="父文件夹不存在")
|
raise HTTPException(status_code=404, detail="父文件夹不存在")
|
||||||
|
|
||||||
# 检查同名文件夹
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Folder).where(
|
select(Folder).where(
|
||||||
and_(
|
and_(
|
||||||
Folder.user_id == current_user.id,
|
Folder.user_id == current_user.id,
|
||||||
Folder.parent_id == folder_data.parent_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(
|
folder = Folder(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
name=folder_data.name,
|
name=folder_data.name,
|
||||||
parent_id=folder_data.parent_id
|
parent_id=folder_data.parent_id,
|
||||||
)
|
)
|
||||||
db.add(folder)
|
db.add(folder)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(folder)
|
await db.refresh(folder)
|
||||||
|
|
||||||
|
document_service = DocumentService(db, current_user.id)
|
||||||
|
await document_service.ensure_folder_directory(current_user.id, folder.id)
|
||||||
return folder
|
return folder
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{folder_id}", response_model=FolderOut)
|
@router.put("/{folder_id}", response_model=FolderOut)
|
||||||
async def rename_folder(
|
async def rename_folder(
|
||||||
folder_id: str,
|
folder_id: str,
|
||||||
folder_data: FolderUpdate,
|
folder_data: FolderUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""重命名文件夹"""
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Folder).where(
|
select(Folder).where(
|
||||||
and_(Folder.id == folder_id, Folder.user_id == current_user.id)
|
and_(Folder.id == folder_id, Folder.user_id == current_user.id)
|
||||||
@@ -93,18 +97,22 @@ async def rename_folder(
|
|||||||
if not folder:
|
if not folder:
|
||||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||||
|
|
||||||
|
old_name = folder.name
|
||||||
folder.name = folder_data.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.commit()
|
||||||
await db.refresh(folder)
|
await db.refresh(folder)
|
||||||
return folder
|
return folder
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_folder(
|
async def delete_folder(
|
||||||
folder_id: str,
|
folder_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
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.models.document import Document
|
||||||
from app.services.knowledge_service import KnowledgeService
|
from app.services.knowledge_service import KnowledgeService
|
||||||
|
|
||||||
@@ -117,15 +125,16 @@ async def delete_folder(
|
|||||||
if not folder:
|
if not folder:
|
||||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
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):
|
async def delete_recursive(fid: str):
|
||||||
# 删除子文件夹(先递归)
|
|
||||||
children = await db.execute(
|
children = await db.execute(
|
||||||
select(Folder).where(Folder.parent_id == fid)
|
select(Folder).where(Folder.parent_id == fid)
|
||||||
)
|
)
|
||||||
for child in children.scalars():
|
for child in children.scalars():
|
||||||
await delete_recursive(child.id)
|
await delete_recursive(child.id)
|
||||||
|
|
||||||
# 删除文档
|
|
||||||
docs = await db.execute(
|
docs = await db.execute(
|
||||||
select(Document).where(Document.folder_id == fid)
|
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 knowledge_service.delete_from_vectorstore(current_user.id, doc.id)
|
||||||
await db.delete(doc)
|
await db.delete(doc)
|
||||||
|
|
||||||
# 删除文件夹本身
|
|
||||||
folder_to_delete = await db.get(Folder, fid)
|
folder_to_delete = await db.get(Folder, fid)
|
||||||
if folder_to_delete:
|
if folder_to_delete:
|
||||||
await db.delete(folder_to_delete)
|
await db.delete(folder_to_delete)
|
||||||
|
|
||||||
await delete_recursive(folder_id)
|
await delete_recursive(folder_id)
|
||||||
await db.commit()
|
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 calendar import monthrange
|
||||||
from datetime import UTC, date, datetime
|
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 import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.goal import Goal
|
from app.models.goal import Goal
|
||||||
from app.models.reminder import Reminder
|
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.todo import DailyTodo
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.routers.auth import get_current_user
|
from app.routers.auth import get_current_user
|
||||||
from app.schemas.schedule_center import (
|
from app.schemas.schedule_center import (
|
||||||
|
ScheduleCenterCommanderSummaryOut,
|
||||||
ScheduleCenterDateOut,
|
ScheduleCenterDateOut,
|
||||||
ScheduleCenterDaySummary,
|
ScheduleCenterDaySummary,
|
||||||
|
ScheduleCenterFocusTaskOut,
|
||||||
ScheduleCenterMonthOut,
|
ScheduleCenterMonthOut,
|
||||||
|
ScheduleCenterQuadrantOut,
|
||||||
|
ScheduleCenterQuadrantTaskOut,
|
||||||
)
|
)
|
||||||
|
from app.schemas.task import build_task_out
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/schedule-center", tags=["调度中心"])
|
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(
|
def _build_summary(
|
||||||
target_date: str,
|
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)
|
@router.get("/month", response_model=ScheduleCenterMonthOut)
|
||||||
async def get_month_schedule(
|
async def get_month_schedule(
|
||||||
year: int = Query(..., ge=2000, le=2100),
|
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())
|
start_dt = datetime.combine(month_start, datetime.min.time())
|
||||||
end_dt = datetime.combine(month_start.replace(day=days_in_month), datetime.max.time())
|
end_dt = datetime.combine(month_start.replace(day=days_in_month), datetime.max.time())
|
||||||
|
|
||||||
todos = (await db.execute(
|
todos = (
|
||||||
select(DailyTodo).where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date >= start_key, DailyTodo.todo_date <= end_key)
|
await db.execute(
|
||||||
)).scalars().all()
|
select(DailyTodo).where(
|
||||||
tasks = (await db.execute(
|
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(
|
select(Task).where(
|
||||||
Task.user_id == current_user.id,
|
Task.user_id == current_user.id,
|
||||||
Task.due_date.is_not(None),
|
Task.due_date.is_not(None),
|
||||||
Task.due_date >= start_dt,
|
Task.due_date >= start_dt,
|
||||||
Task.due_date <= end_dt,
|
Task.due_date <= end_dt,
|
||||||
)
|
)
|
||||||
)).scalars().all()
|
)
|
||||||
reminders = (await db.execute(
|
).scalars().all()
|
||||||
|
reminders = (
|
||||||
|
await db.execute(
|
||||||
select(Reminder).where(
|
select(Reminder).where(
|
||||||
Reminder.user_id == current_user.id,
|
Reminder.user_id == current_user.id,
|
||||||
Reminder.reminder_at >= start_dt,
|
Reminder.reminder_at >= start_dt,
|
||||||
Reminder.reminder_at <= end_dt,
|
Reminder.reminder_at <= end_dt,
|
||||||
)
|
)
|
||||||
)).scalars().all()
|
)
|
||||||
goals = (await db.execute(
|
).scalars().all()
|
||||||
select(Goal).where(Goal.user_id == current_user.id, Goal.goal_date >= start_key, Goal.goal_date <= end_key)
|
goals = (
|
||||||
)).scalars().all()
|
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]] = {}
|
todo_map: dict[str, list[DailyTodo]] = {}
|
||||||
for item in todos:
|
for item in todos:
|
||||||
@@ -96,18 +289,20 @@ async def get_month_schedule(
|
|||||||
days = []
|
days = []
|
||||||
for day in range(1, days_in_month + 1):
|
for day in range(1, days_in_month + 1):
|
||||||
date_key = month_start.replace(day=day).isoformat()
|
date_key = month_start.replace(day=day).isoformat()
|
||||||
days.append(_build_summary(
|
days.append(
|
||||||
|
_build_summary(
|
||||||
date_key,
|
date_key,
|
||||||
todo_map.get(date_key, []),
|
todo_map.get(date_key, []),
|
||||||
task_map.get(date_key, []),
|
task_map.get(date_key, []),
|
||||||
reminder_map.get(date_key, []),
|
reminder_map.get(date_key, []),
|
||||||
goal_map.get(date_key, []),
|
goal_map.get(date_key, []),
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return ScheduleCenterMonthOut(month=f"{year:04d}-{month:02d}", days=days)
|
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(
|
async def get_date_schedule(
|
||||||
date_str: date = Query(...),
|
date_str: date = Query(...),
|
||||||
current_user: User = Depends(get_current_user),
|
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())
|
end_dt = datetime.combine(target_date, datetime.max.time())
|
||||||
date_key = target_date.isoformat()
|
date_key = target_date.isoformat()
|
||||||
|
|
||||||
todos = (await db.execute(
|
todos = (
|
||||||
|
await db.execute(
|
||||||
select(DailyTodo)
|
select(DailyTodo)
|
||||||
.where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date == date_key)
|
.where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date == date_key)
|
||||||
.order_by(DailyTodo.created_at.desc())
|
.order_by(DailyTodo.created_at.desc())
|
||||||
)).scalars().all()
|
)
|
||||||
tasks = (await db.execute(
|
).scalars().all()
|
||||||
|
tasks = (
|
||||||
|
await db.execute(
|
||||||
select(Task)
|
select(Task)
|
||||||
|
.options(selectinload(Task.subtasks), selectinload(Task.history))
|
||||||
.where(
|
.where(
|
||||||
Task.user_id == current_user.id,
|
Task.user_id == current_user.id,
|
||||||
Task.due_date.is_not(None),
|
Task.due_date.is_not(None),
|
||||||
Task.due_date >= start_dt,
|
Task.due_date >= start_dt,
|
||||||
Task.due_date <= end_dt,
|
Task.due_date <= end_dt,
|
||||||
)
|
)
|
||||||
.order_by(Task.created_at.desc())
|
.order_by(Task.priority.desc(), Task.created_at.desc())
|
||||||
)).scalars().all()
|
)
|
||||||
reminders = (await db.execute(
|
).scalars().unique().all()
|
||||||
|
reminders = (
|
||||||
|
await db.execute(
|
||||||
select(Reminder)
|
select(Reminder)
|
||||||
.where(
|
.where(
|
||||||
Reminder.user_id == current_user.id,
|
Reminder.user_id == current_user.id,
|
||||||
@@ -141,20 +342,26 @@ async def get_date_schedule(
|
|||||||
Reminder.reminder_at <= end_dt,
|
Reminder.reminder_at <= end_dt,
|
||||||
)
|
)
|
||||||
.order_by(Reminder.reminder_at.asc(), Reminder.created_at.asc())
|
.order_by(Reminder.reminder_at.asc(), Reminder.created_at.asc())
|
||||||
)).scalars().all()
|
)
|
||||||
goals = (await db.execute(
|
).scalars().all()
|
||||||
|
goals = (
|
||||||
|
await db.execute(
|
||||||
select(Goal)
|
select(Goal)
|
||||||
.where(Goal.user_id == current_user.id, Goal.goal_date == date_key)
|
.where(Goal.user_id == current_user.id, Goal.goal_date == date_key)
|
||||||
.order_by(Goal.created_at.desc())
|
.order_by(Goal.created_at.desc())
|
||||||
)).scalars().all()
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
summary = _build_summary(date_key, todos, tasks, reminders, goals)
|
summary = _build_summary(date_key, todos, tasks, reminders, goals)
|
||||||
return ScheduleCenterDateOut(
|
return ScheduleCenterDateOut(
|
||||||
date=date_key,
|
date=date_key,
|
||||||
todos=todos,
|
todos=todos,
|
||||||
tasks=tasks,
|
tasks=[build_task_out(task) for task in tasks],
|
||||||
reminders=reminders,
|
reminders=reminders,
|
||||||
goals=goals,
|
goals=goals,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
|
focus_tasks=_build_focus_tasks(tasks),
|
||||||
|
quadrants=_build_quadrants(tasks),
|
||||||
|
commander_summary=_build_commander_summary(tasks),
|
||||||
generated_at=datetime.now(UTC),
|
generated_at=datetime.now(UTC),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ async def create_skill(
|
|||||||
visibility=data.visibility,
|
visibility=data.visibility,
|
||||||
team_id=data.team_id,
|
team_id=data.team_id,
|
||||||
is_active=data.is_active,
|
is_active=data.is_active,
|
||||||
|
status=data.status,
|
||||||
|
scope=data.scope,
|
||||||
|
effectiveness=data.effectiveness,
|
||||||
|
review_after=data.review_after,
|
||||||
owner_id=current_user.id,
|
owner_id=current_user.id,
|
||||||
)
|
)
|
||||||
db.add(skill)
|
db.add(skill)
|
||||||
@@ -103,6 +107,14 @@ async def update_skill(
|
|||||||
skill.team_id = data.team_id
|
skill.team_id = data.team_id
|
||||||
if data.is_active is not None:
|
if data.is_active is not None:
|
||||||
skill.is_active = data.is_active
|
skill.is_active = data.is_active
|
||||||
|
if data.status is not None:
|
||||||
|
skill.status = data.status
|
||||||
|
if data.scope is not None:
|
||||||
|
skill.scope = data.scope
|
||||||
|
if data.effectiveness is not None:
|
||||||
|
skill.effectiveness = data.effectiveness
|
||||||
|
if data.review_after is not None:
|
||||||
|
skill.review_after = data.review_after
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(skill)
|
await db.refresh(skill)
|
||||||
|
|||||||
@@ -7,3 +7,9 @@ router = APIRouter(prefix='/api/system', tags=['system'])
|
|||||||
@router.get('/status')
|
@router.get('/status')
|
||||||
async def get_system_status():
|
async def get_system_status():
|
||||||
return SystemService().get_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 datetime import UTC, date, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import desc, select
|
from sqlalchemy import desc, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.database import get_db
|
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.models.user import User
|
||||||
from app.routers.auth import get_current_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])
|
@router.get("", response_model=list[TaskOut])
|
||||||
@@ -18,12 +119,28 @@ async def list_tasks(
|
|||||||
due_date: date | None = Query(default=None),
|
due_date: date | None = Query(default=None),
|
||||||
date_from: date | None = Query(default=None),
|
date_from: date | None = Query(default=None),
|
||||||
date_to: 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),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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:
|
if status:
|
||||||
query = query.where(Task.status == 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:
|
if due_date:
|
||||||
start = datetime.combine(due_date, datetime.min.time())
|
start = datetime.combine(due_date, datetime.min.time())
|
||||||
end = datetime.combine(due_date, datetime.max.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
|
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
|
end = datetime.combine(date_to, datetime.max.time()) if date_to else None
|
||||||
if start and end and start > end:
|
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:
|
if start is not None:
|
||||||
query = query.where(Task.due_date.is_not(None), Task.due_date >= start)
|
query = query.where(Task.due_date.is_not(None), Task.due_date >= start)
|
||||||
if end is not None:
|
if end is not None:
|
||||||
query = query.where(Task.due_date.is_not(None), Task.due_date <= end)
|
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)
|
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(
|
async def create_task(
|
||||||
data: TaskCreate,
|
data: TaskCreate,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
import json
|
|
||||||
task = Task(
|
task = Task(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
title=data.title,
|
title=data.title,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
priority=data.priority,
|
priority=data.priority,
|
||||||
due_date=data.due_date,
|
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)
|
db.add(task)
|
||||||
await db.commit()
|
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(
|
async def update_task(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
data: TaskUpdate,
|
data: TaskUpdate,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
import json
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||||
result = await db.execute(
|
payload = data.model_dump(exclude_none=True)
|
||||||
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
|
previous_assignee = (task.assignee_type, task.assignee_id)
|
||||||
)
|
|
||||||
task = result.scalar_one_or_none()
|
|
||||||
if not task:
|
|
||||||
raise HTTPException(status_code=404, detail="任务不存在")
|
|
||||||
|
|
||||||
for field, value in data.model_dump(exclude_none=True).items():
|
for field, value in payload.items():
|
||||||
|
previous = getattr(task, field)
|
||||||
if field == "tags":
|
if field == "tags":
|
||||||
setattr(task, field, json.dumps(value))
|
task.tags = _encode_tags(value)
|
||||||
elif field == "status" and value == TaskStatus.DONE:
|
append_task_history(task, action="updated", old_value=_decode_tags(previous), new_value=value)
|
||||||
task.completed_at = datetime.now(UTC)
|
continue
|
||||||
setattr(task, field, value)
|
|
||||||
elif field == "status":
|
|
||||||
task.completed_at = None
|
|
||||||
setattr(task, field, value)
|
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.commit()
|
||||||
await db.refresh(task)
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||||
return task
|
return _task_detail_to_out(task)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{task_id}", status_code=204)
|
@router.delete("/{task_id}", status_code=204)
|
||||||
@@ -99,11 +260,171 @@ async def delete_task(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||||
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="任务不存在")
|
|
||||||
await db.delete(task)
|
await db.delete(task)
|
||||||
await db.commit()
|
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,
|
||||||
|
)
|
||||||
|
|||||||
79
backend/app/routers/terminal.py
Normal file
79
backend/app/routers/terminal.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""
|
||||||
|
Terminal WebSocket Router - 终端 WebSocket 端点
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from app.agents.tools.terminal_engine import pty_manager
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ws/terminal", tags=["terminal"])
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionManager:
|
||||||
|
"""WebSocket 连接管理器"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.active_connections: dict[str, WebSocket] = {}
|
||||||
|
|
||||||
|
async def connect(self, session_id: str, websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
self.active_connections[session_id] = websocket
|
||||||
|
|
||||||
|
def disconnect(self, session_id: str):
|
||||||
|
if session_id in self.active_connections:
|
||||||
|
del self.active_connections[session_id]
|
||||||
|
|
||||||
|
async def send(self, session_id: str, data: str):
|
||||||
|
if session_id in self.active_connections:
|
||||||
|
await self.active_connections[session_id].send_text(data)
|
||||||
|
|
||||||
|
def is_connected(self, session_id: str) -> bool:
|
||||||
|
return session_id in self.active_connections
|
||||||
|
|
||||||
|
|
||||||
|
manager = ConnectionManager()
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/{session_id}")
|
||||||
|
async def terminal_websocket(websocket: WebSocket, session_id: str):
|
||||||
|
"""终端 WebSocket 端点"""
|
||||||
|
await manager.connect(session_id, websocket)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 获取该 session 的输出队列
|
||||||
|
queue = pty_manager._output_queues.get(session_id)
|
||||||
|
if queue:
|
||||||
|
# 异步任务:转发 PTY 输出到 WebSocket
|
||||||
|
async def forward_output():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data = await asyncio.wait_for(queue.get(), timeout=0.1)
|
||||||
|
if data is None:
|
||||||
|
await manager.send(session_id, "[SESSION_END]")
|
||||||
|
break
|
||||||
|
await manager.send(session_id, data)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# 检查连接是否还活跃
|
||||||
|
if not manager.is_connected(session_id):
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
forward_task = asyncio.create_task(forward_output())
|
||||||
|
|
||||||
|
# 主循环:接收用户输入
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
# 接收用户输入,写入 PTY
|
||||||
|
await pty_manager.write(session_id, data + "\n")
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
forward_task.cancel()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
manager.disconnect(session_id)
|
||||||
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 datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class MessageCreate(BaseModel):
|
class MessageCreate(BaseModel):
|
||||||
@@ -37,6 +39,7 @@ class ChatRequest(BaseModel):
|
|||||||
conversation_id: str | None = None
|
conversation_id: str | None = None
|
||||||
agent_id: str | None = None
|
agent_id: str | None = None
|
||||||
model_name: str | None = None
|
model_name: str | None = None
|
||||||
|
runtime: Literal["jarvis", "hermes"] | None = None
|
||||||
file_ids: list[str] = []
|
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 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.goal import GoalOut
|
||||||
from app.schemas.reminder import ReminderOut
|
from app.schemas.reminder import ReminderOut
|
||||||
from app.schemas.task import TaskOut
|
from app.schemas.task import TaskOut
|
||||||
@@ -18,6 +19,47 @@ class ScheduleCenterDaySummary(BaseModel):
|
|||||||
goal_total: int
|
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):
|
class ScheduleCenterMonthOut(BaseModel):
|
||||||
month: str
|
month: str
|
||||||
days: list[ScheduleCenterDaySummary]
|
days: list[ScheduleCenterDaySummary]
|
||||||
@@ -30,4 +72,9 @@ class ScheduleCenterDateOut(BaseModel):
|
|||||||
reminders: list[ReminderOut]
|
reminders: list[ReminderOut]
|
||||||
goals: list[GoalOut]
|
goals: list[GoalOut]
|
||||||
summary: ScheduleCenterDaySummary
|
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
|
generated_at: datetime
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ class SkillCreate(BaseModel):
|
|||||||
visibility: str = "private"
|
visibility: str = "private"
|
||||||
team_id: Optional[str] = None
|
team_id: Optional[str] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
status: str = "active"
|
||||||
|
scope: list[str] = []
|
||||||
|
effectiveness: Optional[float] = None
|
||||||
|
review_after: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class SkillUpdate(BaseModel):
|
class SkillUpdate(BaseModel):
|
||||||
@@ -28,6 +32,10 @@ class SkillUpdate(BaseModel):
|
|||||||
visibility: Optional[str] = None
|
visibility: Optional[str] = None
|
||||||
team_id: Optional[str] = None
|
team_id: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
scope: Optional[list[str]] = None
|
||||||
|
effectiveness: Optional[float] = None
|
||||||
|
review_after: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class SkillOut(BaseModel):
|
class SkillOut(BaseModel):
|
||||||
@@ -43,6 +51,12 @@ class SkillOut(BaseModel):
|
|||||||
is_builtin: bool
|
is_builtin: bool
|
||||||
team_id: Optional[str]
|
team_id: Optional[str]
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
status: str
|
||||||
|
scope: list[str]
|
||||||
|
effectiveness: Optional[float]
|
||||||
|
review_after: Optional[datetime]
|
||||||
|
activation_count: int
|
||||||
|
last_activated_at: Optional[datetime]
|
||||||
owner_id: str
|
owner_id: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
@@ -1,14 +1,146 @@
|
|||||||
from pydantic import BaseModel
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import datetime
|
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):
|
class TaskCreate(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
status: TaskStatus = TaskStatus.TODO
|
||||||
priority: TaskPriority = TaskPriority.MEDIUM
|
priority: TaskPriority = TaskPriority.MEDIUM
|
||||||
due_date: datetime | None = None
|
due_date: datetime | None = None
|
||||||
tags: list[str] | 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):
|
class TaskUpdate(BaseModel):
|
||||||
@@ -18,6 +150,16 @@ class TaskUpdate(BaseModel):
|
|||||||
priority: TaskPriority | None = None
|
priority: TaskPriority | None = None
|
||||||
due_date: datetime | None = None
|
due_date: datetime | None = None
|
||||||
tags: list[str] | 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):
|
class TaskOut(BaseModel):
|
||||||
@@ -28,12 +170,128 @@ class TaskOut(BaseModel):
|
|||||||
priority: TaskPriority
|
priority: TaskPriority
|
||||||
due_date: datetime | None
|
due_date: datetime | None
|
||||||
completed_at: 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
|
created_at: datetime
|
||||||
updated_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):
|
class DailyPlanRequest(BaseModel):
|
||||||
user_id: str
|
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 uuid
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from time import perf_counter
|
||||||
from typing import Any, AsyncGenerator
|
from typing import Any, AsyncGenerator
|
||||||
import asyncio
|
import asyncio
|
||||||
from openai import BadRequestError
|
from openai import BadRequestError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from langchain_core.messages import HumanMessage, AIMessage
|
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
|
||||||
|
|
||||||
from app.database import async_session
|
from app.database import async_session
|
||||||
from app.logging_utils import summarize_llm_config
|
from app.logging_utils import summarize_llm_config
|
||||||
@@ -21,12 +22,29 @@ from app.models.conversation import Conversation, Message
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.agents.graph import get_agent_graph
|
from app.agents.graph import get_agent_graph
|
||||||
from app.agents.context import set_current_user, clear_current_user
|
from app.agents.context import set_current_user, clear_current_user
|
||||||
|
from app.agents.learning.jobs import schedule_retrospective_job
|
||||||
|
from app.agents.learning.retrospector import build_session_retrospective
|
||||||
|
from app.agents.learning.session_search import SessionRetrospectiveSearch, summarize_retrospective
|
||||||
|
from app.agents.orchestration.task_graph import build_bounded_task_graph
|
||||||
|
from app.agents.learning.store import append_retrospective_attachment
|
||||||
|
from app.agents.schemas.orchestration import (
|
||||||
|
RuntimeRequestContext,
|
||||||
|
assess_parallel_worthiness,
|
||||||
|
render_runtime_request_context_summary,
|
||||||
|
)
|
||||||
|
from app.agents.schemas.skills import SkillActivationRecord
|
||||||
from app.agents.skills.registry import get_skill_registry
|
from app.agents.skills.registry import get_skill_registry
|
||||||
|
from app.agents.skills.retriever import shortlist_skills_for_request
|
||||||
from app.services import memory_service
|
from app.services import memory_service
|
||||||
from app.services.brain_service import BrainService
|
from app.services.brain_service import BrainService
|
||||||
from app.services.llm_service import create_llm_from_config, resolve_provider_capabilities
|
from app.services.llm_service import create_llm_from_config, resolve_provider_capabilities
|
||||||
|
from app.services.rollback_controller import RollbackController
|
||||||
|
from app.services.runtime_observability import build_runtime_observability_report
|
||||||
from app.agents.tools.time_reasoning import extract_reference_datetime
|
from app.agents.tools.time_reasoning import extract_reference_datetime
|
||||||
from app.agents.state import initial_state
|
from app.agents.state import initial_state
|
||||||
|
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__)
|
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]:
|
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:
|
def _is_streaming_rejection_error(error: Exception, user_llm_config: dict | None) -> bool:
|
||||||
capabilities = resolve_provider_capabilities(user_llm_config)
|
capabilities = resolve_provider_capabilities(user_llm_config)
|
||||||
error_text = str(error).lower()
|
error_text = str(error).lower()
|
||||||
@@ -327,10 +381,47 @@ class AgentService:
|
|||||||
def __init__(self, db: AsyncSession):
|
def __init__(self, db: AsyncSession):
|
||||||
self.db = db
|
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 def _try_auto_summarize_background(self, user_id: str, conversation_id: str) -> None:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
await memory_service.try_auto_summarize(session, user_id, conversation_id)
|
await memory_service.try_auto_summarize(session, user_id, conversation_id)
|
||||||
|
|
||||||
|
# ———— M.4: 主动记忆提取 ————
|
||||||
|
async def _extract_memories_background(self, user_id: str, conversation_id: str) -> None:
|
||||||
|
"""Background task to extract memories from conversation after response."""
|
||||||
|
from app.services.memory.memory_extractor import MemoryExtractor
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models.conversation import Message
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_session() as db:
|
||||||
|
# Load last 10 messages from conversation
|
||||||
|
result = await db.execute(
|
||||||
|
select(Message)
|
||||||
|
.where(Message.conversation_id == conversation_id)
|
||||||
|
.order_by(Message.created_at.desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
messages = list(result.scalars().all())
|
||||||
|
|
||||||
|
if len(messages) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
extractor = MemoryExtractor()
|
||||||
|
new_memories = await extractor.extract_from_conversation(
|
||||||
|
db, user_id, conversation_id, messages
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_memories:
|
||||||
|
await extractor.save_memories(db, user_id, conversation_id, new_memories)
|
||||||
|
logger.info(
|
||||||
|
f"[MemoryExtractor] Extracted {len(new_memories)} new memories from conversation {conversation_id}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"[MemoryExtractor] Extraction failed: {e}")
|
||||||
|
|
||||||
def _build_progress_event(
|
def _build_progress_event(
|
||||||
self,
|
self,
|
||||||
stage: str,
|
stage: str,
|
||||||
@@ -427,18 +518,27 @@ class AgentService:
|
|||||||
async def _build_agent_state(
|
async def _build_agent_state(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
request_id: str,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
conversation: Conversation,
|
conversation: Conversation,
|
||||||
|
raw_user_query: str,
|
||||||
full_message: str,
|
full_message: str,
|
||||||
memory_context: str | None,
|
memory_context: str | None,
|
||||||
current_datetime_context: str,
|
current_datetime_context: str,
|
||||||
current_datetime_reference: dict[str, str],
|
current_datetime_reference: dict[str, str],
|
||||||
user_llm_config: dict | None,
|
user_llm_config: dict | None,
|
||||||
|
runtime_request_context: RuntimeRequestContext,
|
||||||
|
recalled_retrospectives: list[dict[str, Any]],
|
||||||
|
skill_shortlist: list[dict[str, Any]],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
state = initial_state(user_id, conversation.id)
|
state = initial_state(user_id, conversation.id)
|
||||||
|
runtime_summary = render_runtime_request_context_summary(runtime_request_context)
|
||||||
state.update(
|
state.update(
|
||||||
{
|
{
|
||||||
"messages": [HumanMessage(content=full_message)],
|
"messages": [
|
||||||
|
SystemMessage(content=runtime_summary),
|
||||||
|
HumanMessage(content=full_message),
|
||||||
|
],
|
||||||
"memory_context": memory_context,
|
"memory_context": memory_context,
|
||||||
"current_datetime_context": current_datetime_context,
|
"current_datetime_context": current_datetime_context,
|
||||||
"current_datetime_reference": current_datetime_reference,
|
"current_datetime_reference": current_datetime_reference,
|
||||||
@@ -448,9 +548,119 @@ class AgentService:
|
|||||||
previous_snapshot = await self._load_continuity_snapshot(conversation)
|
previous_snapshot = await self._load_continuity_snapshot(conversation)
|
||||||
if previous_snapshot:
|
if previous_snapshot:
|
||||||
state.update(previous_snapshot)
|
state.update(previous_snapshot)
|
||||||
state["messages"] = [HumanMessage(content=full_message)]
|
state["messages"] = [
|
||||||
|
SystemMessage(content=runtime_summary),
|
||||||
|
HumanMessage(content=full_message),
|
||||||
|
]
|
||||||
|
state.update(
|
||||||
|
{
|
||||||
|
"runtime_request_context": runtime_request_context.model_dump(mode="json"),
|
||||||
|
"task_graph": (
|
||||||
|
runtime_request_context.task_graph.model_dump(mode="json")
|
||||||
|
if runtime_request_context.task_graph is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"feature_flags": RollbackController().snapshot_flags(),
|
||||||
|
"recalled_retrospectives": recalled_retrospectives,
|
||||||
|
"retrospective_shortlist": recalled_retrospectives,
|
||||||
|
"skill_shortlist": skill_shortlist,
|
||||||
|
"skill_activation_records": [
|
||||||
|
SkillActivationRecord(
|
||||||
|
skill_name=item.get("skill_name"),
|
||||||
|
source=item.get("source", "runtime"),
|
||||||
|
source_id=item.get("source_id"),
|
||||||
|
status=item.get("status", "active"),
|
||||||
|
injection_mode=item.get("injection_mode", "metadata_only"),
|
||||||
|
matched_terms=item.get("matched_terms", []),
|
||||||
|
rationale=item.get("rationale"),
|
||||||
|
).model_dump(mode="json")
|
||||||
|
for item in skill_shortlist
|
||||||
|
if item.get("skill_name")
|
||||||
|
],
|
||||||
|
"parallel_worthiness": runtime_request_context.parallel_worthiness.model_dump(
|
||||||
|
mode="json"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
return state
|
return state
|
||||||
|
|
||||||
|
async def _build_runtime_request_context(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
request_id: str,
|
||||||
|
user_id: str,
|
||||||
|
conversation: Conversation,
|
||||||
|
user_query: str,
|
||||||
|
memory_context: str | None,
|
||||||
|
) -> tuple[RuntimeRequestContext, list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
started_at = perf_counter()
|
||||||
|
retrospectives_started = perf_counter()
|
||||||
|
recent_retrospectives = await SessionRetrospectiveSearch(self.db).shortlist(
|
||||||
|
user_id=user_id,
|
||||||
|
query_text=user_query,
|
||||||
|
conversation_id=conversation.id,
|
||||||
|
limit=3,
|
||||||
|
)
|
||||||
|
retrospective_ms = (perf_counter() - retrospectives_started) * 1000
|
||||||
|
feature_flags = RollbackController().snapshot_flags()
|
||||||
|
shortlist_started = perf_counter()
|
||||||
|
skill_shortlist = await shortlist_skills_for_request(
|
||||||
|
self.db,
|
||||||
|
user_id=user_id,
|
||||||
|
user_query=user_query,
|
||||||
|
memory_context=memory_context,
|
||||||
|
retrospectives=[item.model_dump(mode="json") for item in recent_retrospectives],
|
||||||
|
include_learned=feature_flags["ENABLE_LEARNED_SKILL_LOADING"],
|
||||||
|
limit=4,
|
||||||
|
)
|
||||||
|
skill_shortlist_ms = (perf_counter() - shortlist_started) * 1000
|
||||||
|
parallel_worthiness = assess_parallel_worthiness(
|
||||||
|
user_query,
|
||||||
|
retrospective_count=len(recent_retrospectives),
|
||||||
|
skill_count=len(skill_shortlist),
|
||||||
|
)
|
||||||
|
recommended_runtime_mode = (
|
||||||
|
"collaboration" if parallel_worthiness.preferred_mode != "direct" else "direct"
|
||||||
|
)
|
||||||
|
task_graph = (
|
||||||
|
build_bounded_task_graph(
|
||||||
|
query_text=user_query,
|
||||||
|
parallel_worthiness=parallel_worthiness,
|
||||||
|
)
|
||||||
|
if feature_flags["ENABLE_PARALLEL_TASK_GRAPH"]
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
runtime_request_context = RuntimeRequestContext(
|
||||||
|
request_id=request_id,
|
||||||
|
session_id=conversation.id,
|
||||||
|
conversation_id=conversation.id,
|
||||||
|
user_id=user_id,
|
||||||
|
query_text=user_query,
|
||||||
|
raw_user_query=user_query,
|
||||||
|
recalled_memories=_extract_memory_highlights(memory_context),
|
||||||
|
recalled_retrospectives=[
|
||||||
|
summarize_retrospective(retrospective) for retrospective in recent_retrospectives
|
||||||
|
],
|
||||||
|
shortlisted_skills=[entry.skill_name for entry in skill_shortlist],
|
||||||
|
skill_shortlist=skill_shortlist,
|
||||||
|
current_agent_role="master",
|
||||||
|
execution_mode=recommended_runtime_mode,
|
||||||
|
conversation_state_ref=conversation.id,
|
||||||
|
parallel_worthiness=parallel_worthiness,
|
||||||
|
task_graph=task_graph,
|
||||||
|
recommended_runtime_mode=recommended_runtime_mode,
|
||||||
|
assembly_metrics={
|
||||||
|
"retrospective_ms": round(retrospective_ms, 3),
|
||||||
|
"skill_shortlist_ms": round(skill_shortlist_ms, 3),
|
||||||
|
"total_ms": round((perf_counter() - started_at) * 1000, 3),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
runtime_request_context,
|
||||||
|
[item.model_dump(mode="json") for item in recent_retrospectives],
|
||||||
|
[item.model_dump(mode="json") for item in skill_shortlist],
|
||||||
|
)
|
||||||
|
|
||||||
async def chat(
|
async def chat(
|
||||||
self,
|
self,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -458,10 +668,12 @@ class AgentService:
|
|||||||
conversation_id: str | None = None,
|
conversation_id: str | None = None,
|
||||||
file_ids: list[str] | None = None,
|
file_ids: list[str] | None = None,
|
||||||
model_name: str | None = None,
|
model_name: str | None = None,
|
||||||
|
runtime: str | None = None,
|
||||||
) -> tuple[str, str, AsyncGenerator[dict[str, Any], 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)
|
user_llm_config = await self._get_user_llm_config(user_id, model_name)
|
||||||
model_name_used = model_name
|
model_name_used = model_name
|
||||||
if model_name and not user_llm_config:
|
if model_name and not user_llm_config:
|
||||||
@@ -543,11 +755,18 @@ class AgentService:
|
|||||||
self.db, user_id, conversation_id, message
|
self.db, user_id, conversation_id, message
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# M.5: Inject recall memories into context (before LLM call)
|
||||||
|
from app.services.memory.recall_injector import MemoryRecallInjector
|
||||||
|
|
||||||
|
recall_ctx = await MemoryRecallInjector().build_context(self.db, user_id, message)
|
||||||
|
if recall_ctx:
|
||||||
|
memory_ctx = f"{memory_ctx}\n{recall_ctx}" if memory_ctx else recall_ctx
|
||||||
|
|
||||||
assistant_msg = Message(
|
assistant_msg = Message(
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
role="assistant",
|
role="assistant",
|
||||||
content="",
|
content="",
|
||||||
model=model_name_used or "jarvis",
|
model=(model_name_used or "jarvis") if runtime_name == "jarvis" else runtime_name,
|
||||||
attachments=None,
|
attachments=None,
|
||||||
)
|
)
|
||||||
self.db.add(assistant_msg)
|
self.db.add(assistant_msg)
|
||||||
@@ -562,28 +781,113 @@ class AgentService:
|
|||||||
"title": "Assistant message",
|
"title": "Assistant message",
|
||||||
"content_summary": content[:500],
|
"content_summary": content[:500],
|
||||||
"raw_excerpt": content[:2000],
|
"raw_excerpt": content[:2000],
|
||||||
"metadata_": {"role": "assistant"},
|
"metadata_": {"role": "assistant", "runtime": runtime_name},
|
||||||
"importance_signal": 0.8,
|
"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():
|
async def run_agent():
|
||||||
collected = ""
|
collected = ""
|
||||||
state: dict[str, Any] | None = None
|
state: dict[str, Any] | None = None
|
||||||
|
runtime_request_context: RuntimeRequestContext | None = None
|
||||||
set_current_user(user_id)
|
set_current_user(user_id)
|
||||||
try:
|
try:
|
||||||
graph = get_agent_graph()
|
graph = get_agent_graph()
|
||||||
current_datetime_context, current_datetime_reference = (
|
current_datetime_context, current_datetime_reference = (
|
||||||
self._build_current_datetime_context()
|
self._build_current_datetime_context()
|
||||||
)
|
)
|
||||||
|
(
|
||||||
state = await self._build_agent_state(
|
runtime_request_context,
|
||||||
|
recalled_retrospectives,
|
||||||
|
skill_shortlist,
|
||||||
|
) = await self._build_runtime_request_context(
|
||||||
|
request_id=assistant_msg.id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
conversation=conv,
|
conversation=conv,
|
||||||
|
user_query=message,
|
||||||
|
memory_context=memory_ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
state = await self._build_agent_state(
|
||||||
|
request_id=assistant_msg.id,
|
||||||
|
user_id=user_id,
|
||||||
|
conversation=conv,
|
||||||
|
raw_user_query=message,
|
||||||
full_message=full_message,
|
full_message=full_message,
|
||||||
memory_context=memory_ctx,
|
memory_context=memory_ctx,
|
||||||
current_datetime_context=current_datetime_context,
|
current_datetime_context=current_datetime_context,
|
||||||
current_datetime_reference=current_datetime_reference,
|
current_datetime_reference=current_datetime_reference,
|
||||||
user_llm_config=user_llm_config,
|
user_llm_config=user_llm_config,
|
||||||
|
runtime_request_context=runtime_request_context,
|
||||||
|
recalled_retrospectives=recalled_retrospectives,
|
||||||
|
skill_shortlist=skill_shortlist,
|
||||||
)
|
)
|
||||||
state.update(_derive_role_memory_contexts(memory_ctx))
|
state.update(_derive_role_memory_contexts(memory_ctx))
|
||||||
|
|
||||||
@@ -708,7 +1012,7 @@ class AgentService:
|
|||||||
if collected:
|
if collected:
|
||||||
assistant_msg.content = collected
|
assistant_msg.content = collected
|
||||||
continuity_snapshot = _build_continuity_snapshot(state or {})
|
continuity_snapshot = _build_continuity_snapshot(state or {})
|
||||||
assistant_msg.attachments = (
|
attachments = (
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"kind": "agent_continuity_state",
|
"kind": "agent_continuity_state",
|
||||||
@@ -716,7 +1020,25 @@ class AgentService:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
if continuity_snapshot
|
if continuity_snapshot
|
||||||
else None
|
else []
|
||||||
|
)
|
||||||
|
if state is not None and runtime_request_context is not None:
|
||||||
|
retrospective = build_session_retrospective(
|
||||||
|
request_id=assistant_msg.id,
|
||||||
|
session_id=conversation_id,
|
||||||
|
user_query=message,
|
||||||
|
state=state,
|
||||||
|
runtime_context=runtime_request_context,
|
||||||
|
)
|
||||||
|
attachments = append_retrospective_attachment(attachments, retrospective)
|
||||||
|
attachments.append(
|
||||||
|
{
|
||||||
|
"kind": "runtime_observability",
|
||||||
|
"payload": build_runtime_observability_report(
|
||||||
|
state=state,
|
||||||
|
feature_flags=state.get("feature_flags") or {},
|
||||||
|
),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
conv.agent_state = (
|
conv.agent_state = (
|
||||||
{
|
{
|
||||||
@@ -730,11 +1052,23 @@ class AgentService:
|
|||||||
user_id,
|
user_id,
|
||||||
**_build_assistant_event_payload(collected),
|
**_build_assistant_event_payload(collected),
|
||||||
)
|
)
|
||||||
|
assistant_msg.attachments = attachments or None
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
await self.db.refresh(assistant_msg)
|
await self.db.refresh(assistant_msg)
|
||||||
|
schedule_retrospective_job(
|
||||||
|
user_id=user_id,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
request_message_id=user_msg.id,
|
||||||
|
response_message_id=assistant_msg.id,
|
||||||
|
query_text=message,
|
||||||
|
final_response=collected,
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("save_assistant_message_failed")
|
logger.exception("save_assistant_message_failed")
|
||||||
asyncio.create_task(self._try_auto_summarize_background(user_id, conversation_id))
|
asyncio.create_task(self._try_auto_summarize_background(user_id, conversation_id))
|
||||||
|
# M.4: Extract memories from conversation
|
||||||
|
asyncio.create_task(self._extract_memories_background(user_id, conversation_id))
|
||||||
|
|
||||||
return conversation_id, assistant_msg.id, run_agent()
|
return conversation_id, assistant_msg.id, run_agent()
|
||||||
|
|
||||||
@@ -745,10 +1079,12 @@ class AgentService:
|
|||||||
conversation_id: str | None = None,
|
conversation_id: str | None = None,
|
||||||
file_ids: list[str] | None = None,
|
file_ids: list[str] | None = None,
|
||||||
model_name: str | None = None,
|
model_name: str | None = None,
|
||||||
|
runtime: str | None = None,
|
||||||
) -> tuple[str, str, str, str | 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)
|
user_llm_config = await self._get_user_llm_config(user_id, model_name)
|
||||||
model_name_used = model_name
|
model_name_used = model_name
|
||||||
if model_name and not user_llm_config:
|
if model_name and not user_llm_config:
|
||||||
@@ -785,7 +1121,7 @@ class AgentService:
|
|||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
role="assistant",
|
role="assistant",
|
||||||
content="",
|
content="",
|
||||||
model=model_name_used or "jarvis",
|
model=(model_name_used or "jarvis") if runtime_name == "jarvis" else runtime_name,
|
||||||
attachments=None,
|
attachments=None,
|
||||||
)
|
)
|
||||||
self.db.add(assistant_msg)
|
self.db.add(assistant_msg)
|
||||||
@@ -807,20 +1143,107 @@ class AgentService:
|
|||||||
self.db, user_id, conversation_id, message
|
self.db, user_id, conversation_id, message
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# M.5: Inject recall memories into context (before LLM call)
|
||||||
|
from app.services.memory.recall_injector import MemoryRecallInjector
|
||||||
|
|
||||||
|
recall_ctx = await MemoryRecallInjector().build_context(self.db, user_id, message)
|
||||||
|
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)
|
set_current_user(user_id)
|
||||||
try:
|
try:
|
||||||
graph = get_agent_graph()
|
graph = get_agent_graph()
|
||||||
current_datetime_context, current_datetime_reference = (
|
current_datetime_context, current_datetime_reference = (
|
||||||
self._build_current_datetime_context()
|
self._build_current_datetime_context()
|
||||||
)
|
)
|
||||||
state = await self._build_agent_state(
|
(
|
||||||
|
runtime_request_context,
|
||||||
|
recalled_retrospectives,
|
||||||
|
skill_shortlist,
|
||||||
|
) = await self._build_runtime_request_context(
|
||||||
|
request_id=assistant_msg.id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
conversation=conv,
|
conversation=conv,
|
||||||
|
user_query=message,
|
||||||
|
memory_context=memory_ctx,
|
||||||
|
)
|
||||||
|
state = await self._build_agent_state(
|
||||||
|
request_id=assistant_msg.id,
|
||||||
|
user_id=user_id,
|
||||||
|
conversation=conv,
|
||||||
|
raw_user_query=message,
|
||||||
full_message=message,
|
full_message=message,
|
||||||
memory_context=memory_ctx,
|
memory_context=memory_ctx,
|
||||||
current_datetime_context=current_datetime_context,
|
current_datetime_context=current_datetime_context,
|
||||||
current_datetime_reference=current_datetime_reference,
|
current_datetime_reference=current_datetime_reference,
|
||||||
user_llm_config=user_llm_config,
|
user_llm_config=user_llm_config,
|
||||||
|
runtime_request_context=runtime_request_context,
|
||||||
|
recalled_retrospectives=recalled_retrospectives,
|
||||||
|
skill_shortlist=skill_shortlist,
|
||||||
)
|
)
|
||||||
state.update(_derive_role_memory_contexts(memory_ctx))
|
state.update(_derive_role_memory_contexts(memory_ctx))
|
||||||
result_state = await graph.ainvoke(state)
|
result_state = await graph.ainvoke(state)
|
||||||
@@ -850,7 +1273,7 @@ class AgentService:
|
|||||||
continuity_snapshot = (
|
continuity_snapshot = (
|
||||||
_build_continuity_snapshot(result_state) if "result_state" in locals() else None
|
_build_continuity_snapshot(result_state) if "result_state" in locals() else None
|
||||||
)
|
)
|
||||||
assistant_msg.attachments = (
|
attachments = (
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"kind": "agent_continuity_state",
|
"kind": "agent_continuity_state",
|
||||||
@@ -858,7 +1281,25 @@ class AgentService:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
if continuity_snapshot
|
if continuity_snapshot
|
||||||
else None
|
else []
|
||||||
|
)
|
||||||
|
if "result_state" in locals() and "runtime_request_context" in locals():
|
||||||
|
retrospective = build_session_retrospective(
|
||||||
|
request_id=assistant_msg.id,
|
||||||
|
session_id=conversation_id,
|
||||||
|
user_query=message,
|
||||||
|
state=result_state,
|
||||||
|
runtime_context=runtime_request_context,
|
||||||
|
)
|
||||||
|
attachments = append_retrospective_attachment(attachments, retrospective)
|
||||||
|
attachments.append(
|
||||||
|
{
|
||||||
|
"kind": "runtime_observability",
|
||||||
|
"payload": build_runtime_observability_report(
|
||||||
|
state=result_state,
|
||||||
|
feature_flags=result_state.get("feature_flags") or {},
|
||||||
|
),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
conv.agent_state = (
|
conv.agent_state = (
|
||||||
{
|
{
|
||||||
@@ -868,7 +1309,17 @@ class AgentService:
|
|||||||
if continuity_snapshot
|
if continuity_snapshot
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
assistant_msg.attachments = attachments or None
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
await self.db.refresh(assistant_msg)
|
await self.db.refresh(assistant_msg)
|
||||||
|
schedule_retrospective_job(
|
||||||
|
user_id=user_id,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
request_message_id=user_msg.id,
|
||||||
|
response_message_id=assistant_msg.id,
|
||||||
|
query_text=message,
|
||||||
|
final_response=response_content,
|
||||||
|
state=result_state if "result_state" in locals() else None,
|
||||||
|
)
|
||||||
|
|
||||||
return conversation_id, assistant_msg.id, response_content, model_name_used
|
return conversation_id, assistant_msg.id, response_content, model_name_used
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import shutil
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
@@ -18,7 +19,6 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
@@ -52,9 +52,9 @@ class DocumentService:
|
|||||||
if ext not in ALLOWED_EXTENSIONS:
|
if ext not in ALLOWED_EXTENSIONS:
|
||||||
raise ValueError(f"不支持的文件类型: {ext}")
|
raise ValueError(f"不支持的文件类型: {ext}")
|
||||||
|
|
||||||
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
folder_path = await self._get_storage_directory(user_id, folder_id)
|
||||||
file_id = str(uuid.uuid4())
|
folder_path.mkdir(parents=True, exist_ok=True)
|
||||||
file_path = os.path.join(settings.UPLOAD_DIR, f"{file_id}{ext}")
|
file_path = self._resolve_unique_file_path(folder_path, file.filename)
|
||||||
|
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
file_size = len(content)
|
file_size = len(content)
|
||||||
@@ -64,7 +64,7 @@ class DocumentService:
|
|||||||
async with aiofiles.open(file_path, "wb") as f:
|
async with aiofiles.open(file_path, "wb") as f:
|
||||||
await f.write(content)
|
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)
|
parsed.structured_markdown = self._render_structured_markdown(parsed)
|
||||||
|
|
||||||
doc = Document(
|
doc = Document(
|
||||||
@@ -73,7 +73,7 @@ class DocumentService:
|
|||||||
filename=file.filename,
|
filename=file.filename,
|
||||||
file_type=ext[1:],
|
file_type=ext[1:],
|
||||||
file_size=file_size,
|
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,
|
summary=parsed.summary[:500] if len(parsed.summary) > 500 else parsed.summary,
|
||||||
folder_id=folder_id,
|
folder_id=folder_id,
|
||||||
ingestion_status="uploaded",
|
ingestion_status="uploaded",
|
||||||
@@ -171,6 +171,83 @@ class DocumentService:
|
|||||||
|
|
||||||
return "/" + "/".join(path_parts) if path_parts else None
|
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):
|
async def delete_document(self, user_id: str, document_id: str):
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(Document).where(
|
select(Document).where(
|
||||||
|
|||||||
20
backend/app/services/memory/__init__.py
Normal file
20
backend/app/services/memory/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""Memory Services Module"""
|
||||||
|
|
||||||
|
from app.services.memory.frequency_tracker import FrequencyTracker
|
||||||
|
from app.services.memory.emotion_analyzer import EmotionAnalyzer
|
||||||
|
from app.services.memory.impact_evaluator import ImpactEvaluator
|
||||||
|
from app.services.memory.importance_scorer import ImportanceScorer, ImportanceLevel
|
||||||
|
from app.services.memory.forgetting_curve import ForgettingCurve
|
||||||
|
from app.services.memory.memory_decay import MemoryDecay
|
||||||
|
from app.services.memory.reinforcement import MemoryReinforcement
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FrequencyTracker",
|
||||||
|
"EmotionAnalyzer",
|
||||||
|
"ImpactEvaluator",
|
||||||
|
"ImportanceScorer",
|
||||||
|
"ImportanceLevel",
|
||||||
|
"ForgettingCurve",
|
||||||
|
"MemoryDecay",
|
||||||
|
"MemoryReinforcement",
|
||||||
|
]
|
||||||
264
backend/app/services/memory/daily_digest.py
Normal file
264
backend/app/services/memory/daily_digest.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
"""
|
||||||
|
DailyDigestGenerator
|
||||||
|
|
||||||
|
Generates daily summary of user's day including conversations, tasks, key memories.
|
||||||
|
Generated at 22:00 daily via scheduler.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date, datetime, timedelta, UTC
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select, and_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.memory import MemorySummary, UserMemory
|
||||||
|
from app.models.task import Task
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DailyDigestData:
|
||||||
|
"""Daily digest data structure."""
|
||||||
|
|
||||||
|
date: date
|
||||||
|
summary: str
|
||||||
|
key_points: list[dict] = field(default_factory=list)
|
||||||
|
pending_questions: list[dict] = field(default_factory=list)
|
||||||
|
suggestions: list[dict] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class DailyDigestGenerator:
|
||||||
|
"""Generate daily summary for a user."""
|
||||||
|
|
||||||
|
MAX_KEY_POINTS = 5
|
||||||
|
MAX_SUGGESTIONS = 3
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
target_date: date | None = None,
|
||||||
|
) -> DailyDigestData:
|
||||||
|
"""Generate daily digest for user.
|
||||||
|
|
||||||
|
1. Get today's conversation summaries
|
||||||
|
2. Get high importance memories from today
|
||||||
|
3. Get today's tasks
|
||||||
|
4. Generate summary using LLM
|
||||||
|
"""
|
||||||
|
if target_date is None:
|
||||||
|
target_date = datetime.now(UTC).date()
|
||||||
|
|
||||||
|
start_of_day = datetime.combine(target_date, datetime.min.time()).replace(tzinfo=UTC)
|
||||||
|
end_of_day = datetime.combine(target_date, datetime.max.time()).replace(tzinfo=UTC)
|
||||||
|
|
||||||
|
# 1. Get conversation summaries from today
|
||||||
|
summaries = await self._get_today_summaries(db, user_id, start_of_day, end_of_day)
|
||||||
|
|
||||||
|
# 2. Get high importance memories from today
|
||||||
|
high_importance_memories = await self._get_high_importance_memories(
|
||||||
|
db, user_id, start_of_day, end_of_day
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Get today's tasks
|
||||||
|
tasks = await self._get_today_tasks(db, user_id, start_of_day, end_of_day)
|
||||||
|
|
||||||
|
# 4. Generate summary using LLM
|
||||||
|
summary = await self._generate_summary(
|
||||||
|
summaries=summaries,
|
||||||
|
memories=high_importance_memories,
|
||||||
|
tasks=tasks,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Extract key points
|
||||||
|
key_points = self._extract_key_points(high_importance_memories, summaries, tasks)
|
||||||
|
|
||||||
|
# 6. Generate suggestions
|
||||||
|
suggestions = self._generate_suggestions(high_importance_memories, tasks)
|
||||||
|
|
||||||
|
return DailyDigestData(
|
||||||
|
date=target_date,
|
||||||
|
summary=summary,
|
||||||
|
key_points=key_points,
|
||||||
|
pending_questions=[], # Filled by LLM analysis
|
||||||
|
suggestions=suggestions,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_today_summaries(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
) -> list[MemorySummary]:
|
||||||
|
"""Get conversation summaries from today."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(MemorySummary)
|
||||||
|
.where(
|
||||||
|
MemorySummary.user_id == user_id,
|
||||||
|
MemorySummary.summary_at >= start,
|
||||||
|
MemorySummary.summary_at <= end,
|
||||||
|
)
|
||||||
|
.order_by(MemorySummary.summary_at.desc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def _get_high_importance_memories(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
) -> list[UserMemory]:
|
||||||
|
"""Get high importance memories accessed or created today."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserMemory)
|
||||||
|
.where(
|
||||||
|
UserMemory.user_id == user_id,
|
||||||
|
UserMemory.importance_level == "high",
|
||||||
|
((UserMemory.last_accessed_at >= start) | (UserMemory.last_accessed_at.is_(None))),
|
||||||
|
)
|
||||||
|
.order_by(UserMemory.importance_score.desc())
|
||||||
|
.limit(5)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def _get_today_tasks(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
) -> list[Task]:
|
||||||
|
"""Get tasks updated today."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Task)
|
||||||
|
.where(
|
||||||
|
Task.user_id == user_id,
|
||||||
|
Task.updated_at >= start,
|
||||||
|
Task.updated_at <= end,
|
||||||
|
)
|
||||||
|
.order_by(Task.priority.desc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def _generate_summary(
|
||||||
|
self,
|
||||||
|
summaries: list[MemorySummary],
|
||||||
|
memories: list[UserMemory],
|
||||||
|
tasks: list[Task],
|
||||||
|
) -> str:
|
||||||
|
"""Generate daily summary text using LLM."""
|
||||||
|
from app.services.llm_service import get_llm
|
||||||
|
from langchain_core.messages import HumanMessage, SystemMessage
|
||||||
|
|
||||||
|
summary_texts = "\n".join(f"- {s.summary_text}" for s in summaries)
|
||||||
|
memory_texts = "\n".join(f"- [{m.memory_type}] {m.content}" for m in memories[:3])
|
||||||
|
task_texts = "\n".join(f"- [{t.status}] {t.title}" for t in tasks[:5])
|
||||||
|
|
||||||
|
prompt = f"""今天的主要活动摘要:
|
||||||
|
|
||||||
|
对话摘要:
|
||||||
|
{summary_texts or "无"}
|
||||||
|
|
||||||
|
高重要性记忆:
|
||||||
|
{memory_texts or "无"}
|
||||||
|
|
||||||
|
任务:
|
||||||
|
{task_texts or "无"}
|
||||||
|
|
||||||
|
请用1-2句话简洁总结今天的核心活动。不要过度发挥。"""
|
||||||
|
try:
|
||||||
|
llm = get_llm()
|
||||||
|
response = await llm.invoke(
|
||||||
|
[
|
||||||
|
SystemMessage(
|
||||||
|
content="你是一个记忆助手。请简洁总结用户今天的核心活动,不超过50字。"
|
||||||
|
),
|
||||||
|
HumanMessage(content=prompt),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return response.content.strip()
|
||||||
|
except Exception:
|
||||||
|
# Fallback: count activities
|
||||||
|
total = len(summaries) + len(memories) + len(tasks)
|
||||||
|
if total == 0:
|
||||||
|
return "今天没有明显的活动记录。"
|
||||||
|
return f"今天共处理了 {total} 项活动({len(summaries)} 次对话、{len(memories)} 条重要记忆、{len(tasks)} 个任务)。"
|
||||||
|
|
||||||
|
def _extract_key_points(
|
||||||
|
self,
|
||||||
|
memories: list[UserMemory],
|
||||||
|
summaries: list[MemorySummary],
|
||||||
|
tasks: list[Task],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Extract key points from today's activities."""
|
||||||
|
key_points = []
|
||||||
|
|
||||||
|
for m in memories[: self.MAX_KEY_POINTS]:
|
||||||
|
key_points.append(
|
||||||
|
{
|
||||||
|
"content": m.content[:100],
|
||||||
|
"importance": m.importance_score or 0.5,
|
||||||
|
"source": "memory",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for t in tasks[:3]:
|
||||||
|
if len(key_points) >= self.MAX_KEY_POINTS:
|
||||||
|
break
|
||||||
|
key_points.append(
|
||||||
|
{
|
||||||
|
"content": t.title,
|
||||||
|
"importance": t.priority / 10.0 if t.priority else 0.5,
|
||||||
|
"source": "task",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
key_points.sort(key=lambda x: x["importance"], reverse=True)
|
||||||
|
return key_points[: self.MAX_KEY_POINTS]
|
||||||
|
|
||||||
|
def _generate_suggestions(
|
||||||
|
self,
|
||||||
|
memories: list[UserMemory],
|
||||||
|
tasks: list[Task],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Generate suggestions based on today's activities."""
|
||||||
|
suggestions = []
|
||||||
|
|
||||||
|
# Suggest following up on high importance memories
|
||||||
|
for m in memories[:2]:
|
||||||
|
if len(suggestions) >= self.MAX_SUGGESTIONS:
|
||||||
|
break
|
||||||
|
suggestions.append(
|
||||||
|
{
|
||||||
|
"text": f"可以继续聊聊「{m.content[:20]}」相关的话题",
|
||||||
|
"reason": "这是你关心的高优先级话题",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Suggest incomplete high-priority tasks
|
||||||
|
incomplete = [t for t in tasks if t.status != "done"]
|
||||||
|
for t in incomplete[:2]:
|
||||||
|
if len(suggestions) >= self.MAX_SUGGESTIONS:
|
||||||
|
break
|
||||||
|
suggestions.append(
|
||||||
|
{
|
||||||
|
"text": f"继续推进:{t.title}",
|
||||||
|
"reason": "这是未完成的高优先级任务",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return suggestions[: self.MAX_SUGGESTIONS]
|
||||||
|
|
||||||
|
async def get_recent_digests(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
limit: int = 7,
|
||||||
|
) -> list[DailyDigestData]:
|
||||||
|
"""Get recent digests (stored as JSON in user metadata or separate table)."""
|
||||||
|
# For simplicity, return empty list - digests are stored separately
|
||||||
|
# In production, would query a daily_digests table
|
||||||
|
return []
|
||||||
149
backend/app/services/memory/emotion_analyzer.py
Normal file
149
backend/app/services/memory/emotion_analyzer.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""
|
||||||
|
EmotionAnalyzer
|
||||||
|
|
||||||
|
Extracts emotional intensity from text and calculates emotion-based importance scores.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.memory import UserMemory
|
||||||
|
|
||||||
|
|
||||||
|
class EmotionAnalyzer:
|
||||||
|
"""Analyze emotional keywords in memory content"""
|
||||||
|
|
||||||
|
# Emotion keyword weights (higher = more important)
|
||||||
|
EMOTION_KEYWORDS = {
|
||||||
|
# High intensity
|
||||||
|
"急": 1.0,
|
||||||
|
"紧急": 1.0,
|
||||||
|
"很重要": 0.9,
|
||||||
|
"非常重要": 1.0,
|
||||||
|
"必须": 0.9,
|
||||||
|
# Medium-high intensity
|
||||||
|
"困扰": 0.8,
|
||||||
|
"烦恼": 0.7,
|
||||||
|
"担心": 0.7,
|
||||||
|
"焦虑": 0.8,
|
||||||
|
"害怕": 0.8,
|
||||||
|
"恐惧": 0.9,
|
||||||
|
# Medium intensity
|
||||||
|
"想解决": 0.6,
|
||||||
|
"希望": 0.5,
|
||||||
|
"想要": 0.4,
|
||||||
|
"需要": 0.4,
|
||||||
|
"渴望": 0.6,
|
||||||
|
# Low intensity (casual/neutral)
|
||||||
|
"无所谓": 0.1,
|
||||||
|
"随便": 0.1,
|
||||||
|
"都行": 0.1,
|
||||||
|
"还好": 0.2,
|
||||||
|
# Negative valence
|
||||||
|
"讨厌": 0.6,
|
||||||
|
"不喜欢": 0.5,
|
||||||
|
"恨": 0.8,
|
||||||
|
"不喜欢": 0.5,
|
||||||
|
# Positive valence
|
||||||
|
"喜欢": 0.5,
|
||||||
|
"爱": 0.7,
|
||||||
|
"开心": 0.4,
|
||||||
|
"高兴": 0.4,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Urgency patterns — only match when there's an explicit time-bound word
|
||||||
|
# NOTE: "今天" alone is too common and matches neutral sentences like "今天天气不错"
|
||||||
|
URGENCY_PATTERNS = [
|
||||||
|
(re.compile(r"马上|立刻|立即|赶紧"), 1.0),
|
||||||
|
(re.compile(r"今天内|今天必须|今日必须"), 0.8),
|
||||||
|
(re.compile(r"明天|明天之前|明日"), 0.6),
|
||||||
|
(re.compile(r"这周|本周"), 0.4),
|
||||||
|
(re.compile(r"尽快|早点"), 0.7),
|
||||||
|
]
|
||||||
|
|
||||||
|
def extract(self, text: str) -> list[str]:
|
||||||
|
"""Extract emotion keywords from text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matched emotion keywords
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
|
||||||
|
matched = []
|
||||||
|
text_lower = text.lower()
|
||||||
|
|
||||||
|
for keyword, weight in self.EMOTION_KEYWORDS.items():
|
||||||
|
if keyword in text_lower:
|
||||||
|
matched.append(keyword)
|
||||||
|
|
||||||
|
# Check urgency patterns
|
||||||
|
for pattern, weight in self.URGENCY_PATTERNS:
|
||||||
|
if pattern.search(text):
|
||||||
|
matched.append(f"[URGENCY:{weight}]")
|
||||||
|
|
||||||
|
return matched
|
||||||
|
|
||||||
|
def calculate_score(self, memory: "UserMemory") -> float:
|
||||||
|
"""Calculate emotion-based importance score (0.0 - 1.0)
|
||||||
|
|
||||||
|
Uses the highest-weighted emotion keyword found in the content.
|
||||||
|
"""
|
||||||
|
content = memory.content or ""
|
||||||
|
emotion_tags = memory.emotion_tags or []
|
||||||
|
|
||||||
|
# Check emotion_tags first (pre-extracted)
|
||||||
|
if emotion_tags:
|
||||||
|
max_weight = 0.0
|
||||||
|
for tag in emotion_tags:
|
||||||
|
if tag in self.EMOTION_KEYWORDS:
|
||||||
|
max_weight = max(max_weight, self.EMOTION_KEYWORDS[tag])
|
||||||
|
if max_weight > 0:
|
||||||
|
return max_weight
|
||||||
|
|
||||||
|
# Extract from content
|
||||||
|
matched = self.extract(content)
|
||||||
|
if not matched:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Get highest weight
|
||||||
|
max_weight = 0.0
|
||||||
|
for keyword in matched:
|
||||||
|
if keyword.startswith("[URGENCY:"):
|
||||||
|
# Extract urgency weight
|
||||||
|
try:
|
||||||
|
weight = float(keyword.split(":")[1].rstrip("]"))
|
||||||
|
max_weight = max(max_weight, weight)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
elif keyword in self.EMOTION_KEYWORDS:
|
||||||
|
max_weight = max(max_weight, self.EMOTION_KEYWORDS[keyword])
|
||||||
|
|
||||||
|
return min(1.0, max_weight)
|
||||||
|
|
||||||
|
def get_emotion_profile(self, text: str) -> dict:
|
||||||
|
"""Get detailed emotion profile for text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with matched keywords, max_weight, and sentiment
|
||||||
|
"""
|
||||||
|
matched = self.extract(text)
|
||||||
|
weights = []
|
||||||
|
for keyword in matched:
|
||||||
|
if keyword.startswith("[URGENCY:"):
|
||||||
|
try:
|
||||||
|
weights.append(float(keyword.split(":")[1].rstrip("]")))
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
elif keyword in self.EMOTION_KEYWORDS:
|
||||||
|
weights.append(self.EMOTION_KEYWORDS[keyword])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"matched_keywords": matched,
|
||||||
|
"max_weight": max(weights) if weights else 0.0,
|
||||||
|
"avg_weight": sum(weights) / len(weights) if weights else 0.0,
|
||||||
|
"sentiment": "positive"
|
||||||
|
if (weights and sum(weights) / len(weights) > 0.5)
|
||||||
|
else "neutral",
|
||||||
|
}
|
||||||
70
backend/app/services/memory/forgetting_curve.py
Normal file
70
backend/app/services/memory/forgetting_curve.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
ForgettingCurve
|
||||||
|
|
||||||
|
Calculates memory decay based on Ebbinghaus forgetting curve.
|
||||||
|
decay_score = exp(-days_since_access / half_life)
|
||||||
|
|
||||||
|
Importance level affects half-life:
|
||||||
|
- high: half_life = 30 * 3 = 90 days (slowest decay)
|
||||||
|
- medium: half_life = 30 * 1 = 30 days
|
||||||
|
- low: half_life = 30 * 0.5 = 15 days (fastest decay)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.memory import UserMemory
|
||||||
|
|
||||||
|
|
||||||
|
class ForgettingCurve:
|
||||||
|
"""Calculate memory decay based on time and importance."""
|
||||||
|
|
||||||
|
BASE_HALF_LIFE_DAYS = 30
|
||||||
|
|
||||||
|
# Half-life multipliers by importance level
|
||||||
|
HALF_LIFE_MULTIPLIERS = {
|
||||||
|
"high": 3.0,
|
||||||
|
"medium": 1.0,
|
||||||
|
"low": 0.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
def calculate_decay(self, memory: "UserMemory") -> float:
|
||||||
|
"""Calculate decay score (0.0-1.0). Higher = more remembered.
|
||||||
|
|
||||||
|
Uses exponential decay: exp(-days_since_access / half_life)
|
||||||
|
"""
|
||||||
|
last_accessed = getattr(memory, "last_accessed_at", None) or getattr(
|
||||||
|
memory, "last_recalled_at", None
|
||||||
|
)
|
||||||
|
if last_accessed is None:
|
||||||
|
return 1.0 # Never accessed = full retention
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
if isinstance(last_accessed, datetime):
|
||||||
|
if last_accessed.tzinfo is None:
|
||||||
|
last_accessed = last_accessed.replace(tzinfo=UTC)
|
||||||
|
days_since = (now - last_accessed).total_seconds() / 86400
|
||||||
|
else:
|
||||||
|
days_since = 0
|
||||||
|
|
||||||
|
half_life = self.get_half_life(memory)
|
||||||
|
decay = math.exp(-days_since / half_life)
|
||||||
|
return min(1.0, max(0.0, decay))
|
||||||
|
|
||||||
|
def get_half_life(self, memory: "UserMemory") -> float:
|
||||||
|
"""Get half-life in days based on importance level."""
|
||||||
|
importance_level = getattr(memory, "importance_level", "medium") or "medium"
|
||||||
|
multiplier = self.HALF_LIFE_MULTIPLIERS.get(importance_level, 1.0)
|
||||||
|
return self.BASE_HALF_LIFE_DAYS * multiplier
|
||||||
|
|
||||||
|
def should_archive(self, memory: "UserMemory") -> bool:
|
||||||
|
"""decay < 0.2 → memory should be archived (cold storage)."""
|
||||||
|
decay = self.calculate_decay(memory)
|
||||||
|
return decay < 0.2
|
||||||
|
|
||||||
|
def should_deprioritize(self, memory: "UserMemory") -> bool:
|
||||||
|
"""decay < 0.5 → memory should be deprioritized (not in active reminders)."""
|
||||||
|
decay = self.calculate_decay(memory)
|
||||||
|
return decay < 0.5
|
||||||
84
backend/app/services/memory/frequency_tracker.py
Normal file
84
backend/app/services/memory/frequency_tracker.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""
|
||||||
|
FrequencyTracker
|
||||||
|
|
||||||
|
Tracks how often a memory is recalled and calculates frequency/recency scores.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.memory import UserMemory
|
||||||
|
|
||||||
|
|
||||||
|
class FrequencyTracker:
|
||||||
|
"""Track and score memory recall frequency"""
|
||||||
|
|
||||||
|
# Score weights
|
||||||
|
MAX_FREQUENCY = 10 # Cap frequency count for scoring
|
||||||
|
RECENCY_DECAY_DAYS = 30 # After 30 days, recency score drops significantly
|
||||||
|
|
||||||
|
def increment(self, memory: "UserMemory") -> "UserMemory":
|
||||||
|
"""Increment recall count and update last recalled timestamp"""
|
||||||
|
memory.frequency_count = (memory.frequency_count or 0) + 1
|
||||||
|
memory.last_recalled_at = datetime.now(UTC)
|
||||||
|
return memory
|
||||||
|
|
||||||
|
def get_frequency_score(self, memory: "UserMemory") -> float:
|
||||||
|
"""Calculate normalized frequency score (0.0 - 1.0)
|
||||||
|
|
||||||
|
Uses logarithmic scaling to prevent high-frequency memories
|
||||||
|
from dominating completely.
|
||||||
|
"""
|
||||||
|
count = memory.frequency_count or 0
|
||||||
|
if count == 0:
|
||||||
|
return 0.0
|
||||||
|
# Logarithmic scaling: more recalls have diminishing returns
|
||||||
|
# log(1+x) / log(1+MAX) gives 0-1 range
|
||||||
|
import math
|
||||||
|
|
||||||
|
score = math.log(1 + count) / math.log(1 + self.MAX_FREQUENCY)
|
||||||
|
return min(1.0, max(0.0, score))
|
||||||
|
|
||||||
|
def get_recency_score(self, memory: "UserMemory") -> float:
|
||||||
|
"""Calculate recency score (0.0 - 1.0)
|
||||||
|
|
||||||
|
Memory recalled recently scores higher. Uses exponential decay.
|
||||||
|
"""
|
||||||
|
last_recalled = memory.last_recalled_at
|
||||||
|
if last_recalled is None:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
if isinstance(last_recalled, datetime):
|
||||||
|
if last_recalled.tzinfo is None:
|
||||||
|
last_recalled = last_recalled.replace(tzinfo=UTC)
|
||||||
|
days_since = (now - last_recalled).total_seconds() / 86400
|
||||||
|
else:
|
||||||
|
days_since = self.RECENCY_DECAY_DAYS
|
||||||
|
|
||||||
|
# Exponential decay: half-life of RECENCY_DECAY_DAYS
|
||||||
|
import math
|
||||||
|
|
||||||
|
decay = math.exp(-days_since / self.RECENCY_DECAY_DAYS)
|
||||||
|
return min(1.0, max(0.0, decay))
|
||||||
|
|
||||||
|
def get_time_decay(self, memory: "UserMemory") -> float:
|
||||||
|
"""Calculate time-based decay factor for forgetting curve"""
|
||||||
|
last_accessed = getattr(memory, "last_accessed_at", None)
|
||||||
|
if last_accessed is None:
|
||||||
|
last_accessed = memory.last_recalled_at
|
||||||
|
if last_accessed is None:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
if isinstance(last_accessed, datetime):
|
||||||
|
if last_accessed.tzinfo is None:
|
||||||
|
last_accessed = last_accessed.replace(tzinfo=UTC)
|
||||||
|
days_since = (now - last_accessed).total_seconds() / 86400
|
||||||
|
else:
|
||||||
|
days_since = 0
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
return math.exp(-days_since / self.RECENCY_DECAY_DAYS)
|
||||||
52
backend/app/services/memory/impact_evaluator.py
Normal file
52
backend/app/services/memory/impact_evaluator.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""
|
||||||
|
ImpactEvaluator
|
||||||
|
|
||||||
|
Evaluates the breadth of impact a memory has based on associated topics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.memory import UserMemory
|
||||||
|
|
||||||
|
|
||||||
|
class ImpactEvaluator:
|
||||||
|
"""Evaluate the impact breadth of a memory"""
|
||||||
|
|
||||||
|
# Threshold for maximum impact score
|
||||||
|
IMPACT_THRESHOLD = 5 # Number of associated topics for max impact
|
||||||
|
|
||||||
|
def evaluate(self, memory: "UserMemory") -> float:
|
||||||
|
"""Calculate impact score (0.0 - 1.0)
|
||||||
|
|
||||||
|
The more associated topics a memory has, the higher its impact.
|
||||||
|
Topics represent "what this memory is about" — if it touches
|
||||||
|
many aspects of the user's life, it has high impact.
|
||||||
|
"""
|
||||||
|
associated_topics = memory.associated_topics or []
|
||||||
|
if not associated_topics:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Normalize: IMPACT_THRESHOLD topics = full impact (1.0)
|
||||||
|
raw_score = len(associated_topics) / self.IMPACT_THRESHOLD
|
||||||
|
return min(1.0, raw_score)
|
||||||
|
|
||||||
|
def get_topic_overlap(self, memory_a: "UserMemory", memory_b: "UserMemory") -> float:
|
||||||
|
"""Calculate topic overlap between two memories (0.0 - 1.0)
|
||||||
|
|
||||||
|
Used for finding related memories.
|
||||||
|
"""
|
||||||
|
topics_a = set(memory_a.associated_topics or [])
|
||||||
|
topics_b = set(memory_b.associated_topics or [])
|
||||||
|
|
||||||
|
if not topics_a or not topics_b:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
intersection = topics_a & topics_b
|
||||||
|
union = topics_a | topics_b
|
||||||
|
|
||||||
|
return len(intersection) / len(union) if union else 0.0
|
||||||
|
|
||||||
|
def rank_by_impact(self, memories: list["UserMemory"]) -> list["UserMemory"]:
|
||||||
|
"""Rank memories by impact score (descending)"""
|
||||||
|
return sorted(memories, key=lambda m: self.evaluate(m), reverse=True)
|
||||||
103
backend/app/services/memory/importance_scorer.py
Normal file
103
backend/app/services/memory/importance_scorer.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""
|
||||||
|
ImportanceScorer
|
||||||
|
|
||||||
|
Composite importance scoring combining frequency, recency, emotion, and impact.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from app.services.memory.frequency_tracker import FrequencyTracker
|
||||||
|
from app.services.memory.emotion_analyzer import EmotionAnalyzer
|
||||||
|
from app.services.memory.impact_evaluator import ImpactEvaluator
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.memory import UserMemory
|
||||||
|
|
||||||
|
|
||||||
|
class ImportanceLevel(str, Enum):
|
||||||
|
"""Importance level classification"""
|
||||||
|
|
||||||
|
HIGH = "high"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
LOW = "low"
|
||||||
|
|
||||||
|
|
||||||
|
class ImportanceScorer:
|
||||||
|
"""Calculate composite importance score for memories
|
||||||
|
|
||||||
|
Score formula:
|
||||||
|
frequency_score * 0.35 +
|
||||||
|
recency_score * 0.20 +
|
||||||
|
emotion_score * 0.25 +
|
||||||
|
impact_score * 0.20
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Score weights
|
||||||
|
WEIGHT_FREQUENCY = 0.35
|
||||||
|
WEIGHT_RECENCY = 0.20
|
||||||
|
WEIGHT_EMOTION = 0.25
|
||||||
|
WEIGHT_IMPACT = 0.20
|
||||||
|
|
||||||
|
# Escalation threshold
|
||||||
|
HIGH_THRESHOLD = 0.8
|
||||||
|
MEDIUM_THRESHOLD = 0.5
|
||||||
|
LOW_THRESHOLD = 0.0
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.tracker = FrequencyTracker()
|
||||||
|
self.emotion_analyzer = EmotionAnalyzer()
|
||||||
|
self.impact_evaluator = ImpactEvaluator()
|
||||||
|
|
||||||
|
def calculate_score(self, memory: "UserMemory") -> float:
|
||||||
|
"""Calculate composite importance score (0.0 - 1.0)"""
|
||||||
|
frequency = self.tracker.get_frequency_score(memory) * self.WEIGHT_FREQUENCY
|
||||||
|
recency = self.tracker.get_recency_score(memory) * self.WEIGHT_RECENCY
|
||||||
|
emotion = self.emotion_analyzer.calculate_score(memory) * self.WEIGHT_EMOTION
|
||||||
|
impact = self.impact_evaluator.evaluate(memory) * self.WEIGHT_IMPACT
|
||||||
|
|
||||||
|
total = frequency + recency + emotion + impact
|
||||||
|
return round(min(1.0, max(0.0, total)), 3)
|
||||||
|
|
||||||
|
def get_importance_level(self, score: float) -> ImportanceLevel:
|
||||||
|
"""Classify importance score into level"""
|
||||||
|
if score >= self.HIGH_THRESHOLD:
|
||||||
|
return ImportanceLevel.HIGH
|
||||||
|
elif score >= self.MEDIUM_THRESHOLD:
|
||||||
|
return ImportanceLevel.MEDIUM
|
||||||
|
else:
|
||||||
|
return ImportanceLevel.LOW
|
||||||
|
|
||||||
|
def should_escalate(self, memory: "UserMemory") -> bool:
|
||||||
|
"""Check if a memory should be escalated (promoted to higher importance)
|
||||||
|
|
||||||
|
A memory should escalate if:
|
||||||
|
- Score exceeds HIGH_THRESHOLD
|
||||||
|
- Emotion analysis shows high intensity keywords
|
||||||
|
"""
|
||||||
|
score = self.calculate_score(memory)
|
||||||
|
if score >= self.HIGH_THRESHOLD:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check emotion intensity
|
||||||
|
emotion_score = self.emotion_analyzer.calculate_score(memory)
|
||||||
|
if emotion_score >= 0.9:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def score_and_classify(self, memory: "UserMemory") -> tuple[float, ImportanceLevel]:
|
||||||
|
"""Calculate score and classify in one call"""
|
||||||
|
score = self.calculate_score(memory)
|
||||||
|
level = self.get_importance_level(score)
|
||||||
|
return score, level
|
||||||
|
|
||||||
|
def update_memory_importance(self, memory: "UserMemory") -> "UserMemory":
|
||||||
|
"""Update memory's importance_score and importance_level fields
|
||||||
|
|
||||||
|
Returns the updated memory.
|
||||||
|
"""
|
||||||
|
score, level = self.score_and_classify(memory)
|
||||||
|
memory.importance_score = score
|
||||||
|
memory.importance_level = level.value if isinstance(level, ImportanceLevel) else level
|
||||||
|
return memory
|
||||||
81
backend/app/services/memory/memory_decay.py
Normal file
81
backend/app/services/memory/memory_decay.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""
|
||||||
|
MemoryDecay
|
||||||
|
|
||||||
|
Handles memory archiving, deprioritization, and restoration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.memory import UserMemory
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryDecay:
|
||||||
|
"""Handle memory archiving and deprioritization decisions."""
|
||||||
|
|
||||||
|
ARCHIVE_THRESHOLD = 0.2
|
||||||
|
DEPRIORITIZE_THRESHOLD = 0.5
|
||||||
|
|
||||||
|
def evaluate(self, memory: "UserMemory") -> dict:
|
||||||
|
"""Evaluate memory and return action recommendation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with keys: decay_score, should_archive, should_deprioritize, action
|
||||||
|
"""
|
||||||
|
from app.services.memory.forgetting_curve import ForgettingCurve
|
||||||
|
|
||||||
|
curve = ForgettingCurve()
|
||||||
|
decay_score = curve.calculate_decay(memory)
|
||||||
|
archive = decay_score < self.ARCHIVE_THRESHOLD
|
||||||
|
deprioritize = decay_score < self.DEPRIORITIZE_THRESHOLD
|
||||||
|
|
||||||
|
if archive:
|
||||||
|
action = "archive"
|
||||||
|
elif deprioritize:
|
||||||
|
action = "deprioritize"
|
||||||
|
else:
|
||||||
|
action = "keep_active"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"decay_score": decay_score,
|
||||||
|
"should_archive": archive,
|
||||||
|
"should_deprioritize": deprioritize,
|
||||||
|
"action": action,
|
||||||
|
}
|
||||||
|
|
||||||
|
def archive_memory(self, memory: "UserMemory") -> "UserMemory":
|
||||||
|
"""Archive a memory (set is_archived=True, reset decay_score to low value).
|
||||||
|
|
||||||
|
Archived memories are moved to cold storage and not included in
|
||||||
|
active reminders or context injection.
|
||||||
|
"""
|
||||||
|
memory.is_archived = True
|
||||||
|
memory.decay_score = 0.1 # Very low, will be restored on access
|
||||||
|
memory.archive_at = datetime.now(UTC)
|
||||||
|
return memory
|
||||||
|
|
||||||
|
def deprioritize_memory(self, memory: "UserMemory") -> "UserMemory":
|
||||||
|
"""Mark a memory as deprioritized (excluded from active reminders).
|
||||||
|
|
||||||
|
Unlike archival, the memory is still accessible and included in
|
||||||
|
context injection if relevant.
|
||||||
|
"""
|
||||||
|
# Just update decay_score, the importance_level already encodes priority
|
||||||
|
from app.services.memory.forgetting_curve import ForgettingCurve
|
||||||
|
|
||||||
|
curve = ForgettingCurve()
|
||||||
|
memory.decay_score = curve.calculate_decay(memory)
|
||||||
|
return memory
|
||||||
|
|
||||||
|
def restore_from_archive(self, memory: "UserMemory") -> "UserMemory":
|
||||||
|
"""Restore a memory from archive.
|
||||||
|
|
||||||
|
Resets is_archived=False and decay_score=0.8 (strong retention).
|
||||||
|
The memory is moved back to hot storage.
|
||||||
|
"""
|
||||||
|
memory.is_archived = False
|
||||||
|
memory.decay_score = 0.8 # Strong retention after restore
|
||||||
|
memory.last_accessed_at = datetime.now(UTC)
|
||||||
|
memory.archive_at = None
|
||||||
|
return memory
|
||||||
239
backend/app/services/memory/memory_extractor.py
Normal file
239
backend/app/services/memory/memory_extractor.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
"""
|
||||||
|
MemoryExtractor
|
||||||
|
|
||||||
|
Automatically extracts memories from conversations using LLM.
|
||||||
|
Extracts 5 types: fact, preference, goal, pain_point, event.
|
||||||
|
Deduplicates against existing memories (similarity > 0.85 → reinforce instead of create).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.conversation import Message
|
||||||
|
from app.models.memory import UserMemory
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MEMORY_TYPES = ("fact", "preference", "goal", "pain_point", "event")
|
||||||
|
|
||||||
|
EXTRACT_PROMPT = """从以下对话中提取用户的记忆信息,以 JSON 格式返回。
|
||||||
|
|
||||||
|
对话内容:
|
||||||
|
{conversation_text}
|
||||||
|
|
||||||
|
提取以下类型(只提取明确信息,不要猜测):
|
||||||
|
- fact: 关于用户的客观事实(职业、 location、技能、健康状况等)
|
||||||
|
- preference: 用户的偏好和习惯(回答风格偏好、沟通偏好、生活习惯等)
|
||||||
|
- goal: 用户提到的目标或计划(想做什么、计划做什么、目标是什么)
|
||||||
|
- pain_point: 反复出现或明显困扰用户的问题
|
||||||
|
- event: 今天发生的重要事件
|
||||||
|
|
||||||
|
输出格式(只输出 JSON,不要其他内容):
|
||||||
|
[
|
||||||
|
{{"type": "fact", "content": "...", "confidence": 0.9}},
|
||||||
|
{{"type": "goal", "content": "...", "confidence": 0.7}}
|
||||||
|
]"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExtractedMemory:
|
||||||
|
"""A memory extracted from conversation."""
|
||||||
|
|
||||||
|
memory_type: str # "fact" | "preference" | "goal" | "pain_point" | "event"
|
||||||
|
content: str
|
||||||
|
confidence: float # 0.0-1.0
|
||||||
|
source_conversation_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryExtractor:
|
||||||
|
"""Extract memories from conversations using LLM."""
|
||||||
|
|
||||||
|
SIMILARITY_THRESHOLD = 0.85
|
||||||
|
|
||||||
|
async def extract_from_conversation(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
conversation_id: str,
|
||||||
|
messages: list[Message],
|
||||||
|
) -> list[ExtractedMemory]:
|
||||||
|
"""Extract memories from conversation messages.
|
||||||
|
|
||||||
|
1. Build conversation text
|
||||||
|
2. Call LLM to extract memories
|
||||||
|
3. Parse JSON response
|
||||||
|
4. Deduplicate against existing memories
|
||||||
|
5. Return new memories
|
||||||
|
"""
|
||||||
|
if len(messages) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 1. Build conversation text
|
||||||
|
conversation_text = "\n".join(f"[{m.role}] {m.content}" for m in messages[-10:])
|
||||||
|
|
||||||
|
# 2. Call LLM
|
||||||
|
extracted = await self._call_llm_extract(conversation_text)
|
||||||
|
if not extracted:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 3. Build ExtractedMemory objects
|
||||||
|
new_memories = [
|
||||||
|
ExtractedMemory(
|
||||||
|
memory_type=m["type"],
|
||||||
|
content=m["content"],
|
||||||
|
confidence=m.get("confidence", 0.5),
|
||||||
|
source_conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
for m in extracted
|
||||||
|
if m.get("type") in MEMORY_TYPES and m.get("content")
|
||||||
|
]
|
||||||
|
|
||||||
|
# 4. Deduplicate
|
||||||
|
new_memories = await self._deduplicate(db, user_id, new_memories)
|
||||||
|
|
||||||
|
return new_memories
|
||||||
|
|
||||||
|
async def _call_llm_extract(self, conversation_text: str) -> list[dict]:
|
||||||
|
"""Call LLM to extract memories from conversation text."""
|
||||||
|
from app.services.llm_service import get_llm
|
||||||
|
from langchain_core.messages import HumanMessage, SystemMessage
|
||||||
|
|
||||||
|
prompt = EXTRACT_PROMPT.format(conversation_text=conversation_text)
|
||||||
|
|
||||||
|
try:
|
||||||
|
llm = get_llm()
|
||||||
|
response = await llm.invoke(
|
||||||
|
[
|
||||||
|
SystemMessage(
|
||||||
|
content="你是一个记忆提取助手。从对话中提取用户的记忆信息,只返回JSON数组,不要其他内容。"
|
||||||
|
),
|
||||||
|
HumanMessage(content=prompt),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
content = response.content.strip()
|
||||||
|
|
||||||
|
# Try to extract JSON from response
|
||||||
|
if content.startswith("["):
|
||||||
|
return json.loads(content)
|
||||||
|
# Try to find JSON in response
|
||||||
|
start = content.find("[")
|
||||||
|
end = content.rfind("]") + 1
|
||||||
|
if start != -1 and end != 0:
|
||||||
|
return json.loads(content[start:end])
|
||||||
|
return []
|
||||||
|
except (json.JSONDecodeError, Exception) as e:
|
||||||
|
logger.warning(f"Memory extraction LLM call failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _deduplicate(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
new_memories: list[ExtractedMemory],
|
||||||
|
) -> list[ExtractedMemory]:
|
||||||
|
"""Filter duplicates against existing UserMemory.
|
||||||
|
|
||||||
|
Similarity > 0.85 → reinforce existing instead of creating new.
|
||||||
|
Returns only truly new memories.
|
||||||
|
"""
|
||||||
|
if not new_memories:
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserMemory)
|
||||||
|
.where(
|
||||||
|
UserMemory.user_id == user_id,
|
||||||
|
UserMemory.is_archived == False,
|
||||||
|
)
|
||||||
|
.limit(20)
|
||||||
|
)
|
||||||
|
existing = list(result.scalars().all())
|
||||||
|
|
||||||
|
deduplicated = []
|
||||||
|
for new_mem in new_memories:
|
||||||
|
is_duplicate = False
|
||||||
|
for existing_mem in existing:
|
||||||
|
if self._is_similar(new_mem.content, existing_mem.content):
|
||||||
|
# Reinforce existing memory instead of creating new
|
||||||
|
await self._reinforce_existing(db, existing_mem)
|
||||||
|
is_duplicate = True
|
||||||
|
break
|
||||||
|
if not is_duplicate:
|
||||||
|
deduplicated.append(new_mem)
|
||||||
|
|
||||||
|
return deduplicated
|
||||||
|
|
||||||
|
def _is_similar(self, text1: str, text2: str) -> bool:
|
||||||
|
"""Simple similarity check using keyword overlap.
|
||||||
|
|
||||||
|
In production would use embedding similarity.
|
||||||
|
"""
|
||||||
|
# Simple word overlap ratio
|
||||||
|
words1 = set(text1.lower().split())
|
||||||
|
words2 = set(text2.lower().split())
|
||||||
|
if not words1 or not words2:
|
||||||
|
return False
|
||||||
|
|
||||||
|
overlap = len(words1 & words2)
|
||||||
|
union = len(words1 | words2)
|
||||||
|
jaccard = overlap / union if union > 0 else 0
|
||||||
|
|
||||||
|
# Also check substring
|
||||||
|
if jaccard > 0.5:
|
||||||
|
return True
|
||||||
|
if len(text1) > 5 and len(text2) > 5:
|
||||||
|
if text1[:20].lower() == text2[:20].lower():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _reinforce_existing(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
memory: UserMemory,
|
||||||
|
) -> None:
|
||||||
|
"""Reinforce an existing memory instead of creating a duplicate."""
|
||||||
|
from app.services.memory.reinforcement import MemoryReinforcement
|
||||||
|
|
||||||
|
reinforcement = MemoryReinforcement()
|
||||||
|
reinforcement.trigger(memory)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def save_memories(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
conversation_id: str,
|
||||||
|
memories: list[ExtractedMemory],
|
||||||
|
) -> list[UserMemory]:
|
||||||
|
"""Save extracted memories as UserMemory records."""
|
||||||
|
from app.services.memory.importance_scorer import ImportanceScorer
|
||||||
|
|
||||||
|
saved = []
|
||||||
|
scorer = ImportanceScorer()
|
||||||
|
|
||||||
|
for mem in memories:
|
||||||
|
user_mem = UserMemory(
|
||||||
|
user_id=user_id,
|
||||||
|
memory_type=mem.memory_type,
|
||||||
|
content=mem.content,
|
||||||
|
source_conversation_id=mem.source_conversation_id,
|
||||||
|
importance_score=0.5, # Will be updated by scorer
|
||||||
|
importance_level="medium",
|
||||||
|
)
|
||||||
|
# Update importance based on content
|
||||||
|
scorer.update_memory_importance(user_mem)
|
||||||
|
|
||||||
|
db.add(user_mem)
|
||||||
|
saved.append(user_mem)
|
||||||
|
|
||||||
|
if saved:
|
||||||
|
await db.commit()
|
||||||
|
for mem in saved:
|
||||||
|
await db.refresh(mem)
|
||||||
|
|
||||||
|
return saved
|
||||||
136
backend/app/services/memory/proactive_informer.py
Normal file
136
backend/app/services/memory/proactive_informer.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""
|
||||||
|
ProactiveInformer
|
||||||
|
|
||||||
|
Checks conversation context and proactively informs user of relevant memories.
|
||||||
|
Only fires probabilistically based on trigger type.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
# Trigger types and their firing probabilities
|
||||||
|
TRIGGERS = {
|
||||||
|
"high_importance_topic": {"probability": 0.8},
|
||||||
|
"repeat_question": {"probability": 1.0},
|
||||||
|
"forgotten_context": {"probability": 0.5},
|
||||||
|
"pending_goal": {"probability": 0.3},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Message style templates for proactive informing
|
||||||
|
INFORM_STYLE = {
|
||||||
|
"casual": "对了,你之前提到...",
|
||||||
|
"gentle": "不知道你有没有注意到...",
|
||||||
|
"helpful": "我记起你关心这个,要不看看...",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keywords that indicate different trigger types
|
||||||
|
TRIGGER_KEYWORDS = {
|
||||||
|
"high_importance_topic": ["关于", "提到", "说过", "记得"],
|
||||||
|
"repeat_question": ["之前", "上次", "以前", "好像问过"],
|
||||||
|
"forgotten_context": ["忘了", "不记得", "记不清", "之前聊过"],
|
||||||
|
"pending_goal": ["目标", "计划", "想做", "打算", "想学"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ProactiveInformer:
|
||||||
|
"""Proactively inform users of relevant memories based on conversation context."""
|
||||||
|
|
||||||
|
def should_inform(self, trigger_type: str) -> bool:
|
||||||
|
"""Check if should inform based on probability."""
|
||||||
|
if trigger_type not in TRIGGERS:
|
||||||
|
return False
|
||||||
|
probability = TRIGGERS[trigger_type]["probability"]
|
||||||
|
return random.random() < probability
|
||||||
|
|
||||||
|
def detect_trigger(self, message: str) -> str | None:
|
||||||
|
"""Detect which trigger type (if any) this message corresponds to."""
|
||||||
|
msg_lower = message.lower()
|
||||||
|
for trigger_type, keywords in TRIGGER_KEYWORDS.items():
|
||||||
|
for keyword in keywords:
|
||||||
|
if keyword in msg_lower:
|
||||||
|
return trigger_type
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_inform_message(self, trigger_type: str, context: dict) -> str:
|
||||||
|
"""Generate natural proactive message based on trigger type."""
|
||||||
|
style = INFORM_STYLE.get(context.get("style", "casual"), INFORM_STYLE["casual"])
|
||||||
|
|
||||||
|
if trigger_type == "high_importance_topic":
|
||||||
|
memory_content = context.get("memory_content", "")
|
||||||
|
return f"{style}「{memory_content[:30]}」这个话题你很关心,要深入聊聊吗?"
|
||||||
|
|
||||||
|
elif trigger_type == "repeat_question":
|
||||||
|
return "你之前问过类似的问题,我之前的回答可能还有参考价值。"
|
||||||
|
|
||||||
|
elif trigger_type == "forgotten_context":
|
||||||
|
memory_content = context.get("memory_content", "")
|
||||||
|
return f"这个话题你一个月前聊过:「{memory_content[:30]}」。要恢复一下吗?"
|
||||||
|
|
||||||
|
elif trigger_type == "pending_goal":
|
||||||
|
goal_content = context.get("goal_content", "")
|
||||||
|
return f"你之前说要「{goal_content[:30]}」,有进展了吗?"
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
async def check_and_inform(
|
||||||
|
self,
|
||||||
|
db: "AsyncSession",
|
||||||
|
user_id: str,
|
||||||
|
current_message: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""Main entry point.
|
||||||
|
|
||||||
|
Returns proactive message if should inform, else None.
|
||||||
|
"""
|
||||||
|
# 1. Detect trigger type
|
||||||
|
trigger_type = self.detect_trigger(current_message)
|
||||||
|
if not trigger_type:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 2. Check probability
|
||||||
|
if not self.should_inform(trigger_type):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 3. Fetch relevant context
|
||||||
|
context = await self._fetch_trigger_context(db, user_id, trigger_type)
|
||||||
|
if not context:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 4. Generate message
|
||||||
|
return self.get_inform_message(trigger_type, context)
|
||||||
|
|
||||||
|
async def _fetch_trigger_context(
|
||||||
|
self,
|
||||||
|
db: "AsyncSession",
|
||||||
|
user_id: str,
|
||||||
|
trigger_type: str,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Fetch relevant context for the trigger type."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models.memory import UserMemory
|
||||||
|
|
||||||
|
# Get most recent high-importance memory
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserMemory)
|
||||||
|
.where(
|
||||||
|
UserMemory.user_id == user_id,
|
||||||
|
UserMemory.importance_level == "high",
|
||||||
|
UserMemory.is_archived == False,
|
||||||
|
)
|
||||||
|
.order_by(UserMemory.last_accessed_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
memory = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not memory:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"memory_content": memory.content,
|
||||||
|
"style": "casual",
|
||||||
|
"memory_id": str(memory.id),
|
||||||
|
}
|
||||||
168
backend/app/services/memory/recall_injector.py
Normal file
168
backend/app/services/memory/recall_injector.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""
|
||||||
|
MemoryRecallInjector
|
||||||
|
|
||||||
|
Injects relevant memories into LLM system prompt before response generation.
|
||||||
|
Token budget: 800 by default (configurable).
|
||||||
|
Priority: pain_point > goal > preference > fact > event
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.models.memory import UserMemory
|
||||||
|
|
||||||
|
|
||||||
|
MEMORY_TYPE_PRIORITY = {
|
||||||
|
"pain_point": 1,
|
||||||
|
"goal": 2,
|
||||||
|
"preference": 3,
|
||||||
|
"fact": 4,
|
||||||
|
"event": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_TOKEN_BUDGET = 800
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryRecallInjector:
|
||||||
|
"""Inject relevant memories into system prompt with token budget control."""
|
||||||
|
|
||||||
|
def __init__(self, token_budget: int = DEFAULT_TOKEN_BUDGET):
|
||||||
|
self.token_budget = token_budget
|
||||||
|
|
||||||
|
async def build_context(
|
||||||
|
self,
|
||||||
|
db: "AsyncSession",
|
||||||
|
user_id: str,
|
||||||
|
current_message: str,
|
||||||
|
token_budget: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build memory context string for injection into system prompt.
|
||||||
|
|
||||||
|
1. Recall relevant memories (top_k=20)
|
||||||
|
2. Filter out archived memories
|
||||||
|
3. Rank by importance × relevance × type priority
|
||||||
|
4. Select within token budget
|
||||||
|
5. Format as system prompt fragment
|
||||||
|
"""
|
||||||
|
budget = token_budget or self.token_budget
|
||||||
|
|
||||||
|
# 1. Recall candidates using existing memory service
|
||||||
|
candidates = await recall_user_memories_for_injection(
|
||||||
|
db, user_id, current_message, top_k=20
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Filter archived and non-UserMemory
|
||||||
|
active = [
|
||||||
|
m
|
||||||
|
for m in candidates
|
||||||
|
if isinstance(m, UserMemory) and not getattr(m, "is_archived", False)
|
||||||
|
]
|
||||||
|
|
||||||
|
# 3. Rank
|
||||||
|
ranked = self._rank(active, current_message)
|
||||||
|
|
||||||
|
# 4. Budget select
|
||||||
|
selected = self._budget_select(ranked, budget)
|
||||||
|
|
||||||
|
# 5. Format
|
||||||
|
return self._format(selected)
|
||||||
|
|
||||||
|
def _rank(
|
||||||
|
self,
|
||||||
|
memories: list["UserMemory"],
|
||||||
|
query: str,
|
||||||
|
) -> list["UserMemory"]:
|
||||||
|
"""Rank memories by: relevance * 0.6 + importance * 0.4 * type_boost.
|
||||||
|
|
||||||
|
pain_point/goal get 1.0 type boost, others get 0.8.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def score(m: "UserMemory") -> float:
|
||||||
|
relevance = (
|
||||||
|
getattr(m, "similarity_score", 0.5) if hasattr(m, "similarity_score") else 0.5
|
||||||
|
)
|
||||||
|
importance = getattr(m, "importance_score", 0.5) or 0.5
|
||||||
|
mem_type = getattr(m, "memory_type", None) or "fact"
|
||||||
|
type_boost = 1.0 if mem_type in ("goal", "pain_point") else 0.8
|
||||||
|
return relevance * 0.6 + importance * 0.4 * type_boost
|
||||||
|
|
||||||
|
return sorted(memories, key=score, reverse=True)
|
||||||
|
|
||||||
|
def _budget_select(
|
||||||
|
self,
|
||||||
|
memories: list["UserMemory"],
|
||||||
|
token_budget: int,
|
||||||
|
) -> list["UserMemory"]:
|
||||||
|
"""Greedy selection until token budget runs out.
|
||||||
|
|
||||||
|
Rough estimate: 1 token ≈ 2 characters, fixed overhead = 20 tokens.
|
||||||
|
"""
|
||||||
|
selected = []
|
||||||
|
used = 20 # "[关于你的记忆]\n"
|
||||||
|
for m in memories:
|
||||||
|
content = getattr(m, "content", "") or ""
|
||||||
|
cost = len(content) // 2 + 10
|
||||||
|
if used + cost > token_budget:
|
||||||
|
break
|
||||||
|
selected.append(m)
|
||||||
|
used += cost
|
||||||
|
return selected
|
||||||
|
|
||||||
|
def _format(self, memories: list["UserMemory"]) -> str:
|
||||||
|
"""Format memories as system prompt fragment."""
|
||||||
|
if not memories:
|
||||||
|
return ""
|
||||||
|
lines = ["[关于你的记忆]"]
|
||||||
|
for m in memories:
|
||||||
|
mem_type = getattr(m, "memory_type", None) or ""
|
||||||
|
content = getattr(m, "content", "") or ""
|
||||||
|
type_label = f"[{mem_type}]" if mem_type else ""
|
||||||
|
lines.append(f"- {type_label} {content}".strip())
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def recall_user_memories_for_injection(
|
||||||
|
db: "AsyncSession",
|
||||||
|
user_id: str,
|
||||||
|
query: str,
|
||||||
|
top_k: int = 5,
|
||||||
|
) -> list:
|
||||||
|
"""Recall user memories for injection (used by MemoryRecallInjector).
|
||||||
|
|
||||||
|
This is a simplified version of recall_user_memories that returns
|
||||||
|
UserMemory objects directly instead of dicts.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models.memory import UserMemory
|
||||||
|
|
||||||
|
def _extract_query_tokens(q: str) -> list[str]:
|
||||||
|
normalized = (q or "").lower()
|
||||||
|
tokens = [token for token in re.findall(r"[a-z0-9]+", normalized) if len(token) >= 3]
|
||||||
|
for chunk in re.findall(r"[\u4e00-\u9fff]+", q or ""):
|
||||||
|
stripped = chunk.strip()
|
||||||
|
if len(stripped) >= 4:
|
||||||
|
tokens.append(stripped)
|
||||||
|
return list(dict.fromkeys(tokens))
|
||||||
|
|
||||||
|
query_tokens = _extract_query_tokens(query)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserMemory)
|
||||||
|
.where(UserMemory.user_id == user_id)
|
||||||
|
.order_by(UserMemory.importance_score.desc(), UserMemory.created_at.desc())
|
||||||
|
)
|
||||||
|
memories = list(result.scalars().all())
|
||||||
|
|
||||||
|
if query_tokens:
|
||||||
|
matched = [
|
||||||
|
m
|
||||||
|
for m in memories
|
||||||
|
if any(token in ((getattr(m, "content", "") or "").lower()) for token in query_tokens)
|
||||||
|
]
|
||||||
|
if matched:
|
||||||
|
return matched[:top_k]
|
||||||
|
return memories[:top_k]
|
||||||
|
|
||||||
|
return memories[:top_k]
|
||||||
72
backend/app/services/memory/reinforcement.py
Normal file
72
backend/app/services/memory/reinforcement.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
MemoryReinforcement
|
||||||
|
|
||||||
|
Triggers memory reinforcement on recall and handles auto-reinforcement.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.memory import UserMemory
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryReinforcement:
|
||||||
|
"""Reinforce memories on recall to prevent forgetting."""
|
||||||
|
|
||||||
|
MAX_FREQUENCY = 10
|
||||||
|
AUTO_REINFORCE_BOOST = 1.1 # 10% boost per week
|
||||||
|
|
||||||
|
def trigger(self, memory: "UserMemory") -> "UserMemory":
|
||||||
|
"""Called when memory is recalled: reset decay_score, increment frequency.
|
||||||
|
|
||||||
|
This is the core reinforcement mechanism - each recall makes the memory
|
||||||
|
stickier by resetting its decay curve and incrementing frequency.
|
||||||
|
"""
|
||||||
|
from app.services.memory.forgetting_curve import ForgettingCurve
|
||||||
|
|
||||||
|
# Increment frequency count (capped at MAX_FREQUENCY)
|
||||||
|
current_freq = getattr(memory, "frequency_count", 0) or 0
|
||||||
|
memory.frequency_count = min(current_freq + 1, self.MAX_FREQUENCY)
|
||||||
|
|
||||||
|
# Update last accessed time
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
memory.last_accessed_at = now
|
||||||
|
memory.last_recalled_at = now
|
||||||
|
|
||||||
|
# Reset decay score to near 1.0 (fully retained)
|
||||||
|
curve = ForgettingCurve()
|
||||||
|
memory.decay_score = min(0.95, curve.calculate_decay(memory) + 0.1)
|
||||||
|
|
||||||
|
return memory
|
||||||
|
|
||||||
|
def auto_reinforce(self, memories: list["UserMemory"]) -> list["UserMemory"]:
|
||||||
|
"""Weekly auto-reinforce for high-importance memories.
|
||||||
|
|
||||||
|
Applies a 10% boost to frequency_count for high-importance memories
|
||||||
|
that haven't been accessed recently, keeping them fresh.
|
||||||
|
"""
|
||||||
|
reinforced = []
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
for memory in memories:
|
||||||
|
importance_level = getattr(memory, "importance_level", "medium") or "medium"
|
||||||
|
if importance_level != "high":
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_freq = getattr(memory, "frequency_count", 0) or 0
|
||||||
|
if current_freq >= self.MAX_FREQUENCY:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Apply 10% boost, capped at MAX_FREQUENCY
|
||||||
|
new_freq = min(int(current_freq * self.AUTO_REINFORCE_BOOST + 1), self.MAX_FREQUENCY)
|
||||||
|
memory.frequency_count = new_freq
|
||||||
|
|
||||||
|
# Slightly improve decay score
|
||||||
|
current_decay = getattr(memory, "decay_score", 0.5) or 0.5
|
||||||
|
memory.decay_score = min(0.95, current_decay * 1.05)
|
||||||
|
|
||||||
|
memory.last_accessed_at = now
|
||||||
|
reinforced.append(memory)
|
||||||
|
|
||||||
|
return reinforced
|
||||||
113
backend/app/services/memory/reminder_scheduler.py
Normal file
113
backend/app/services/memory/reminder_scheduler.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""
|
||||||
|
ReminderScheduler
|
||||||
|
|
||||||
|
Schedules and manages user reminders.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import select, and_
|
||||||
|
|
||||||
|
from app.models.reminder import Reminder
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderScheduler:
|
||||||
|
"""Schedule and manage user reminders."""
|
||||||
|
|
||||||
|
async def create_reminder(
|
||||||
|
self,
|
||||||
|
db: "AsyncSession",
|
||||||
|
user_id: str,
|
||||||
|
content: str,
|
||||||
|
trigger_at: datetime,
|
||||||
|
trigger_type: str = "time",
|
||||||
|
context_memory_id: str | None = None,
|
||||||
|
) -> Reminder:
|
||||||
|
"""Create a new reminder."""
|
||||||
|
reminder = Reminder(
|
||||||
|
user_id=user_id,
|
||||||
|
content=content,
|
||||||
|
trigger_type=trigger_type,
|
||||||
|
trigger_at=trigger_at,
|
||||||
|
context_memory_id=context_memory_id,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db.add(reminder)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(reminder)
|
||||||
|
return reminder
|
||||||
|
|
||||||
|
async def get_due_reminders(self, db: "AsyncSession", user_id: str) -> list[Reminder]:
|
||||||
|
"""Get reminders that are due (status=pending, trigger_at <= now, not snoozed)."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Reminder)
|
||||||
|
.where(
|
||||||
|
Reminder.user_id == user_id,
|
||||||
|
Reminder.status == "pending",
|
||||||
|
Reminder.trigger_at <= now,
|
||||||
|
((Reminder.snoozed_until.is_(None)) | (Reminder.snoozed_until <= now)),
|
||||||
|
)
|
||||||
|
.order_by(Reminder.trigger_at.asc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def snooze(
|
||||||
|
self,
|
||||||
|
db: "AsyncSession",
|
||||||
|
reminder_id: int,
|
||||||
|
minutes: int,
|
||||||
|
) -> Reminder | None:
|
||||||
|
"""Snooze reminder for N minutes."""
|
||||||
|
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
|
||||||
|
reminder = result.scalar_one_or_none()
|
||||||
|
if not reminder:
|
||||||
|
return None
|
||||||
|
|
||||||
|
reminder.status = "snoozed"
|
||||||
|
reminder.snoozed_until = datetime.now(UTC) + timedelta(minutes=minutes)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(reminder)
|
||||||
|
return reminder
|
||||||
|
|
||||||
|
async def dismiss(self, db: "AsyncSession", reminder_id: int) -> bool:
|
||||||
|
"""Mark reminder as dismissed."""
|
||||||
|
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
|
||||||
|
reminder = result.scalar_one_or_none()
|
||||||
|
if not reminder:
|
||||||
|
return False
|
||||||
|
|
||||||
|
reminder.status = "dismissed"
|
||||||
|
await db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def mark_sent(self, db: "AsyncSession", reminder_id: int) -> bool:
|
||||||
|
"""Mark reminder as sent."""
|
||||||
|
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
|
||||||
|
reminder = result.scalar_one_or_none()
|
||||||
|
if not reminder:
|
||||||
|
return False
|
||||||
|
|
||||||
|
reminder.status = "sent"
|
||||||
|
await db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_pending_reminders(
|
||||||
|
self,
|
||||||
|
db: "AsyncSession",
|
||||||
|
user_id: str,
|
||||||
|
) -> list[Reminder]:
|
||||||
|
"""Get all pending reminders for a user."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Reminder)
|
||||||
|
.where(
|
||||||
|
Reminder.user_id == user_id,
|
||||||
|
Reminder.status.in_(["pending", "snoozed"]),
|
||||||
|
)
|
||||||
|
.order_by(Reminder.trigger_at.asc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
@@ -7,6 +7,7 @@ Jarvis 记忆系统 (基于 Mem0)
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import json
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
from sqlalchemy import select, desc, func
|
from sqlalchemy import select, desc, func
|
||||||
@@ -15,6 +16,13 @@ from app.models.conversation import Conversation, Message
|
|||||||
from app.models.memory import UserMemory
|
from app.models.memory import UserMemory
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.brain_service import BrainService
|
from app.services.brain_service import BrainService
|
||||||
|
from app.services.memory.frequency_tracker import FrequencyTracker
|
||||||
|
from app.services.memory.emotion_analyzer import EmotionAnalyzer
|
||||||
|
from app.services.memory.impact_evaluator import ImpactEvaluator
|
||||||
|
from app.services.memory.importance_scorer import ImportanceScorer
|
||||||
|
from app.services.memory.forgetting_curve import ForgettingCurve
|
||||||
|
from app.services.memory.memory_decay import MemoryDecay
|
||||||
|
from app.services.memory.reinforcement import MemoryReinforcement
|
||||||
from app.config import settings as _settings
|
from app.config import settings as _settings
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -312,8 +320,7 @@ def _extract_memory_query_tokens(query: str) -> list[str]:
|
|||||||
tokens.append(stripped_chunk)
|
tokens.append(stripped_chunk)
|
||||||
if len(stripped_chunk) > 6:
|
if len(stripped_chunk) > 6:
|
||||||
tokens.extend(
|
tokens.extend(
|
||||||
stripped_chunk[index:index + 4]
|
stripped_chunk[index : index + 4] for index in range(len(stripped_chunk) - 3)
|
||||||
for index in range(len(stripped_chunk) - 3)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return list(dict.fromkeys(tokens))
|
return list(dict.fromkeys(tokens))
|
||||||
@@ -344,16 +351,21 @@ async def recall_user_memories(
|
|||||||
|
|
||||||
query_tokens = _extract_memory_query_tokens(query)
|
query_tokens = _extract_memory_query_tokens(query)
|
||||||
statement = select(UserMemory).where(UserMemory.user_id == user_id)
|
statement = select(UserMemory).where(UserMemory.user_id == user_id)
|
||||||
result = await db.execute(statement.order_by(UserMemory.importance.desc(), UserMemory.created_at.desc()))
|
result = await db.execute(
|
||||||
|
statement.order_by(UserMemory.importance_score.desc(), UserMemory.created_at.desc())
|
||||||
|
)
|
||||||
fallback_memories = list(result.scalars().all())
|
fallback_memories = list(result.scalars().all())
|
||||||
|
|
||||||
if _contains_hint(_normalize_query(query), MEMORY_QUERY_HINTS) or _matches_memory_query_pattern(_normalize_query(query)):
|
if _contains_hint(_normalize_query(query), MEMORY_QUERY_HINTS) or _matches_memory_query_pattern(
|
||||||
|
_normalize_query(query)
|
||||||
|
):
|
||||||
return fallback_memories[:top_k]
|
return fallback_memories[:top_k]
|
||||||
|
|
||||||
if query_tokens:
|
if query_tokens:
|
||||||
matched_memories = [
|
matched_memories = [
|
||||||
memory for memory in fallback_memories
|
memory
|
||||||
if any(token in (memory.content or '').lower() for token in query_tokens)
|
for memory in fallback_memories
|
||||||
|
if any(token in (memory.content or "").lower() for token in query_tokens)
|
||||||
]
|
]
|
||||||
return matched_memories[:top_k]
|
return matched_memories[:top_k]
|
||||||
|
|
||||||
@@ -361,13 +373,31 @@ async def recall_user_memories(
|
|||||||
|
|
||||||
|
|
||||||
async def _mark_memories_recalled(db: AsyncSession, memories: list[UserMemory]) -> None:
|
async def _mark_memories_recalled(db: AsyncSession, memories: list[UserMemory]) -> None:
|
||||||
|
"""Mark memories as recalled and update importance score + reinforce them."""
|
||||||
|
from app.services.memory.frequency_tracker import FrequencyTracker
|
||||||
|
from app.services.memory.importance_scorer import ImportanceScorer
|
||||||
|
from app.services.memory.reinforcement import MemoryReinforcement
|
||||||
|
|
||||||
recalled_at = datetime.now(UTC)
|
recalled_at = datetime.now(UTC)
|
||||||
|
tracker = FrequencyTracker()
|
||||||
|
scorer = ImportanceScorer()
|
||||||
|
reinforcement = MemoryReinforcement()
|
||||||
updated = False
|
updated = False
|
||||||
|
|
||||||
for memory in memories:
|
for memory in memories:
|
||||||
memory.is_recalled = True
|
memory.is_recalled = True
|
||||||
memory.recall_count = (memory.recall_count or 0) + 1
|
memory.recall_count = (memory.recall_count or 0) + 1
|
||||||
memory.last_recalled_at = recalled_at
|
memory.last_recalled_at = recalled_at
|
||||||
|
memory.frequency_count = memory.recall_count # Keep in sync
|
||||||
|
|
||||||
|
# Update importance score on recall
|
||||||
|
scorer.update_memory_importance(memory)
|
||||||
|
|
||||||
|
# M.2: Reinforce memory on recall (reset decay, increment frequency)
|
||||||
|
reinforcement.trigger(memory)
|
||||||
|
|
||||||
updated = True
|
updated = True
|
||||||
|
|
||||||
if updated:
|
if updated:
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@@ -417,9 +447,7 @@ MEMORY_QUERY_HINTS = (
|
|||||||
"偏好",
|
"偏好",
|
||||||
"习惯",
|
"习惯",
|
||||||
)
|
)
|
||||||
MEMORY_QUERY_PATTERNS = (
|
MEMORY_QUERY_PATTERNS = (re.compile(r"\bremember\s+(?:that\s+)?i\b"),)
|
||||||
re.compile(r"\bremember\s+(?:that\s+)?i\b"),
|
|
||||||
)
|
|
||||||
GROUNDING_QUERY_HINTS = (
|
GROUNDING_QUERY_HINTS = (
|
||||||
"根据文档",
|
"根据文档",
|
||||||
"严格根据",
|
"严格根据",
|
||||||
@@ -634,3 +662,77 @@ async def update_memory(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Mem0 update error: {e}")
|
print(f"Mem0 update error: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ———— M.2: 遗忘曲线处理 ————
|
||||||
|
|
||||||
|
|
||||||
|
async def process_memory_decay(db: AsyncSession, user_id: str) -> dict:
|
||||||
|
"""
|
||||||
|
处理用户所有记忆的衰减:
|
||||||
|
1. 计算每条记忆的 decay_score
|
||||||
|
2. 归档 decay < 0.2 的记忆
|
||||||
|
3. 降权 decay < 0.5 的记忆
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserMemory).where(
|
||||||
|
UserMemory.user_id == user_id,
|
||||||
|
UserMemory.is_archived == False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
memories = list(result.scalars().all())
|
||||||
|
|
||||||
|
archived_count = 0
|
||||||
|
deprioritized_count = 0
|
||||||
|
curve = ForgettingCurve()
|
||||||
|
decay_mgr = MemoryDecay()
|
||||||
|
|
||||||
|
for memory in memories:
|
||||||
|
evaluation = decay_mgr.evaluate(memory)
|
||||||
|
decay_score = evaluation["decay_score"]
|
||||||
|
|
||||||
|
# Update decay_score on the memory
|
||||||
|
memory.decay_score = decay_score
|
||||||
|
|
||||||
|
if evaluation["should_archive"]:
|
||||||
|
decay_mgr.archive_memory(memory)
|
||||||
|
archived_count += 1
|
||||||
|
elif evaluation["should_deprioritize"]:
|
||||||
|
decay_mgr.deprioritize_memory(memory)
|
||||||
|
deprioritized_count += 1
|
||||||
|
|
||||||
|
if archived_count > 0 or deprioritized_count > 0:
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"archived": archived_count,
|
||||||
|
"deprioritized": deprioritized_count,
|
||||||
|
"total": len(memories),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def process_weekly_reinforcement(db: AsyncSession, user_id: str) -> int:
|
||||||
|
"""
|
||||||
|
每周自动强化高重要性记忆。
|
||||||
|
Returns number of reinforced memories.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserMemory).where(
|
||||||
|
UserMemory.user_id == user_id,
|
||||||
|
UserMemory.importance_level == "high",
|
||||||
|
UserMemory.is_archived == False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
memories = list(result.scalars().all())
|
||||||
|
|
||||||
|
reinforcement = MemoryReinforcement()
|
||||||
|
reinforced = reinforcement.auto_reinforce(memories)
|
||||||
|
|
||||||
|
if reinforced:
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return len(reinforced)
|
||||||
|
|||||||
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,
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -20,7 +20,137 @@ logger = logging.getLogger(__name__)
|
|||||||
scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
|
scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
# ===================== 定时任务函数 =====================
|
# ===================== M.2: 遗忘曲线任务 =====================
|
||||||
|
|
||||||
|
|
||||||
|
async def daily_forgetting_check():
|
||||||
|
"""
|
||||||
|
每日遗忘检查 (03:00)
|
||||||
|
- 计算所有记忆的 decay_score
|
||||||
|
- 归档 decay < 0.2 的记忆
|
||||||
|
- 降权 decay < 0.5 的记忆
|
||||||
|
"""
|
||||||
|
from app.services.memory_service import process_memory_decay
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
logger.info("[Scheduler] 开始执行每日遗忘检查...")
|
||||||
|
|
||||||
|
async with async_session() as db:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.is_active == True))
|
||||||
|
users = result.scalars().all()
|
||||||
|
|
||||||
|
total_archived = 0
|
||||||
|
total_deprioritized = 0
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
decay_result = await process_memory_decay(db, user.id)
|
||||||
|
total_archived += decay_result["archived"]
|
||||||
|
total_deprioritized += decay_result["deprioritized"]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Scheduler] 用户 {user.id} 遗忘检查失败: {e}")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[Scheduler] 每日遗忘检查完成,归档 {total_archived} 条,降权 {total_deprioritized} 条"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def weekly_reinforcement_task():
|
||||||
|
"""
|
||||||
|
每周自动强化 (周一 04:00)
|
||||||
|
对 high 重要性记忆做轻量强化
|
||||||
|
"""
|
||||||
|
from app.services.memory_service import process_weekly_reinforcement
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
logger.info("[Scheduler] 开始执行每周强化任务...")
|
||||||
|
|
||||||
|
async with async_session() as db:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.is_active == True))
|
||||||
|
users = result.scalars().all()
|
||||||
|
|
||||||
|
total_reinforced = 0
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
count = await process_weekly_reinforcement(db, user.id)
|
||||||
|
total_reinforced += count
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Scheduler] 用户 {user.id} 强化任务失败: {e}")
|
||||||
|
|
||||||
|
logger.info(f"[Scheduler] 每周强化完成,共强化 {total_reinforced} 条记忆")
|
||||||
|
|
||||||
|
|
||||||
|
# ===================== M.3: 主动提醒任务 =====================
|
||||||
|
|
||||||
|
|
||||||
|
async def daily_digest_generation():
|
||||||
|
"""
|
||||||
|
每日摘要生成 (22:00)
|
||||||
|
为所有活跃用户生成每日摘要
|
||||||
|
"""
|
||||||
|
from app.services.memory.daily_digest import DailyDigestGenerator
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
logger.info("[Scheduler] 开始执行每日摘要生成...")
|
||||||
|
|
||||||
|
async with async_session() as db:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.is_active == True))
|
||||||
|
users = result.scalars().all()
|
||||||
|
|
||||||
|
generated = 0
|
||||||
|
generator = DailyDigestGenerator()
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
digest = await generator.generate(db, user.id, target_date=date.today())
|
||||||
|
# In production, would save digest to database
|
||||||
|
generated += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Scheduler] 用户 {user.id} 摘要生成失败: {e}")
|
||||||
|
|
||||||
|
logger.info(f"[Scheduler] 每日摘要生成完成,共生成 {generated} 条")
|
||||||
|
|
||||||
|
|
||||||
|
async def reminder_check_task():
|
||||||
|
"""
|
||||||
|
提醒检查 (每15分钟)
|
||||||
|
检查到期的提醒并标记为 sent
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
logger.info("[Scheduler] 开始检查到期提醒...")
|
||||||
|
|
||||||
|
async with async_session() as db:
|
||||||
|
from app.models.reminder import Reminder
|
||||||
|
from app.services.memory.reminder_scheduler import ReminderScheduler
|
||||||
|
|
||||||
|
scheduler = ReminderScheduler()
|
||||||
|
result = await db.execute(
|
||||||
|
select(Reminder).where(
|
||||||
|
Reminder.status == "pending",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
reminders = result.scalars().all()
|
||||||
|
|
||||||
|
sent_count = 0
|
||||||
|
for reminder in reminders:
|
||||||
|
try:
|
||||||
|
due = await scheduler.get_due_reminders(db, reminder.user_id)
|
||||||
|
for due_reminder in due:
|
||||||
|
await scheduler.mark_sent(db, due_reminder.id)
|
||||||
|
sent_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Scheduler] 提醒检查失败: {e}")
|
||||||
|
|
||||||
|
if sent_count > 0:
|
||||||
|
logger.info(f"[Scheduler] 提醒检查完成,发送 {sent_count} 条提醒")
|
||||||
|
|
||||||
|
|
||||||
async def daily_task_analysis():
|
async def daily_task_analysis():
|
||||||
"""
|
"""
|
||||||
@@ -37,15 +167,13 @@ async def daily_task_analysis():
|
|||||||
yesterday = datetime.now(UTC).date() - timedelta(days=1)
|
yesterday = datetime.now(UTC).date() - timedelta(days=1)
|
||||||
|
|
||||||
# 统计昨日任务完成情况
|
# 统计昨日任务完成情况
|
||||||
result = await db.execute(
|
result = await db.execute(select(Task).where(Task.updated_at >= yesterday))
|
||||||
select(Task).where(Task.updated_at >= yesterday)
|
|
||||||
)
|
|
||||||
tasks = result.scalars().all()
|
tasks = result.scalars().all()
|
||||||
|
|
||||||
completed = [t for t in tasks if t.status == "done"]
|
completed = [t for t in tasks if t.status == "done"]
|
||||||
pending = [t for t in tasks if t.status != "done"]
|
pending = [t for t in tasks if t.status != "done"]
|
||||||
|
|
||||||
report = f"""## 每日任务报告 - {yesterday.strftime('%Y-%m-%d')}
|
report = f"""## 每日任务报告 - {yesterday.strftime("%Y-%m-%d")}
|
||||||
|
|
||||||
### 完成情况
|
### 完成情况
|
||||||
- 总任务数: {len(tasks)}
|
- 总任务数: {len(tasks)}
|
||||||
@@ -65,6 +193,7 @@ async def daily_task_analysis():
|
|||||||
|
|
||||||
# 发布到论坛
|
# 发布到论坛
|
||||||
from app.models.forum import ForumPost
|
from app.models.forum import ForumPost
|
||||||
|
|
||||||
post = ForumPost(
|
post = ForumPost(
|
||||||
title=f"每日报告 - {yesterday.strftime('%Y-%m-%d')}",
|
title=f"每日报告 - {yesterday.strftime('%Y-%m-%d')}",
|
||||||
content=report,
|
content=report,
|
||||||
@@ -97,11 +226,14 @@ async def forum_scan_task():
|
|||||||
|
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ForumPost).where(
|
select(ForumPost)
|
||||||
|
.where(
|
||||||
ForumPost.category == "instruction",
|
ForumPost.category == "instruction",
|
||||||
ForumPost.is_executed == False,
|
ForumPost.is_executed == False,
|
||||||
).limit(5)
|
)
|
||||||
|
.limit(5)
|
||||||
)
|
)
|
||||||
posts = result.scalars().all()
|
posts = result.scalars().all()
|
||||||
|
|
||||||
@@ -165,9 +297,9 @@ async def tag_generation_task():
|
|||||||
tag_service = TagService(db, llm_client)
|
tag_service = TagService(db, llm_client)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(KGNode.user_id).distinct().where(
|
select(KGNode.user_id)
|
||||||
KGNode.entity_type.in_(["conversation", "document", "chunk"])
|
.distinct()
|
||||||
)
|
.where(KGNode.entity_type.in_(["conversation", "document", "chunk"]))
|
||||||
)
|
)
|
||||||
user_ids = result.scalars().all()
|
user_ids = result.scalars().all()
|
||||||
|
|
||||||
@@ -211,8 +343,75 @@ async def daily_todo_generation():
|
|||||||
logger.error(f"[Scheduler] 每日待办生成失败: {e}")
|
logger.error(f"[Scheduler] 每日待办生成失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ———— M.4: 主动记忆提取 ————
|
||||||
|
async def check_idle_conversations():
|
||||||
|
"""
|
||||||
|
每30分钟检查空闲超过30分钟的对话,提取记忆
|
||||||
|
M.4: 主动记忆提取
|
||||||
|
"""
|
||||||
|
from datetime import timedelta, datetime, UTC
|
||||||
|
from app.models.conversation import Conversation, Message
|
||||||
|
from app.services.memory.memory_extractor import MemoryExtractor
|
||||||
|
|
||||||
|
logger.info("[Scheduler] 开始检查空闲对话...")
|
||||||
|
|
||||||
|
async with async_session() as db:
|
||||||
|
try:
|
||||||
|
# Find conversations idle > 30 minutes (no recent messages)
|
||||||
|
cutoff = datetime.now(UTC) - timedelta(minutes=30)
|
||||||
|
|
||||||
|
# Subquery to find last message time per conversation
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
subq = (
|
||||||
|
select(Message.conversation_id, func.max(Message.created_at).label("last_message"))
|
||||||
|
.group_by(Message.conversation_id)
|
||||||
|
.having(func.max(Message.created_at) < cutoff)
|
||||||
|
).subquery()
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Conversation)
|
||||||
|
.join(subq, Conversation.id == subq.c.conversation_id)
|
||||||
|
.where(Conversation.updated_at >= datetime.now(UTC) - timedelta(hours=24))
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
idle_conversations = list(result.scalars().all())
|
||||||
|
|
||||||
|
extractor = MemoryExtractor()
|
||||||
|
total_extracted = 0
|
||||||
|
|
||||||
|
for conv in idle_conversations:
|
||||||
|
try:
|
||||||
|
# Get conversation messages
|
||||||
|
msg_result = await db.execute(
|
||||||
|
select(Message)
|
||||||
|
.where(Message.conversation_id == conv.id)
|
||||||
|
.order_by(Message.created_at.desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
messages = list(msg_result.scalars().all())
|
||||||
|
|
||||||
|
if len(messages) >= 2:
|
||||||
|
new_memories = await extractor.extract_from_conversation(
|
||||||
|
db, conv.user_id, conv.id, messages
|
||||||
|
)
|
||||||
|
if new_memories:
|
||||||
|
await extractor.save_memories(db, conv.user_id, conv.id, new_memories)
|
||||||
|
total_extracted += len(new_memories)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[MemoryExtractor] Failed to process conversation {conv.id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if total_extracted > 0:
|
||||||
|
logger.info(f"[Scheduler] 空闲对话记忆提取完成,共提取 {total_extracted} 条记忆")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Scheduler] 空闲对话检查失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
# ===================== 调度器管理 =====================
|
# ===================== 调度器管理 =====================
|
||||||
|
|
||||||
|
|
||||||
def start_scheduler():
|
def start_scheduler():
|
||||||
"""启动调度器,注册所有定时任务"""
|
"""启动调度器,注册所有定时任务"""
|
||||||
if scheduler.running:
|
if scheduler.running:
|
||||||
@@ -264,6 +463,54 @@ def start_scheduler():
|
|||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ———— M.2: 遗忘曲线系统 ————
|
||||||
|
# 每天凌晨 03:00 执行遗忘检查
|
||||||
|
scheduler.add_job(
|
||||||
|
daily_forgetting_check,
|
||||||
|
CronTrigger(hour=3, minute=0, timezone="Asia/Shanghai"),
|
||||||
|
id="daily_forgetting_check",
|
||||||
|
name="每日遗忘检查",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 每周一 04:00 执行自动强化
|
||||||
|
scheduler.add_job(
|
||||||
|
weekly_reinforcement_task,
|
||||||
|
CronTrigger(day_of_week="mon", hour=4, minute=0, timezone="Asia/Shanghai"),
|
||||||
|
id="weekly_reinforcement",
|
||||||
|
name="每周记忆强化",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ———— M.3: 主动提醒系统 ————
|
||||||
|
# 每天 22:00 生成每日摘要
|
||||||
|
scheduler.add_job(
|
||||||
|
daily_digest_generation,
|
||||||
|
CronTrigger(hour=22, minute=0, timezone="Asia/Shanghai"),
|
||||||
|
id="daily_digest_generation",
|
||||||
|
name="每日摘要生成",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 每15分钟检查到期提醒
|
||||||
|
scheduler.add_job(
|
||||||
|
reminder_check_task,
|
||||||
|
IntervalTrigger(minutes=15),
|
||||||
|
id="reminder_check",
|
||||||
|
name="提醒检查",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ———— M.4: 主动记忆提取 ————
|
||||||
|
# 每30分钟检查空闲对话并提取记忆
|
||||||
|
scheduler.add_job(
|
||||||
|
check_idle_conversations,
|
||||||
|
IntervalTrigger(minutes=30),
|
||||||
|
id="check_idle_conversations",
|
||||||
|
name="空闲对话记忆提取",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logger.info("[Scheduler] 定时任务调度器已启动")
|
logger.info("[Scheduler] 定时任务调度器已启动")
|
||||||
|
|
||||||
@@ -282,10 +529,12 @@ def get_scheduler_status() -> dict:
|
|||||||
|
|
||||||
jobs = []
|
jobs = []
|
||||||
for job in scheduler.get_jobs():
|
for job in scheduler.get_jobs():
|
||||||
jobs.append({
|
jobs.append(
|
||||||
|
{
|
||||||
"id": job.id,
|
"id": job.id,
|
||||||
"name": job.name,
|
"name": job.name,
|
||||||
"next_run": str(job.next_run_time) if job.next_run_time else None,
|
"next_run": str(job.next_run_time) if job.next_run_time else None,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return {"status": "running", "jobs": jobs}
|
return {"status": "running", "jobs": jobs}
|
||||||
|
|||||||
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 typing import Optional
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, and_, or_
|
from sqlalchemy import select, and_, or_
|
||||||
|
from app.agents.schemas.learning import SkillCandidate
|
||||||
|
from app.agents.skills.models import SkillLifecycleDecision
|
||||||
from app.models.skill import Skill
|
from app.models.skill import Skill
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
@@ -28,6 +32,10 @@ class SkillService:
|
|||||||
visibility=data.get("visibility", "private"),
|
visibility=data.get("visibility", "private"),
|
||||||
team_id=data.get("team_id"),
|
team_id=data.get("team_id"),
|
||||||
is_active=data.get("is_active", True),
|
is_active=data.get("is_active", True),
|
||||||
|
status=data.get("status", "active"),
|
||||||
|
scope=data.get("scope", []),
|
||||||
|
effectiveness=data.get("effectiveness", 0.0),
|
||||||
|
review_after=data.get("review_after"),
|
||||||
)
|
)
|
||||||
self.db.add(skill)
|
self.db.add(skill)
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
@@ -41,6 +49,17 @@ class SkillService:
|
|||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_by_name_for_user(self, user_id: str, name: str) -> Optional[Skill]:
|
||||||
|
access_scope = or_(
|
||||||
|
Skill.owner_id == user_id,
|
||||||
|
Skill.visibility == "market",
|
||||||
|
Skill.team_id == user_id,
|
||||||
|
)
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Skill).where(and_(Skill.name == name, access_scope))
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
async def list_for_user(
|
async def list_for_user(
|
||||||
self,
|
self,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -56,7 +75,7 @@ class SkillService:
|
|||||||
Skill.team_id == user_id,
|
Skill.team_id == user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
filters = [access_scope, Skill.is_active == True]
|
filters = [access_scope, Skill.is_active == True, Skill.status != "retired"]
|
||||||
|
|
||||||
if agent_type:
|
if agent_type:
|
||||||
filters.append(Skill.agent_type == agent_type)
|
filters.append(Skill.agent_type == agent_type)
|
||||||
@@ -83,7 +102,7 @@ class SkillService:
|
|||||||
update_fields = [
|
update_fields = [
|
||||||
"name", "description", "instructions", "agent_type",
|
"name", "description", "instructions", "agent_type",
|
||||||
"tools", "required_context", "output_format", "visibility",
|
"tools", "required_context", "output_format", "visibility",
|
||||||
"team_id", "is_active"
|
"team_id", "is_active", "status", "scope", "effectiveness", "review_after"
|
||||||
]
|
]
|
||||||
|
|
||||||
for field in update_fields:
|
for field in update_fields:
|
||||||
@@ -117,6 +136,7 @@ class SkillService:
|
|||||||
and_(
|
and_(
|
||||||
Skill.agent_type == agent_type,
|
Skill.agent_type == agent_type,
|
||||||
Skill.is_active == True,
|
Skill.is_active == True,
|
||||||
|
Skill.status == "active",
|
||||||
or_(
|
or_(
|
||||||
Skill.visibility == "market",
|
Skill.visibility == "market",
|
||||||
Skill.visibility == "private"
|
Skill.visibility == "private"
|
||||||
@@ -125,3 +145,234 @@ class SkillService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def list_runtime_candidates(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
*,
|
||||||
|
agent_type: Optional[str] = None,
|
||||||
|
include_shadow: bool = True,
|
||||||
|
include_learned: bool = True,
|
||||||
|
) -> list[Skill]:
|
||||||
|
allowed_statuses = ["active", "shadow"] if include_shadow else ["active"]
|
||||||
|
access_scope = or_(
|
||||||
|
Skill.owner_id == user_id,
|
||||||
|
Skill.visibility == "market",
|
||||||
|
Skill.team_id == user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
filters = [
|
||||||
|
access_scope,
|
||||||
|
Skill.is_active == True,
|
||||||
|
Skill.status.in_(allowed_statuses),
|
||||||
|
]
|
||||||
|
if not include_learned:
|
||||||
|
filters.append(Skill.is_builtin == True)
|
||||||
|
if agent_type:
|
||||||
|
filters.append(Skill.agent_type == agent_type)
|
||||||
|
|
||||||
|
result = await self.db.execute(select(Skill).where(and_(*filters)))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def upsert_learned_candidate(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
candidate: SkillCandidate,
|
||||||
|
primary_agent: str | None,
|
||||||
|
evidence_refs: list[dict] | None = None,
|
||||||
|
) -> SkillLifecycleDecision:
|
||||||
|
source_hash = self._build_candidate_source_hash(candidate)
|
||||||
|
skill = await self.get_by_name_for_user(user_id, candidate.name)
|
||||||
|
if skill is None:
|
||||||
|
review_after = datetime.now(UTC) + timedelta(days=7)
|
||||||
|
skill = Skill(
|
||||||
|
owner_id=user_id,
|
||||||
|
name=candidate.name,
|
||||||
|
description=candidate.summary,
|
||||||
|
instructions=candidate.summary,
|
||||||
|
agent_type=primary_agent or "master",
|
||||||
|
tools=[],
|
||||||
|
required_context=[],
|
||||||
|
output_format=None,
|
||||||
|
visibility="private",
|
||||||
|
is_active=True,
|
||||||
|
status="candidate",
|
||||||
|
scope=[primary_agent or "master", "learned", candidate.candidate_type],
|
||||||
|
effectiveness=candidate.confidence,
|
||||||
|
review_after=review_after,
|
||||||
|
candidate_count=1,
|
||||||
|
candidate_source_hashes=[source_hash],
|
||||||
|
)
|
||||||
|
self.db.add(skill)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(skill)
|
||||||
|
return SkillLifecycleDecision(
|
||||||
|
skill_name=skill.name,
|
||||||
|
action="created_candidate",
|
||||||
|
previous_status=None,
|
||||||
|
new_status="candidate",
|
||||||
|
reason="First learned candidate created from retrospective evidence.",
|
||||||
|
evidence_refs=evidence_refs or [],
|
||||||
|
confidence=candidate.confidence,
|
||||||
|
review_after=review_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
previous_status = skill.status
|
||||||
|
known_hashes = list(skill.candidate_source_hashes or [])
|
||||||
|
is_duplicate_candidate = source_hash in known_hashes
|
||||||
|
if not is_duplicate_candidate:
|
||||||
|
skill.candidate_count = int(skill.candidate_count or 0) + 1
|
||||||
|
known_hashes.append(source_hash)
|
||||||
|
skill.candidate_source_hashes = known_hashes
|
||||||
|
current_effectiveness = float(skill.effectiveness or 0.0)
|
||||||
|
skill.effectiveness = round(max(current_effectiveness, float(candidate.confidence or 0.0)), 3)
|
||||||
|
skill.review_after = datetime.now(UTC) + timedelta(days=7)
|
||||||
|
if primary_agent and primary_agent not in (skill.scope or []):
|
||||||
|
skill.scope = [*(skill.scope or []), primary_agent]
|
||||||
|
|
||||||
|
action = "no_change"
|
||||||
|
reason = "Candidate evidence refreshed."
|
||||||
|
if is_duplicate_candidate:
|
||||||
|
reason = "Duplicate candidate evidence ignored for promotion counting."
|
||||||
|
if (
|
||||||
|
not is_duplicate_candidate
|
||||||
|
and skill.status == "candidate"
|
||||||
|
and skill.candidate_count >= 2
|
||||||
|
and skill.effectiveness >= 0.6
|
||||||
|
):
|
||||||
|
skill.status = "shadow"
|
||||||
|
action = "promoted_to_shadow"
|
||||||
|
reason = "Repeated candidate evidence promoted the learned skill to shadow."
|
||||||
|
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(skill)
|
||||||
|
return SkillLifecycleDecision(
|
||||||
|
skill_name=skill.name,
|
||||||
|
action=action,
|
||||||
|
previous_status=previous_status,
|
||||||
|
new_status=skill.status,
|
||||||
|
reason=reason,
|
||||||
|
evidence_refs=evidence_refs or [],
|
||||||
|
confidence=skill.effectiveness,
|
||||||
|
review_after=skill.review_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def record_activation_feedback(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
skill_name: str,
|
||||||
|
outcome_score: float,
|
||||||
|
evidence_refs: list[dict] | None = None,
|
||||||
|
) -> SkillLifecycleDecision | None:
|
||||||
|
skill = await self.get_by_name_for_user(user_id, skill_name)
|
||||||
|
if skill is None or skill.status not in {"shadow", "active", "deprecated"}:
|
||||||
|
return None
|
||||||
|
|
||||||
|
previous_status = skill.status
|
||||||
|
previous_activation_count = int(skill.activation_count or 0)
|
||||||
|
skill.activation_count = previous_activation_count + 1
|
||||||
|
skill.last_activated_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
previous_effectiveness = float(skill.effectiveness or 0.0)
|
||||||
|
if previous_activation_count <= 0:
|
||||||
|
skill.effectiveness = round(outcome_score, 3)
|
||||||
|
else:
|
||||||
|
skill.effectiveness = round(
|
||||||
|
((previous_effectiveness * previous_activation_count) + outcome_score)
|
||||||
|
/ skill.activation_count,
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
|
||||||
|
action = "feedback_recorded"
|
||||||
|
reason = "Activation outcome recorded."
|
||||||
|
if skill.status == "shadow" and skill.activation_count >= 2 and skill.effectiveness >= 0.7:
|
||||||
|
skill.status = "active"
|
||||||
|
action = "promoted_to_active"
|
||||||
|
reason = "Shadow skill proved effective enough to become active."
|
||||||
|
elif skill.status == "active" and skill.activation_count >= 3 and skill.effectiveness < 0.35:
|
||||||
|
skill.status = "deprecated"
|
||||||
|
action = "degraded_to_deprecated"
|
||||||
|
reason = "Active skill underperformed repeatedly and was deprecated."
|
||||||
|
elif skill.status == "deprecated" and skill.activation_count >= 4 and skill.effectiveness < 0.2:
|
||||||
|
skill.status = "retired"
|
||||||
|
action = "retired"
|
||||||
|
reason = "Deprecated skill stayed ineffective and was retired."
|
||||||
|
elif skill.status == "deprecated" and skill.effectiveness >= 0.65 and outcome_score >= 0.8:
|
||||||
|
skill.status = "active"
|
||||||
|
action = "reactivated"
|
||||||
|
reason = "Deprecated skill recovered with strong positive feedback."
|
||||||
|
|
||||||
|
skill.review_after = datetime.now(UTC) + timedelta(days=7)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(skill)
|
||||||
|
return SkillLifecycleDecision(
|
||||||
|
skill_name=skill.name,
|
||||||
|
action=action,
|
||||||
|
previous_status=previous_status,
|
||||||
|
new_status=skill.status,
|
||||||
|
reason=reason,
|
||||||
|
evidence_refs=evidence_refs or [],
|
||||||
|
confidence=skill.effectiveness,
|
||||||
|
review_after=skill.review_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_decay_review(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
as_of: datetime | None = None,
|
||||||
|
) -> list[SkillLifecycleDecision]:
|
||||||
|
review_time = as_of or datetime.now(UTC)
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Skill).where(
|
||||||
|
and_(
|
||||||
|
Skill.owner_id == user_id,
|
||||||
|
Skill.is_active == True,
|
||||||
|
Skill.status.in_(["shadow", "active", "deprecated"]),
|
||||||
|
Skill.review_after.is_not(None),
|
||||||
|
Skill.review_after <= review_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
skills = list(result.scalars().all())
|
||||||
|
decisions: list[SkillLifecycleDecision] = []
|
||||||
|
for skill in skills:
|
||||||
|
previous_status = skill.status
|
||||||
|
action = "no_change"
|
||||||
|
reason = "Review completed without status change."
|
||||||
|
|
||||||
|
if skill.status == "shadow" and float(skill.effectiveness or 0.0) < 0.45:
|
||||||
|
skill.status = "deprecated"
|
||||||
|
action = "degraded_to_deprecated"
|
||||||
|
reason = "Shadow skill review found low effectiveness."
|
||||||
|
elif skill.status == "deprecated" and float(skill.effectiveness or 0.0) < 0.2:
|
||||||
|
skill.status = "retired"
|
||||||
|
action = "retired"
|
||||||
|
reason = "Deprecated skill remained weak through review."
|
||||||
|
|
||||||
|
skill.review_after = review_time + timedelta(days=7)
|
||||||
|
decisions.append(
|
||||||
|
SkillLifecycleDecision(
|
||||||
|
skill_name=skill.name,
|
||||||
|
action=action,
|
||||||
|
previous_status=previous_status,
|
||||||
|
new_status=skill.status,
|
||||||
|
reason=reason,
|
||||||
|
confidence=skill.effectiveness,
|
||||||
|
review_after=skill.review_after,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.db.commit()
|
||||||
|
return decisions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_candidate_source_hash(candidate: SkillCandidate) -> str:
|
||||||
|
raw = (
|
||||||
|
f"{candidate.name}|{candidate.summary}|"
|
||||||
|
f"{','.join(candidate.source_pattern_ids)}|"
|
||||||
|
f"{len(candidate.evidence_refs)}"
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha1(raw).hexdigest()
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
from datetime import datetime, UTC
|
from datetime import datetime, UTC
|
||||||
from time import monotonic
|
from time import monotonic
|
||||||
|
import os
|
||||||
import platform
|
import platform
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import psutil
|
import psutil
|
||||||
@@ -14,6 +18,15 @@ class SystemService:
|
|||||||
_last_net_bytes_sent: int | None = None
|
_last_net_bytes_sent: int | None = None
|
||||||
_last_net_bytes_recv: int | None = None
|
_last_net_bytes_recv: int | None = None
|
||||||
_last_net_sample_at: float | None = None
|
_last_net_sample_at: float | None = None
|
||||||
|
_weather_cache: dict | None = None
|
||||||
|
_weather_cached_at: float | None = None
|
||||||
|
_weather_cached_location: str | None = None
|
||||||
|
_weather_cache_ttl_seconds: float = 10 * 60 # 10 minutes
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Import settings here to avoid circular imports
|
||||||
|
from app.config import settings
|
||||||
|
self._settings = settings
|
||||||
|
|
||||||
def _get_network_rates(self) -> tuple[float, float]:
|
def _get_network_rates(self) -> tuple[float, float]:
|
||||||
counters = psutil.net_io_counters()
|
counters = psutil.net_io_counters()
|
||||||
@@ -127,3 +140,96 @@ class SystemService:
|
|||||||
**gpu_status,
|
**gpu_status,
|
||||||
'timestamp': datetime.now(UTC).isoformat(),
|
'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,
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user