PMFlow/backend/app/services/domain.py

449 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Neural Sync 2.0 - Project Domain Service
项目域服务 - 管理动态项目域
每个域有独立的信号源、知识库和配置
"""
import asyncio
import logging
from typing import Optional, Dict, List, Any
from pydantic import BaseModel, Field
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
class DomainConfig(BaseModel):
"""项目域配置"""
signal_sources: List[str] = Field(default_factory=lambda: ["hackernews"])
ai_config: Dict[str, Any] = Field(default_factory=dict)
audit_threshold: float = 98.0
auto_merge_enabled: bool = False
webhook_urls: Dict[str, str] = Field(default_factory=dict)
class ProjectDomainCreate(BaseModel):
"""创建项目域请求"""
name: str
slug: str
description: Optional[str] = None
config: Optional[DomainConfig] = None
is_default: bool = False
class ProjectDomainUpdate(BaseModel):
"""更新项目域请求"""
name: Optional[str] = None
description: Optional[str] = None
config: Optional[DomainConfig] = None
is_active: Optional[bool] = None
is_default: Optional[bool] = None
class ProjectDomainResponse(BaseModel):
"""项目域响应"""
id: str
name: str
slug: str
description: Optional[str]
config: DomainConfig
is_active: bool
is_default: bool
signal_count: int
context_node_count: int
experiment_count: int
created_at: datetime
updated_at: datetime
last_synced_at: Optional[datetime]
class DomainSyncProgress(BaseModel):
"""域同步进度"""
domain_id: str
phase: str # connecting, loading_knowledge, syncing_signals, complete
progress: float # 0.0 - 1.0
message: str
class ProjectDomainService:
"""
项目域服务
功能:
- CRUD 操作
- 域切换和知识同步
- 统计信息更新
- 默认域管理
"""
def __init__(self, db_session: AsyncSession, event_bus=None):
self.db = db_session
self.event_bus = event_bus
self._active_domain_id: Optional[str] = None
async def create_domain(self, request: ProjectDomainCreate) -> ProjectDomainResponse:
"""创建新项目域"""
from ..models import ProjectDomain
import uuid
# 检查 slug 唯一性
existing = await self.db.execute(
select(ProjectDomain).where(ProjectDomain.slug == request.slug)
)
if existing.scalar_one_or_none():
raise ValueError(f"Slug '{request.slug}' already exists")
# 如果设置为默认域,取消其他域的默认状态
if request.is_default:
await self._clear_default_domains()
# 序列化配置
config_dict = request.config.model_dump() if request.config else {}
domain = ProjectDomain(
name=request.name,
slug=request.slug,
description=request.description,
config=config_dict,
is_default=request.is_default,
)
self.db.add(domain)
await self.db.commit()
await self.db.refresh(domain)
# 发布域创建事件
if self.event_bus:
from ..events import EventType, Event
await self.event_bus.publish(
Event(
type=EventType.DOMAIN_CREATED,
data={
"domain_id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
},
)
)
return self._to_response(domain)
async def get_domain(self, domain_id: str) -> Optional[ProjectDomainResponse]:
"""获取单个域"""
from ..models import ProjectDomain
import uuid
try:
domain_uuid = uuid.UUID(domain_id)
except ValueError:
return None
result = await self.db.execute(
select(ProjectDomain).where(ProjectDomain.id == domain_uuid)
)
domain = result.scalar_one_or_none()
if not domain:
return None
return self._to_response(domain)
async def get_domain_by_slug(self, slug: str) -> Optional[ProjectDomainResponse]:
"""通过 slug 获取域"""
from ..models import ProjectDomain
result = await self.db.execute(
select(ProjectDomain).where(ProjectDomain.slug == slug)
)
domain = result.scalar_one_or_none()
if not domain:
return None
return self._to_response(domain)
async def list_domains(
self, include_inactive: bool = False
) -> List[ProjectDomainResponse]:
"""列出所有域"""
from ..models import ProjectDomain
query = select(ProjectDomain)
if not include_inactive:
query = query.where(ProjectDomain.is_active == True)
query = query.order_by(ProjectDomain.is_default.desc(), ProjectDomain.created_at.asc())
result = await self.db.execute(query)
domains = result.scalars().all()
return [self._to_response(d) for d in domains]
async def update_domain(
self, domain_id: str, request: ProjectDomainUpdate
) -> Optional[ProjectDomainResponse]:
"""更新域"""
from ..models import ProjectDomain
import uuid
try:
domain_uuid = uuid.UUID(domain_id)
except ValueError:
return None
result = await self.db.execute(
select(ProjectDomain).where(ProjectDomain.id == domain_uuid)
)
domain = result.scalar_one_or_none()
if not domain:
return None
# 更新字段
if request.name is not None:
domain.name = request.name
if request.description is not None:
domain.description = request.description
if request.config is not None:
domain.config = request.config.model_dump()
if request.is_active is not None:
domain.is_active = request.is_active
if request.is_default is not None and request.is_default != domain.is_default:
if request.is_default:
await self._clear_default_domains()
domain.is_default = request.is_default
domain.updated_at = datetime.utcnow()
await self.db.commit()
await self.db.refresh(domain)
# 发布域更新事件
if self.event_bus:
from ..events import EventType, Event
await self.event_bus.publish(
Event(
type=EventType.DOMAIN_UPDATED,
data={
"domain_id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
},
)
)
return self._to_response(domain)
async def delete_domain(self, domain_id: str) -> bool:
"""删除域"""
from ..models import ProjectDomain
import uuid
try:
domain_uuid = uuid.UUID(domain_id)
except ValueError:
return False
result = await self.db.execute(
select(ProjectDomain).where(ProjectDomain.id == domain_uuid)
)
domain = result.scalar_one_or_none()
if not domain:
return False
# 不允许删除默认域
if domain.is_default:
raise ValueError("Cannot delete default domain")
await self.db.delete(domain)
await self.db.commit()
# 发布域删除事件
if self.event_bus:
from ..events import EventType, Event
await self.event_bus.publish(
Event(
type=EventType.DOMAIN_DELETED,
data={
"domain_id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
},
)
)
return True
async def switch_domain(self, domain_id: str) -> DomainSyncProgress:
"""
切换活动域
流程:
1. 验证域存在且激活
2. 开始知识同步(1.5s 动画)
3. 更新活动域 ID
4. 返回同步进度
"""
from ..models import ProjectDomain
import uuid
try:
domain_uuid = uuid.UUID(domain_id)
except ValueError:
raise ValueError("Invalid domain ID")
result = await self.db.execute(
select(ProjectDomain).where(ProjectDomain.id == domain_uuid)
)
domain = result.scalar_one_or_none()
if not domain:
raise ValueError("Domain not found")
if not domain.is_active:
raise ValueError("Domain is not active")
# 更新最后同步时间
domain.last_synced_at = datetime.utcnow()
await self.db.commit()
# 设置活动域
self._active_domain_id = str(domain.id)
# 发布域切换事件
if self.event_bus:
from ..events import EventType, Event
await self.event_bus.publish(
Event(
type=EventType.DOMAIN_SWITCHED,
data={
"domain_id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
},
)
)
# 模拟同步进度(1.5s)
return DomainSyncProgress(
domain_id=str(domain.id),
phase="complete",
progress=1.0,
message=f"已切换到 {domain.name}",
)
async def get_default_domain(self) -> Optional[ProjectDomainResponse]:
"""获取默认域"""
from ..models import ProjectDomain
result = await self.db.execute(
select(ProjectDomain).where(ProjectDomain.is_default == True)
)
domain = result.scalar_one_or_none()
if not domain:
# 如果没有默认域,返回第一个激活域
result = await self.db.execute(
select(ProjectDomain)
.where(ProjectDomain.is_active == True)
.order_by(ProjectDomain.created_at.asc())
.limit(1)
)
domain = result.scalar_one_or_none()
if not domain:
return None
return self._to_response(domain)
async def update_domain_stats(self, domain_id: str) -> None:
"""更新域统计信息"""
from ..models import ProjectDomain, Signal, ContextNode, Experiment
import uuid
try:
domain_uuid = uuid.UUID(domain_id)
except ValueError:
return
result = await self.db.execute(
select(ProjectDomain).where(ProjectDomain.id == domain_uuid)
)
domain = result.scalar_one_or_none()
if not domain:
return
# TODO: 需要添加域关联到 Signal, ContextNode, Experiment
# 当前使用全局计数
signal_count = await self.db.execute(select(Signal))
domain.signal_count = len(signal_count.scalars().all())
context_count = await self.db.execute(select(ContextNode))
domain.context_node_count = len(context_count.scalars().all())
experiment_count = await self.db.execute(select(Experiment))
domain.experiment_count = len(experiment_count.scalars().all())
await self.db.commit()
async def get_active_domain_id(self) -> Optional[str]:
"""获取当前活动域 ID"""
if self._active_domain_id:
return self._active_domain_id
# 如果没有设置,返回默认域
default = await self.get_default_domain()
if default:
self._active_domain_id = default.id
return default.id
return None
async def _clear_default_domains(self) -> None:
"""清除所有域的默认状态"""
from ..models import ProjectDomain
result = await self.db.execute(
select(ProjectDomain).where(ProjectDomain.is_default == True)
)
domains = result.scalars().all()
for domain in domains:
domain.is_default = False
await self.db.commit()
def _to_response(self, domain) -> ProjectDomainResponse:
"""转换为响应模型"""
config = DomainConfig(**domain.config) if domain.config else DomainConfig()
return ProjectDomainResponse(
id=str(domain.id),
name=domain.name,
slug=domain.slug,
description=domain.description,
config=config,
is_active=domain.is_active,
is_default=domain.is_default,
signal_count=domain.signal_count,
context_node_count=domain.context_node_count,
experiment_count=domain.experiment_count,
created_at=domain.created_at,
updated_at=domain.updated_at,
last_synced_at=getattr(domain, 'last_synced_at', None),
)
# 全局服务实例
_domain_service = None
def get_domain_service(db_session: AsyncSession, event_bus=None) -> ProjectDomainService:
"""获取项目域服务实例"""
return ProjectDomainService(db_session, event_bus)