Compare commits

..

40 Commits

Author SHA1 Message Date
145c43f09c fix(backend): update conversation and schedule center schemas
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:51:11 +08:00
847d9f96db test(backend): add Hermes runtime and task router tests
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:50:47 +08:00
7f5b133fad feat(backend): add office router and agent runtime services
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:50:32 +08:00
21c869db62 feat(docs): add development documentation, prototypes, and war-room components
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:49:41 +08:00
1ca8855751 chore(frontend): update styles, vite config, and package dependencies
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:49:08 +08:00
d8f8b0c177 feat(frontend): update schedule center and war room pages
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:48:53 +08:00
7e6eb6a7b3 feat(frontend): update chat page composables and sidebar plan implementation
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:48:37 +08:00
c70e7e7253 feat(frontend): update API clients and Kanban components with enhanced UI
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:48:22 +08:00
39a9058de1 test(backend): update backend router tests for conversation, schedule center, and schema
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:48:07 +08:00
ac49c13965 feat(backend): update database schema and agent service
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:47:53 +08:00
3e39b40a50 feat(backend): enhance task and schedule center APIs with expanded endpoints
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:47:39 +08:00
8c7cf0732b Align knowledge storage with real folders and add WebDAV import surface
Knowledge files were only partitioned in the database, which made nested uploads, local folder visibility, and delete behavior diverge from the UI. This change makes folder selection drive physical storage paths, keeps original filenames, adds a minimal WebDAV mount/sync path, and reshapes the knowledge panel so local and remote sources can share the same surface.

Constraint: Existing knowledge flow already depends on local-folder-backed uploads and document indexing
Rejected: Real-time bidirectional WebDAV sync | too much conflict and lifecycle complexity for the first pass
Confidence: medium
Scope-risk: moderate
Reversibility: messy
Directive: Keep remote mounts single-direction into local knowledge folders until etag-based incremental sync and conflict rules are verified
Tested: Python py_compile on new/modified backend files; LSP diagnostics on new frontend/backend files; manual targeted code-path inspection
Not-tested: Full pytest/vitest end-to-end runs blocked by environment temp/cache permission errors; live WebDAV server interoperability
2026-04-09 17:26:37 +08:00
aa12c92a5a feat(temple): add Temple modal with Tools browser and Skills management 2026-04-08 16:46:02 +08:00
51e38e039b chore: update gitignore and remove env.example
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:16:15 +08:00
e637c8ca2f feat(frontend): update chat composables and vite config
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:16:13 +08:00
52fb619084 test(backend): add tests for orchestration and learning runtimes
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:16:12 +08:00
dc9051debc feat(routers): add API endpoints for agents and skills
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:16:10 +08:00
74fdfc2652 feat(services): enhance services with rollback and observability
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:16:08 +08:00
36c93a764f feat(learning): add learning runtime with pattern mining
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:16:07 +08:00
72a60c698a feat(skills): enhance skills system with matching and evaluation
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:16:04 +08:00
4ef7549efe feat(orchestration): add orchestration system with task scheduling
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:11:17 +08:00
de08165e07 feat(agents): enhance agent core with state management
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:10:58 +08:00
4702cc8ed2 feat(database): add schema bootstrap and config
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:10:42 +08:00
62bf414ff2 fix(frontend): weather shows default value when API fails
- Set default weather (Clear 25°C, Beijing) on mount before API call
- Don't overwrite weather on API failure to keep default visible
- Use Beijing coordinates as default fallback location
2026-04-07 13:49:36 +08:00
536c541a5b feat(frontend): redesign KanbanDetail modal - remove sidebar, add editable title, subtasks with drag-drop 2026-04-07 13:16:34 +08:00
7aef898bf5 fix(frontend): calendar session navigation - enable today click always, show indicator only for dates with sessions, use UTC for consistent date matching 2026-04-07 11:18:07 +08:00
721ddbeef9 feat(frontend): add calendar click to switch conversation by date
- Add selected date state and conversation mapping in useSidebarPlan
- Connect calendar cells to conversation switching logic
- Add conversation indicator dot on dates with sessions
- Only clickable dates show hand cursor (today + dates with conversations)
- Add .selected styling for non-today dates, today keeps blue
- Fix hover effect to only apply to non-today dates
- Add daily doc for session date mapping feature

BREAKING: Calendar click now switches sessions by date
2026-04-07 10:28:31 +08:00
3bff9b3b93 feat(frontend): add four-quadrant kanban task management system
- Add KanbanPanel component with four-quadrant task layout
- Add KanbanDetail component for task configuration modal
- Add "待办" (Todo) module to sidebar collapsed icon rail
- Click TODAY'S STATUS card or sidebar icon to open kanban drawer
- Click quadrant check icon to open detail modal with Teleport to body
- Apply blur effect to sidebar and chat area when detail modal is open
- Import ListTodo icon from lucide-vue-next
- Update sidebar labels to English for consistency
2026-04-06 23:48:52 +08:00
3cf8762b96 fix(frontend): change time format to 12-hour with AM/PM
- Change time locale from zh-CN to en-US to properly display AM/PM
- Increase letter-spacing for better readability (0.08em → 0.12em)
- Update all time displays to use 12-hour format consistently
2026-04-06 22:21:54 +08:00
712d9e1652 feat(frontend): add weather icons and redesign calendar header
Backend changes:
- Add LOCATION configuration option to Settings
- Add /api/system/config endpoint to expose public config
- Implement location priority: config > geolocation > default

Frontend changes:
- Install and integrate weather-icons npm package (Erik Flowers)
- Redesign calendar header with date/time on left, weather/location on right
- Display weather icon using CSS classes instead of SVG components
- Fetch location from backend API on component mount
- Use configured location name (from .env) instead of geocoded result

Layout:
- Left: month/year + current time
- Right: city name + weather description + weather icon
2026-04-06 22:18:44 +08:00
ff042cd932 fix(frontend): remove duplicate calendar title-row from sidebar calendar
- Remove calendar-title-row (year/month + time) that was showing below the main date row
- Keep only the primary date display (jarvis-date-row) at the top
- Also removes unused calendarYear/calendarMonth computed properties
2026-04-06 21:33:45 +08:00
472528e708 feat(frontend): add memory components, temple/war-room pages, and composables
- Add DailyDigestCard and ReminderToast memory components
- Add temple and war-room page routes
- Add memory API module with TypeScript definitions
- Add chat composables: useClientTime, useDailyDigest, useSidebarPlan
- Simplify chat/logs/settings pages (remove unused code)
- Add settingsPage.css
2026-04-05 20:45:16 +08:00
e24092f3ab fix(chat): narrow left sidebar (332→280px) and add Chinese font fallbacks for mech aesthetic
Sidebar width reduced for denser layout. Font stacks updated to include Noto Sans SC and Microsoft YaHei fallbacks so Chinese text renders with consistent mech typography. Left sidebar elements (new-chat-btn, conv-title, empty-text, empty-hint) now explicitly use var(--font-display).
2026-04-05 20:37:46 +08:00
f0658201e5 test(agents): expand Code Commander tests to 67 tests
- Phase 1: state, prompts, tools registry (13 tests)
- Phase 2: AI adapters, security classifier, sandbox/executors (54 tests)
  - SecurityClassifier: 21 tests covering classify() with edge cases
  - SandboxEnvironment: 5 tests for create/cleanup/list_files
  - DirectExecutor: 3 tests with mocked subprocess
  - SandboxExecutor: 6 tests with mocked subprocess
- Phase 3: schemas (8 tests)
2026-04-05 18:06:17 +08:00
f033fb5879 test(agents): add Code Commander unit tests for Phases 1-3
Tests Phase 1: state, prompts, tools registry
Tests Phase 2: AI adapters, security classifier, direct executor
Tests Phase 3: schemas (CodeTask, CodeExecutionResult, enums)
2026-04-05 15:02:23 +08:00
5667190abe feat(agents): implement Code Commander module (Phases 1-5)
- Phase 1: Infrastructure (state, prompts, registry)
- Phase 2: Execution engine (AI adapters, security classifier, executors)
- Phase 3: Agent integration (graph nodes, routing)
- Phase 4: Streaming interaction (PTY terminal, WebSocket)
- Phase 5: Frontend integration (Vue components)
2026-04-05 14:56:45 +08:00
11160ec4d2 feat(memory): complete M.2-M.5 memory upgrade phases with tests
- M.2: ForgettingCurve, MemoryDecay, MemoryReinforcement (selective forgetting)
- M.3: DailyDigestGenerator, ReminderScheduler, ProactiveInformer (proactive reminders)
- M.4: MemoryExtractor with LLM-based memory extraction from conversations
- M.5: MemoryRecallInjector with token budget control for prompt injection
- All phases include comprehensive unit tests (109 tests passing)
- Updated checklist.md to mark all tasks complete
2026-04-05 14:09:51 +08:00
9bfa0dcc11 feat(memory): Day M.1 complete - importance scoring system
- Add FrequencyTracker: increment(), get_frequency_score(), get_recency_score(), get_time_decay()
- Add EmotionAnalyzer: EMOTION_KEYWORDS dict, extract(), calculate_score(), get_emotion_profile()
- Add ImpactEvaluator: evaluate(), get_topic_overlap(), rank_by_impact()
- Add ImportanceScorer: composite scoring (freq 35% + recency 20% + emotion 25% + impact 20%)
- Update UserMemory model: frequency_count, emotion_tags, importance_score, importance_level, associated_topics
- Integrate ImportanceScorer into memory_service.py (recall + importance update)
- Add 37 tests for all memory scoring components
- Fix urgency patterns: remove overly broad '今天' that matched neutral text
- Update memory-update checklist: mark all M.1 tasks complete
2026-04-05 13:22:23 +08:00
bfe3b6bb9d docs(tools): update checklist - mark all Phase T.1-T.4 tasks complete 2026-04-05 12:34:13 +08:00
10d9340c53 feat(tools): Phase T.1-T.4 complete - manifest system, registry, implementations, runtime, collaboration, scheduler 2026-04-05 11:54:57 +08:00
1429 changed files with 45637 additions and 3418 deletions

3
.gitignore vendored
View File

@@ -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

View 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",
]

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

View 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

View 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

View 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

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

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

View 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

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

View 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

View File

@@ -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",
] ]

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

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

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

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

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

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

View File

@@ -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,
} }

View File

@@ -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()
) )

View File

@@ -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",
] ]

View File

@@ -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",

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

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

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

View File

@@ -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

View File

@@ -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",
]

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

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

View 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

View File

@@ -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

View 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

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

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

View File

@@ -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,

View File

@@ -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",
]

View 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

View 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

View 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="",
)

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

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

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

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

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

View File

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

View File

@@ -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()

View File

@@ -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")

View File

@@ -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",
] ]

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

View File

@@ -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) # 归档时间

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

View File

@@ -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])

View File

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

View File

@@ -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

View File

@@ -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,

View File

@@ -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"

View File

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

View 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"}

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

View File

@@ -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),
) )

View File

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

View File

@@ -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()

View File

@@ -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,
)

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

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

View File

@@ -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] = []

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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
),
)

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

View 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",
]

View 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]: ...

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

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

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

View File

@@ -7,12 +7,13 @@ import json
import uuid import uuid
import logging import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from time import perf_counter
from typing import Any, AsyncGenerator from typing import Any, AsyncGenerator
import asyncio import asyncio
from openai import BadRequestError from openai import BadRequestError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from langchain_core.messages import HumanMessage, AIMessage from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
from app.database import async_session from app.database import async_session
from app.logging_utils import summarize_llm_config from app.logging_utils import summarize_llm_config
@@ -21,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

View File

@@ -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(

View 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",
]

View 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 []

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

View 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

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

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

View 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

View 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

View 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

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

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

View 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

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

View File

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

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

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from app.config import settings
FEATURE_FLAG_NAMES = (
"ENABLE_RETROSPECTIVE",
"ENABLE_SESSION_RETROSPECTIVE_SEARCH",
"ENABLE_RUNTIME_SKILL_SHORTLIST",
"ENABLE_LEARNING_SIGNALS",
"ENABLE_SKILL_PROMOTION",
"ENABLE_LEARNED_SKILL_LOADING",
"ENABLE_PARALLEL_TASK_GRAPH",
)
class RollbackController:
def snapshot_flags(self) -> dict[str, bool]:
return {
flag_name: bool(getattr(settings, flag_name, False))
for flag_name in FEATURE_FLAG_NAMES
}
def is_enabled(self, flag_name: str) -> bool:
return bool(getattr(settings, flag_name, False))

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from typing import Any
from app.agents.orchestration.monitor import build_parallel_runtime_metrics
def build_runtime_observability_report(
*,
state: dict[str, Any],
feature_flags: dict[str, bool] | None = None,
) -> dict[str, Any]:
task_graph = state.get("task_graph") if isinstance(state.get("task_graph"), dict) else None
scheduled_subtasks = (
state.get("scheduled_subtasks") if isinstance(state.get("scheduled_subtasks"), list) else []
)
task_results = state.get("task_results") if isinstance(state.get("task_results"), list) else []
merge_report = state.get("merge_report") if isinstance(state.get("merge_report"), dict) else None
return {
"execution_mode": state.get("execution_mode"),
"verification_status": state.get("verification_status"),
"skill_shortlist_count": len(state.get("skill_shortlist") or []),
"retrospective_shortlist_count": len(state.get("retrospective_shortlist") or []),
"feature_flags": feature_flags or {},
"parallel_metrics": build_parallel_runtime_metrics(
task_graph=task_graph,
scheduled_subtasks=scheduled_subtasks,
task_results=task_results,
merge_report=merge_report,
),
}

View File

@@ -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}

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

View File

@@ -3,9 +3,13 @@ Skill Service - 技能管理服务层
负责技能的创建、查询、更新、删除等操作 负责技能的创建、查询、更新、删除等操作
""" """
import hashlib
from datetime import UTC, datetime, timedelta
from typing import Optional from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_ from sqlalchemy import select, and_, or_
from app.agents.schemas.learning import SkillCandidate
from app.agents.skills.models import SkillLifecycleDecision
from app.models.skill import Skill from app.models.skill import Skill
from app.models.user import User from app.models.user import User
@@ -28,6 +32,10 @@ class SkillService:
visibility=data.get("visibility", "private"), visibility=data.get("visibility", "private"),
team_id=data.get("team_id"), team_id=data.get("team_id"),
is_active=data.get("is_active", True), is_active=data.get("is_active", True),
status=data.get("status", "active"),
scope=data.get("scope", []),
effectiveness=data.get("effectiveness", 0.0),
review_after=data.get("review_after"),
) )
self.db.add(skill) self.db.add(skill)
await self.db.commit() await self.db.commit()
@@ -41,6 +49,17 @@ class SkillService:
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_by_name_for_user(self, user_id: str, name: str) -> Optional[Skill]:
access_scope = or_(
Skill.owner_id == user_id,
Skill.visibility == "market",
Skill.team_id == user_id,
)
result = await self.db.execute(
select(Skill).where(and_(Skill.name == name, access_scope))
)
return result.scalar_one_or_none()
async def list_for_user( async def list_for_user(
self, self,
user_id: str, user_id: str,
@@ -56,7 +75,7 @@ class SkillService:
Skill.team_id == user_id, Skill.team_id == user_id,
) )
filters = [access_scope, Skill.is_active == True] filters = [access_scope, Skill.is_active == True, Skill.status != "retired"]
if agent_type: if agent_type:
filters.append(Skill.agent_type == agent_type) filters.append(Skill.agent_type == agent_type)
@@ -83,7 +102,7 @@ class SkillService:
update_fields = [ update_fields = [
"name", "description", "instructions", "agent_type", "name", "description", "instructions", "agent_type",
"tools", "required_context", "output_format", "visibility", "tools", "required_context", "output_format", "visibility",
"team_id", "is_active" "team_id", "is_active", "status", "scope", "effectiveness", "review_after"
] ]
for field in update_fields: for field in update_fields:
@@ -117,6 +136,7 @@ class SkillService:
and_( and_(
Skill.agent_type == agent_type, Skill.agent_type == agent_type,
Skill.is_active == True, Skill.is_active == True,
Skill.status == "active",
or_( or_(
Skill.visibility == "market", Skill.visibility == "market",
Skill.visibility == "private" Skill.visibility == "private"
@@ -125,3 +145,234 @@ class SkillService:
) )
) )
return list(result.scalars().all()) return list(result.scalars().all())
async def list_runtime_candidates(
self,
user_id: str,
*,
agent_type: Optional[str] = None,
include_shadow: bool = True,
include_learned: bool = True,
) -> list[Skill]:
allowed_statuses = ["active", "shadow"] if include_shadow else ["active"]
access_scope = or_(
Skill.owner_id == user_id,
Skill.visibility == "market",
Skill.team_id == user_id,
)
filters = [
access_scope,
Skill.is_active == True,
Skill.status.in_(allowed_statuses),
]
if not include_learned:
filters.append(Skill.is_builtin == True)
if agent_type:
filters.append(Skill.agent_type == agent_type)
result = await self.db.execute(select(Skill).where(and_(*filters)))
return list(result.scalars().all())
async def upsert_learned_candidate(
self,
*,
user_id: str,
candidate: SkillCandidate,
primary_agent: str | None,
evidence_refs: list[dict] | None = None,
) -> SkillLifecycleDecision:
source_hash = self._build_candidate_source_hash(candidate)
skill = await self.get_by_name_for_user(user_id, candidate.name)
if skill is None:
review_after = datetime.now(UTC) + timedelta(days=7)
skill = Skill(
owner_id=user_id,
name=candidate.name,
description=candidate.summary,
instructions=candidate.summary,
agent_type=primary_agent or "master",
tools=[],
required_context=[],
output_format=None,
visibility="private",
is_active=True,
status="candidate",
scope=[primary_agent or "master", "learned", candidate.candidate_type],
effectiveness=candidate.confidence,
review_after=review_after,
candidate_count=1,
candidate_source_hashes=[source_hash],
)
self.db.add(skill)
await self.db.commit()
await self.db.refresh(skill)
return SkillLifecycleDecision(
skill_name=skill.name,
action="created_candidate",
previous_status=None,
new_status="candidate",
reason="First learned candidate created from retrospective evidence.",
evidence_refs=evidence_refs or [],
confidence=candidate.confidence,
review_after=review_after,
)
previous_status = skill.status
known_hashes = list(skill.candidate_source_hashes or [])
is_duplicate_candidate = source_hash in known_hashes
if not is_duplicate_candidate:
skill.candidate_count = int(skill.candidate_count or 0) + 1
known_hashes.append(source_hash)
skill.candidate_source_hashes = known_hashes
current_effectiveness = float(skill.effectiveness or 0.0)
skill.effectiveness = round(max(current_effectiveness, float(candidate.confidence or 0.0)), 3)
skill.review_after = datetime.now(UTC) + timedelta(days=7)
if primary_agent and primary_agent not in (skill.scope or []):
skill.scope = [*(skill.scope or []), primary_agent]
action = "no_change"
reason = "Candidate evidence refreshed."
if is_duplicate_candidate:
reason = "Duplicate candidate evidence ignored for promotion counting."
if (
not is_duplicate_candidate
and skill.status == "candidate"
and skill.candidate_count >= 2
and skill.effectiveness >= 0.6
):
skill.status = "shadow"
action = "promoted_to_shadow"
reason = "Repeated candidate evidence promoted the learned skill to shadow."
await self.db.commit()
await self.db.refresh(skill)
return SkillLifecycleDecision(
skill_name=skill.name,
action=action,
previous_status=previous_status,
new_status=skill.status,
reason=reason,
evidence_refs=evidence_refs or [],
confidence=skill.effectiveness,
review_after=skill.review_after,
)
async def record_activation_feedback(
self,
*,
user_id: str,
skill_name: str,
outcome_score: float,
evidence_refs: list[dict] | None = None,
) -> SkillLifecycleDecision | None:
skill = await self.get_by_name_for_user(user_id, skill_name)
if skill is None or skill.status not in {"shadow", "active", "deprecated"}:
return None
previous_status = skill.status
previous_activation_count = int(skill.activation_count or 0)
skill.activation_count = previous_activation_count + 1
skill.last_activated_at = datetime.now(UTC)
previous_effectiveness = float(skill.effectiveness or 0.0)
if previous_activation_count <= 0:
skill.effectiveness = round(outcome_score, 3)
else:
skill.effectiveness = round(
((previous_effectiveness * previous_activation_count) + outcome_score)
/ skill.activation_count,
3,
)
action = "feedback_recorded"
reason = "Activation outcome recorded."
if skill.status == "shadow" and skill.activation_count >= 2 and skill.effectiveness >= 0.7:
skill.status = "active"
action = "promoted_to_active"
reason = "Shadow skill proved effective enough to become active."
elif skill.status == "active" and skill.activation_count >= 3 and skill.effectiveness < 0.35:
skill.status = "deprecated"
action = "degraded_to_deprecated"
reason = "Active skill underperformed repeatedly and was deprecated."
elif skill.status == "deprecated" and skill.activation_count >= 4 and skill.effectiveness < 0.2:
skill.status = "retired"
action = "retired"
reason = "Deprecated skill stayed ineffective and was retired."
elif skill.status == "deprecated" and skill.effectiveness >= 0.65 and outcome_score >= 0.8:
skill.status = "active"
action = "reactivated"
reason = "Deprecated skill recovered with strong positive feedback."
skill.review_after = datetime.now(UTC) + timedelta(days=7)
await self.db.commit()
await self.db.refresh(skill)
return SkillLifecycleDecision(
skill_name=skill.name,
action=action,
previous_status=previous_status,
new_status=skill.status,
reason=reason,
evidence_refs=evidence_refs or [],
confidence=skill.effectiveness,
review_after=skill.review_after,
)
async def run_decay_review(
self,
*,
user_id: str,
as_of: datetime | None = None,
) -> list[SkillLifecycleDecision]:
review_time = as_of or datetime.now(UTC)
result = await self.db.execute(
select(Skill).where(
and_(
Skill.owner_id == user_id,
Skill.is_active == True,
Skill.status.in_(["shadow", "active", "deprecated"]),
Skill.review_after.is_not(None),
Skill.review_after <= review_time,
)
)
)
skills = list(result.scalars().all())
decisions: list[SkillLifecycleDecision] = []
for skill in skills:
previous_status = skill.status
action = "no_change"
reason = "Review completed without status change."
if skill.status == "shadow" and float(skill.effectiveness or 0.0) < 0.45:
skill.status = "deprecated"
action = "degraded_to_deprecated"
reason = "Shadow skill review found low effectiveness."
elif skill.status == "deprecated" and float(skill.effectiveness or 0.0) < 0.2:
skill.status = "retired"
action = "retired"
reason = "Deprecated skill remained weak through review."
skill.review_after = review_time + timedelta(days=7)
decisions.append(
SkillLifecycleDecision(
skill_name=skill.name,
action=action,
previous_status=previous_status,
new_status=skill.status,
reason=reason,
confidence=skill.effectiveness,
review_after=skill.review_after,
)
)
await self.db.commit()
return decisions
@staticmethod
def _build_candidate_source_hash(candidate: SkillCandidate) -> str:
raw = (
f"{candidate.name}|{candidate.summary}|"
f"{','.join(candidate.source_pattern_ids)}|"
f"{len(candidate.evidence_refs)}"
).encode("utf-8")
return hashlib.sha1(raw).hexdigest()

View File

@@ -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