feat: complete DDD Phase 2 - add domain services, infrastructure layers, and shared components

- domain/value_objects: Add Topic, UserTitle, GoldenQuote, Statistics value objects
- domain/services: Add StatisticsCalculator, ReportGenerator domain services
- domain/exceptions: Add comprehensive domain exception hierarchy
- infrastructure/persistence: Add HistoryRepository for data storage
- infrastructure/llm: Add LLMClient wrapper for AstrBot providers
- infrastructure/config: Add ConfigManager for centralized configuration
- infrastructure/resilience: Add CircuitBreaker, RateLimiter, retry utilities
- application: Add SchedulingService, ReportingService application services
- shared: Add constants and TraceContext for request tracing

All imports verified in Docker container.
This commit is contained in:
SXP-Simon
2026-02-08 14:39:19 +08:00
parent 8f1878356d
commit 7ef58f9d53
27 changed files with 3379 additions and 3 deletions
+8 -1
View File
@@ -1,5 +1,12 @@
# Application Layer - Orchestration and Use Cases
from .analysis_orchestrator import AnalysisOrchestrator
from .message_converter import MessageConverter
from .scheduling_service import SchedulingService
from .reporting_service import ReportingService
__all__ = ["AnalysisOrchestrator", "MessageConverter"]
__all__ = [
"AnalysisOrchestrator",
"MessageConverter",
"SchedulingService",
"ReportingService",
]
+263
View File
@@ -0,0 +1,263 @@
"""
Reporting Service - Application service for generating and sending reports
This service coordinates report generation and delivery to groups.
"""
from datetime import datetime
from typing import Any, Dict, List, Optional
from astrbot.api import logger
from ..domain.services import ReportGenerator
from ..domain.value_objects.topic import Topic
from ..domain.value_objects.user_title import UserTitle
from ..domain.value_objects.golden_quote import GoldenQuote
from ..domain.value_objects.statistics import GroupStatistics
from ..infrastructure.config import ConfigManager
from ..infrastructure.persistence import HistoryRepository
class ReportingService:
"""
Application service for generating and managing reports.
This service coordinates between domain services and infrastructure
to produce and deliver analysis reports.
"""
def __init__(
self,
config: ConfigManager,
history_repository: HistoryRepository,
):
"""
Initialize the reporting service.
Args:
config: Configuration manager
history_repository: Repository for storing reports
"""
self.config = config
self.history = history_repository
def generate_report(
self,
group_id: str,
group_name: str,
statistics: GroupStatistics,
topics: List[Topic],
user_titles: List[UserTitle],
golden_quotes: List[GoldenQuote],
date_str: Optional[str] = None,
) -> str:
"""
Generate a complete analysis report.
Args:
group_id: Group identifier
group_name: Group display name
statistics: Group statistics
topics: List of discussion topics
user_titles: List of user titles
golden_quotes: List of golden quotes
date_str: Report date (defaults to today)
Returns:
Formatted report string
"""
date_str = date_str or datetime.now().strftime("%Y-%m-%d")
generator = ReportGenerator(
group_name=group_name,
date_str=date_str,
)
# Generate report based on configuration
report = generator.generate_full_report(
statistics=statistics,
topics=topics if self.config.get_include_topics() else [],
user_titles=user_titles if self.config.get_include_user_titles() else [],
golden_quotes=golden_quotes if self.config.get_include_golden_quotes() else [],
include_header=True,
include_footer=True,
)
return report
def generate_summary(
self,
group_id: str,
statistics: GroupStatistics,
top_topic: Optional[Topic] = None,
top_quote: Optional[GoldenQuote] = None,
date_str: Optional[str] = None,
) -> str:
"""
Generate a brief summary report.
Args:
group_id: Group identifier
statistics: Group statistics
top_topic: Most significant topic
top_quote: Best golden quote
date_str: Report date
Returns:
Brief summary string
"""
date_str = date_str or datetime.now().strftime("%Y-%m-%d")
generator = ReportGenerator(date_str=date_str)
return generator.generate_summary_report(
statistics=statistics,
top_topic=top_topic,
top_quote=top_quote,
)
def save_report(
self,
group_id: str,
report_data: Dict[str, Any],
date_str: Optional[str] = None,
) -> bool:
"""
Save a report to history.
Args:
group_id: Group identifier
report_data: Report data dictionary
date_str: Report date
Returns:
True if saved successfully
"""
date_str = date_str or datetime.now().strftime("%Y-%m-%d")
return self.history.save_analysis_result(
group_id=group_id,
result=report_data,
date_str=date_str,
)
def get_report(
self,
group_id: str,
date_str: str,
) -> Optional[Dict[str, Any]]:
"""
Get a saved report.
Args:
group_id: Group identifier
date_str: Report date
Returns:
Report data or None
"""
return self.history.get_analysis_result(group_id, date_str)
def get_recent_reports(
self,
group_id: str,
limit: int = 7,
) -> List[Dict[str, Any]]:
"""
Get recent reports for a group.
Args:
group_id: Group identifier
limit: Maximum number of reports
Returns:
List of report data dictionaries
"""
return self.history.get_recent_results(group_id, limit)
def has_report_for_today(self, group_id: str) -> bool:
"""
Check if a report exists for today.
Args:
group_id: Group identifier
Returns:
True if report exists
"""
today = datetime.now().strftime("%Y-%m-%d")
return self.history.has_analysis_for_date(group_id, today)
def format_for_platform(
self,
report: str,
platform: str,
format_type: Optional[str] = None,
) -> str:
"""
Format a report for a specific platform.
Args:
report: Raw report text
platform: Target platform
format_type: Override format type
Returns:
Platform-formatted report
"""
format_type = format_type or self.config.get_report_format()
# For now, return as-is. Can be extended for platform-specific formatting
if format_type == "markdown":
return report
elif format_type == "text":
# Strip markdown formatting
return self._strip_markdown(report)
else:
return report
def _strip_markdown(self, text: str) -> str:
"""Strip markdown formatting from text."""
# Simple markdown stripping
import re
# Remove bold
text = re.sub(r"\*\*(.*?)\*\*", r"\1", text)
# Remove italic
text = re.sub(r"\*(.*?)\*", r"\1", text)
# Remove headers
text = re.sub(r"^#+\s*", "", text, flags=re.MULTILINE)
return text
def create_report_data(
self,
group_id: str,
group_name: str,
statistics: GroupStatistics,
topics: List[Topic],
user_titles: List[UserTitle],
golden_quotes: List[GoldenQuote],
) -> Dict[str, Any]:
"""
Create a report data dictionary for storage.
Args:
group_id: Group identifier
group_name: Group display name
statistics: Group statistics
topics: List of topics
user_titles: List of user titles
golden_quotes: List of golden quotes
Returns:
Report data dictionary
"""
return {
"group_id": group_id,
"group_name": group_name,
"timestamp": datetime.now().isoformat(),
"statistics": statistics.to_dict(),
"topics": [t.to_dict() for t in topics],
"user_titles": [u.to_dict() for u in user_titles],
"golden_quotes": [q.to_dict() for q in golden_quotes],
}
+264
View File
@@ -0,0 +1,264 @@
"""
Scheduling Service - Application service for scheduled analysis
This service manages scheduled analysis tasks and coordinates
with the analysis orchestrator.
"""
import asyncio
from datetime import datetime, timedelta
from typing import Any, Callable, Dict, List, Optional, Set
from astrbot.api import logger
from ..infrastructure.config import ConfigManager
from ..shared.constants import TASK_STATE_PENDING, TASK_STATE_RUNNING, TASK_STATE_COMPLETED
class ScheduledTask:
"""Represents a scheduled analysis task."""
def __init__(
self,
task_id: str,
group_id: str,
scheduled_time: str, # HH:MM format
callback: Callable,
enabled: bool = True,
):
self.task_id = task_id
self.group_id = group_id
self.scheduled_time = scheduled_time
self.callback = callback
self.enabled = enabled
self.last_run: Optional[datetime] = None
self.next_run: Optional[datetime] = None
self._calculate_next_run()
def _calculate_next_run(self) -> None:
"""Calculate the next run time."""
if not self.enabled:
self.next_run = None
return
try:
hours, minutes = map(int, self.scheduled_time.split(":"))
now = datetime.now()
next_run = now.replace(hour=hours, minute=minutes, second=0, microsecond=0)
# If the time has passed today, schedule for tomorrow
if next_run <= now:
next_run += timedelta(days=1)
self.next_run = next_run
except ValueError:
logger.error(f"Invalid scheduled time format: {self.scheduled_time}")
self.next_run = None
def should_run(self) -> bool:
"""Check if the task should run now."""
if not self.enabled or not self.next_run:
return False
now = datetime.now()
# Check if we're within the execution window (5 minute tolerance)
if self.next_run <= now <= self.next_run + timedelta(minutes=5):
# Check if we haven't run today
if self.last_run is None or self.last_run.date() != now.date():
return True
return False
def mark_completed(self) -> None:
"""Mark the task as completed and schedule next run."""
self.last_run = datetime.now()
self._calculate_next_run()
class SchedulingService:
"""
Application service for managing scheduled analysis tasks.
This service runs a background loop that checks for and
executes scheduled tasks.
"""
def __init__(self, config: ConfigManager):
"""
Initialize the scheduling service.
Args:
config: Configuration manager
"""
self.config = config
self._tasks: Dict[str, ScheduledTask] = {}
self._running = False
self._task: Optional[asyncio.Task] = None
self._callbacks: Dict[str, Callable] = {}
def register_callback(self, name: str, callback: Callable) -> None:
"""
Register a callback for scheduled tasks.
Args:
name: Callback name
callback: Async callback function
"""
self._callbacks[name] = callback
def add_task(
self,
group_id: str,
scheduled_time: Optional[str] = None,
callback_name: str = "analyze",
) -> str:
"""
Add a scheduled task for a group.
Args:
group_id: Group identifier
scheduled_time: Time in HH:MM format (uses config default if not provided)
callback_name: Name of registered callback to use
Returns:
Task ID
"""
scheduled_time = scheduled_time or self.config.get_analysis_time()
task_id = f"task_{group_id}"
callback = self._callbacks.get(callback_name)
if not callback:
logger.warning(f"Callback '{callback_name}' not registered")
return task_id
task = ScheduledTask(
task_id=task_id,
group_id=group_id,
scheduled_time=scheduled_time,
callback=callback,
enabled=True,
)
self._tasks[task_id] = task
logger.info(f"Added scheduled task {task_id} for {scheduled_time}")
return task_id
def remove_task(self, task_id: str) -> bool:
"""
Remove a scheduled task.
Args:
task_id: Task identifier
Returns:
True if task was removed
"""
if task_id in self._tasks:
del self._tasks[task_id]
logger.info(f"Removed scheduled task {task_id}")
return True
return False
def enable_task(self, task_id: str) -> bool:
"""Enable a scheduled task."""
if task_id in self._tasks:
self._tasks[task_id].enabled = True
self._tasks[task_id]._calculate_next_run()
return True
return False
def disable_task(self, task_id: str) -> bool:
"""Disable a scheduled task."""
if task_id in self._tasks:
self._tasks[task_id].enabled = False
self._tasks[task_id].next_run = None
return True
return False
def get_task_status(self, task_id: str) -> Optional[Dict[str, Any]]:
"""
Get status of a scheduled task.
Args:
task_id: Task identifier
Returns:
Task status dictionary or None
"""
task = self._tasks.get(task_id)
if not task:
return None
return {
"task_id": task.task_id,
"group_id": task.group_id,
"scheduled_time": task.scheduled_time,
"enabled": task.enabled,
"last_run": task.last_run.isoformat() if task.last_run else None,
"next_run": task.next_run.isoformat() if task.next_run else None,
}
def list_tasks(self) -> List[Dict[str, Any]]:
"""List all scheduled tasks."""
return [self.get_task_status(tid) for tid in self._tasks.keys()]
async def start(self) -> None:
"""Start the scheduling service."""
if self._running:
return
self._running = True
self._task = asyncio.create_task(self._run_loop())
logger.info("Scheduling service started")
async def stop(self) -> None:
"""Stop the scheduling service."""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
logger.info("Scheduling service stopped")
async def _run_loop(self) -> None:
"""Main scheduling loop."""
while self._running:
try:
await self._check_and_run_tasks()
# Check every minute
await asyncio.sleep(60)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in scheduling loop: {e}")
await asyncio.sleep(60)
async def _check_and_run_tasks(self) -> None:
"""Check for and execute due tasks."""
for task in list(self._tasks.values()):
if task.should_run():
try:
logger.info(f"Executing scheduled task {task.task_id}")
await task.callback(task.group_id)
task.mark_completed()
logger.info(f"Completed scheduled task {task.task_id}")
except Exception as e:
logger.error(f"Failed to execute task {task.task_id}: {e}")
def setup_from_config(self) -> None:
"""Set up scheduled tasks from configuration."""
if not self.config.get_auto_analysis_enabled():
logger.info("Auto analysis is disabled")
return
enabled_groups = self.config.get_enabled_groups()
analysis_time = self.config.get_analysis_time()
for group_id in enabled_groups:
self.add_task(group_id, analysis_time)
logger.info(f"Set up {len(enabled_groups)} scheduled tasks")