```
feat: 添加AI记忆模块和事件调度系统 - 新增AI记忆模块,支持本地记忆召回和自动写入功能 - 实现事件调度系统,支持批量处理待定事件和重试机制 - 集成心跳监控机制,跟踪API、调度器和工作节点状态 - 扩展仪表板数据统计,包含AI记忆条目和心跳概要 - 添加企业运营分析报告功能,提供财务、采购等多维度分析 - 更新配置设置,增加事件调度和AI记忆相关参数 - 优化任务队列,添加事件分发任务类型 - 扩展审计日志,记录AI记忆操作和事件调度行为 - 实现领域事件模型,支持事件持久化和状态管理 - 添加观察性服务,监控系统组件健康状况 ```
This commit is contained in:
@@ -2,6 +2,7 @@ from fastapi import APIRouter
|
||||
|
||||
from app.core.constants import ApiResponseKey, ApiStatus
|
||||
from app.modules.ai_agent.routes import router as ai_router
|
||||
from app.modules.ai_memory.routes import router as ai_memory_router
|
||||
from app.modules.audit.routes import router as audit_router
|
||||
from app.modules.business.routes import router as business_router
|
||||
from app.modules.dashboard.routes import router as dashboard_router
|
||||
@@ -28,6 +29,7 @@ api_router.include_router(dashboard_router, prefix="/dashboard", tags=["dashboar
|
||||
api_router.include_router(legacy_mysql_router, prefix="/integrations/mysql", tags=["mysql"])
|
||||
api_router.include_router(feishu_router, prefix="/integrations/feishu", tags=["feishu"])
|
||||
api_router.include_router(ai_router, prefix="/ai", tags=["ai"])
|
||||
api_router.include_router(ai_memory_router, prefix="/ai", tags=["ai-memory"])
|
||||
api_router.include_router(reports_router, prefix="/reports", tags=["reports"])
|
||||
api_router.include_router(risk_router, prefix="/risks", tags=["risks"])
|
||||
api_router.include_router(audit_router, prefix="/audit", tags=["audit"])
|
||||
|
||||
@@ -80,6 +80,34 @@ class Settings(BaseSettings):
|
||||
legacy_project_sync_cron_minute: int = 0
|
||||
legacy_task_sync_cron_hour: int = 2
|
||||
legacy_task_sync_cron_minute: int = 30
|
||||
event_dispatch_enabled: bool = True
|
||||
event_dispatch_batch_size: int = 100
|
||||
event_dispatch_max_attempts: int = 3
|
||||
event_dispatch_retry_delay_seconds: int = 300
|
||||
event_dispatch_lock_seconds: int = 300
|
||||
event_dispatch_cron_minute: str = "*/5"
|
||||
heartbeat_interval_seconds: int = 60
|
||||
ai_memory_enabled: bool = True
|
||||
ai_memory_auto_write_enabled: bool = True
|
||||
ai_memory_recall_limit: int = 5
|
||||
ai_memory_forbidden_keys: list[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"authorization",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"access_token",
|
||||
"tenant_access_token",
|
||||
"token",
|
||||
"secret",
|
||||
"password",
|
||||
"openclaw_gateway_token",
|
||||
"hermes_api_key",
|
||||
"direct_llm_api_key",
|
||||
"feishu_app_secret",
|
||||
"feishu_verification_token",
|
||||
]
|
||||
)
|
||||
|
||||
@field_validator("cors_origins", mode="before")
|
||||
@classmethod
|
||||
def parse_cors_origins(cls, value: Any) -> list[str]:
|
||||
@@ -98,6 +126,7 @@ class Settings(BaseSettings):
|
||||
@field_validator(
|
||||
"openclaw_allowed_tools",
|
||||
"openclaw_allowed_actions",
|
||||
"ai_memory_forbidden_keys",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
|
||||
@@ -6,6 +6,7 @@ class ActorValue(StrEnum):
|
||||
AUDITOR = "auditor"
|
||||
SYSTEM = "system"
|
||||
SCHEDULER = "scheduler"
|
||||
WORKER = "worker"
|
||||
FEISHU = "feishu"
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from socket import gethostname
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ActorValue
|
||||
from app.modules.feishu.constants import FeishuReceiveIdType
|
||||
from app.modules.observability.constants import HeartbeatComponent
|
||||
|
||||
|
||||
def attach_scheduler(app: FastAPI) -> None:
|
||||
@@ -12,35 +16,53 @@ def attach_scheduler(app: FastAPI) -> None:
|
||||
if not settings.scheduler_enabled:
|
||||
return
|
||||
|
||||
scheduler = create_scheduler(app)
|
||||
|
||||
@app.on_event("startup")
|
||||
def start_scheduler() -> None:
|
||||
scheduler.start()
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def stop_scheduler() -> None:
|
||||
scheduler.shutdown(wait=False)
|
||||
|
||||
|
||||
def create_scheduler(app: FastAPI | None = None) -> Any:
|
||||
"""Create the V2/V3 scheduler without requiring a FastAPI process."""
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.core.task_queue import (
|
||||
enqueue_daily_brief_push,
|
||||
enqueue_event_dispatch,
|
||||
enqueue_legacy_project_sync,
|
||||
enqueue_legacy_task_sync,
|
||||
enqueue_project_weekly_push,
|
||||
)
|
||||
from app.modules.observability.service import ObservabilityService
|
||||
from app.modules.reports.service import ReportService
|
||||
|
||||
settings = get_settings()
|
||||
scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
|
||||
|
||||
def run_daily_brief() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
report = ReportService(db).daily_brief()
|
||||
app.state.last_daily_brief = report
|
||||
_set_state(app, "last_daily_brief", report)
|
||||
if (
|
||||
settings.feishu_app_id
|
||||
and settings.feishu_app_secret
|
||||
and settings.feishu_default_chat_id
|
||||
):
|
||||
if settings.task_queue_enabled:
|
||||
app.state.last_daily_brief_dispatch = enqueue_daily_brief_push(
|
||||
dispatch = enqueue_daily_brief_push(
|
||||
receive_id=settings.feishu_default_chat_id,
|
||||
receive_id_type=FeishuReceiveIdType.CHAT_ID,
|
||||
actor=ActorValue.SCHEDULER,
|
||||
)
|
||||
_set_state(app, "last_daily_brief_dispatch", dispatch)
|
||||
return
|
||||
ReportService(db).push_report(
|
||||
report,
|
||||
@@ -55,18 +77,19 @@ def attach_scheduler(app: FastAPI) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
report = ReportService(db).project_weekly()
|
||||
app.state.last_project_weekly = report
|
||||
_set_state(app, "last_project_weekly", report)
|
||||
if (
|
||||
settings.feishu_app_id
|
||||
and settings.feishu_app_secret
|
||||
and settings.feishu_default_chat_id
|
||||
):
|
||||
if settings.task_queue_enabled:
|
||||
app.state.last_project_weekly_dispatch = enqueue_project_weekly_push(
|
||||
dispatch = enqueue_project_weekly_push(
|
||||
receive_id=settings.feishu_default_chat_id,
|
||||
receive_id_type=FeishuReceiveIdType.CHAT_ID,
|
||||
actor=ActorValue.SCHEDULER,
|
||||
)
|
||||
_set_state(app, "last_project_weekly_dispatch", dispatch)
|
||||
return
|
||||
ReportService(db).push_report(
|
||||
report,
|
||||
@@ -78,14 +101,31 @@ def attach_scheduler(app: FastAPI) -> None:
|
||||
db.close()
|
||||
|
||||
def run_legacy_project_sync() -> None:
|
||||
app.state.last_legacy_project_sync_dispatch = enqueue_legacy_project_sync(
|
||||
actor=ActorValue.SCHEDULER,
|
||||
)
|
||||
dispatch = enqueue_legacy_project_sync(actor=ActorValue.SCHEDULER)
|
||||
_set_state(app, "last_legacy_project_sync_dispatch", dispatch)
|
||||
|
||||
def run_legacy_task_sync() -> None:
|
||||
app.state.last_legacy_task_sync_dispatch = enqueue_legacy_task_sync(
|
||||
dispatch = enqueue_legacy_task_sync(actor=ActorValue.SCHEDULER)
|
||||
_set_state(app, "last_legacy_task_sync_dispatch", dispatch)
|
||||
|
||||
def run_event_dispatch() -> None:
|
||||
dispatch = enqueue_event_dispatch(
|
||||
limit=settings.event_dispatch_batch_size,
|
||||
actor=ActorValue.SCHEDULER,
|
||||
)
|
||||
_set_state(app, "last_event_dispatch", dispatch)
|
||||
|
||||
def record_scheduler_heartbeat() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
heartbeat = ObservabilityService(db).record_heartbeat(
|
||||
component=HeartbeatComponent.SCHEDULER,
|
||||
instance_id=gethostname(),
|
||||
actor=ActorValue.SCHEDULER,
|
||||
)
|
||||
_set_state(app, "last_scheduler_heartbeat", heartbeat)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
scheduler.add_job(
|
||||
run_daily_brief,
|
||||
@@ -104,6 +144,21 @@ def attach_scheduler(app: FastAPI) -> None:
|
||||
id="project_weekly_push",
|
||||
replace_existing=True,
|
||||
)
|
||||
if settings.event_dispatch_enabled:
|
||||
scheduler.add_job(
|
||||
run_event_dispatch,
|
||||
trigger="cron",
|
||||
minute=settings.event_dispatch_cron_minute,
|
||||
id="event_dispatch",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
record_scheduler_heartbeat,
|
||||
trigger="interval",
|
||||
seconds=settings.heartbeat_interval_seconds,
|
||||
id="scheduler_heartbeat",
|
||||
replace_existing=True,
|
||||
)
|
||||
if settings.legacy_sync_enabled and settings.legacy_project_query:
|
||||
scheduler.add_job(
|
||||
run_legacy_project_sync,
|
||||
@@ -122,11 +177,9 @@ def attach_scheduler(app: FastAPI) -> None:
|
||||
id="legacy_task_sync",
|
||||
replace_existing=True,
|
||||
)
|
||||
return scheduler
|
||||
|
||||
@app.on_event("startup")
|
||||
def start_scheduler() -> None:
|
||||
scheduler.start()
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def stop_scheduler() -> None:
|
||||
scheduler.shutdown(wait=False)
|
||||
def _set_state(app: FastAPI | None, key: str, value: Any) -> None:
|
||||
if app is not None:
|
||||
setattr(app.state, key, value)
|
||||
|
||||
@@ -9,6 +9,7 @@ TASK_PUSH_PROJECT_WEEKLY = "reports.push_project_weekly"
|
||||
TASK_GENERATE_RISK_EVENTS = "risks.generate_events"
|
||||
TASK_SYNC_LEGACY_PROJECTS = "legacy.sync_projects"
|
||||
TASK_SYNC_LEGACY_TASKS = "legacy.sync_tasks"
|
||||
TASK_DISPATCH_PENDING_EVENTS = "events.dispatch_pending"
|
||||
|
||||
|
||||
def dispatch_task(
|
||||
@@ -191,6 +192,31 @@ def enqueue_risk_event_generation(actor: str = "scheduler") -> dict[str, Any]:
|
||||
)
|
||||
|
||||
|
||||
def enqueue_event_dispatch(
|
||||
limit: int | None = None,
|
||||
actor: str = "scheduler",
|
||||
) -> dict[str, Any]:
|
||||
def inline() -> Any:
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import SessionLocal
|
||||
from app.modules.events.service import EventService
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return EventService(db).dispatch_pending(
|
||||
limit=limit or get_settings().event_dispatch_batch_size,
|
||||
worker_id=actor,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return dispatch_task(
|
||||
TASK_DISPATCH_PENDING_EVENTS,
|
||||
{"limit": limit, "actor": actor},
|
||||
inline,
|
||||
)
|
||||
|
||||
|
||||
def enqueue_legacy_project_sync(
|
||||
source_query: str | None = None,
|
||||
source_query_name: str | None = None,
|
||||
|
||||
@@ -29,6 +29,8 @@ class AIResponseKey(StrEnum):
|
||||
TEXT = "text"
|
||||
PIPELINE = "pipeline"
|
||||
HERMES_RECALL = "hermes_recall"
|
||||
LOCAL_MEMORY = "local_memory"
|
||||
MEMORY_WRITE = "memory_write"
|
||||
HERMES_ANSWER = "hermes_answer"
|
||||
HERMES_REMEMBER = "hermes_remember"
|
||||
OPENCLAW = "openclaw"
|
||||
@@ -49,6 +51,9 @@ class AIContextKey(StrEnum):
|
||||
OPENCLAW_SESSION_KEY = "openclaw_session_key"
|
||||
AGENT_PIPELINE = "agent_pipeline"
|
||||
HERMES_MEMORY = "hermes_memory"
|
||||
LOCAL_MEMORY = "local_memory"
|
||||
MEMORY_SCOPE = "memory_scope"
|
||||
MEMORY_SUBJECT = "memory_subject"
|
||||
OPENCLAW = "openclaw"
|
||||
MODE = "mode"
|
||||
USER_PROMPT = "user_prompt"
|
||||
|
||||
@@ -14,10 +14,13 @@ from app.modules.ai_agent.constants import (
|
||||
AI_AUDIT_SENSITIVE_KEYS,
|
||||
AI_AUDIT_TRUNCATED_VALUE,
|
||||
AIToolAuditKey,
|
||||
AIContextKey,
|
||||
AIProviderName,
|
||||
AIRequestKey,
|
||||
AIResponseKey,
|
||||
)
|
||||
from app.modules.ai_memory.constants import AIMemoryPayloadKey, AIMemoryScope
|
||||
from app.modules.ai_memory.service import AIMemoryService
|
||||
from app.modules.ai_agent.skills import AISkillId, get_ai_skill
|
||||
from app.modules.audit.constants import (
|
||||
AuditAction,
|
||||
@@ -44,11 +47,37 @@ class AIService:
|
||||
source: str = AuditSource.API,
|
||||
) -> dict[str, Any]:
|
||||
adapter = get_adapter()
|
||||
result = adapter.ask(prompt, context or {})
|
||||
original_context = context or {}
|
||||
adapter_context = dict(original_context)
|
||||
memory_service = AIMemoryService(self.db)
|
||||
local_memory = memory_service.recall(
|
||||
query=prompt,
|
||||
scope=_memory_scope(original_context),
|
||||
subject=_memory_subject(original_context),
|
||||
actor=actor,
|
||||
)
|
||||
if local_memory:
|
||||
adapter_context[AIContextKey.LOCAL_MEMORY] = local_memory
|
||||
result = adapter.ask(prompt, adapter_context)
|
||||
answer = result[AIResponseKey.ANSWER]
|
||||
raw = dict(result.get(AIResponseKey.RAW, {}))
|
||||
if local_memory:
|
||||
raw[AIResponseKey.LOCAL_MEMORY] = local_memory
|
||||
memory_record = memory_service.auto_write(
|
||||
prompt=prompt,
|
||||
context=original_context,
|
||||
answer=answer,
|
||||
actor=actor,
|
||||
)
|
||||
if memory_record is not None:
|
||||
raw[AIResponseKey.MEMORY_WRITE] = {
|
||||
AIMemoryPayloadKey.CODE: memory_record.code,
|
||||
AIMemoryPayloadKey.STATUS: memory_record.status,
|
||||
}
|
||||
response = {
|
||||
AIResponseKey.PROVIDER: adapter.provider_name,
|
||||
AIResponseKey.ANSWER: result[AIResponseKey.ANSWER],
|
||||
AIResponseKey.RAW: result.get(AIResponseKey.RAW, {}),
|
||||
AIResponseKey.ANSWER: answer,
|
||||
AIResponseKey.RAW: raw,
|
||||
}
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
@@ -197,3 +226,16 @@ def _audit_safe_payload(value: Any, depth: int = 0) -> Any:
|
||||
if isinstance(value, str) and len(value) > AI_AUDIT_MAX_TEXT_LENGTH:
|
||||
return value[:AI_AUDIT_MAX_TEXT_LENGTH] + AI_AUDIT_TRUNCATED_VALUE
|
||||
return value
|
||||
|
||||
|
||||
def _memory_scope(context: dict[str, Any]) -> str:
|
||||
return str(
|
||||
context.get(AIContextKey.MEMORY_SCOPE)
|
||||
or context.get(AIMemoryPayloadKey.SCOPE)
|
||||
or AIMemoryScope.GLOBAL
|
||||
)
|
||||
|
||||
|
||||
def _memory_subject(context: dict[str, Any]) -> str | None:
|
||||
value = context.get(AIContextKey.MEMORY_SUBJECT) or context.get(AIMemoryPayloadKey.SUBJECT)
|
||||
return str(value) if value else None
|
||||
|
||||
1
app/modules/ai_memory/__init__.py
Normal file
1
app/modules/ai_memory/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
55
app/modules/ai_memory/constants.py
Normal file
55
app/modules/ai_memory/constants.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class AIMemoryScope(StrEnum):
|
||||
GLOBAL = "global"
|
||||
PROJECT = "project"
|
||||
DEPARTMENT = "department"
|
||||
USER = "user"
|
||||
|
||||
|
||||
class AIMemoryStatus(StrEnum):
|
||||
ACTIVE = "active"
|
||||
ARCHIVED = "archived"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class AIMemorySource(StrEnum):
|
||||
AUTO = "auto"
|
||||
HERMES = "hermes"
|
||||
API = "api"
|
||||
|
||||
|
||||
class AIMemoryResponseKey(StrEnum):
|
||||
ITEMS = "items"
|
||||
DATA = "data"
|
||||
TOTAL = "total"
|
||||
|
||||
|
||||
class AIMemoryPayloadKey(StrEnum):
|
||||
CODE = "code"
|
||||
SCOPE = "scope"
|
||||
SUBJECT = "subject"
|
||||
CONTENT = "content"
|
||||
SUMMARY = "summary"
|
||||
TAGS = "tags"
|
||||
SOURCE = "source"
|
||||
IMPORTANCE = "importance"
|
||||
STATUS = "status"
|
||||
QUERY = "query"
|
||||
LIMIT = "limit"
|
||||
COUNT = "count"
|
||||
REJECTED_REASON = "rejected_reason"
|
||||
|
||||
|
||||
class AIMemoryText(StrEnum):
|
||||
DEFAULT_SCOPE = "global"
|
||||
DEFAULT_SUBJECT = "company"
|
||||
AUTO_TAG = "auto"
|
||||
REJECTED_SECRET = "secret-like content rejected"
|
||||
|
||||
|
||||
AI_MEMORY_CODE_PREFIX = "MEM"
|
||||
AI_MEMORY_MAX_CONTENT_LENGTH = 2000
|
||||
AI_MEMORY_MAX_SUMMARY_LENGTH = 500
|
||||
AI_MEMORY_MIN_AUTO_WRITE_LENGTH = 12
|
||||
33
app/modules/ai_memory/models.py
Normal file
33
app/modules/ai_memory/models.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.db_base import Base
|
||||
from app.core.time import utc_now
|
||||
from app.modules.ai_memory.constants import AIMemoryScope, AIMemorySource, AIMemoryStatus
|
||||
|
||||
|
||||
class AIMemoryEntry(Base):
|
||||
__tablename__ = "ai_memory_entries"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
scope: Mapped[str] = mapped_column(String(64), default=AIMemoryScope.GLOBAL, index=True)
|
||||
subject: Mapped[str] = mapped_column(String(128), index=True)
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tags: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
source: Mapped[str] = mapped_column(String(64), default=AIMemorySource.AUTO, index=True)
|
||||
importance: Mapped[int] = mapped_column(Integer, default=1, index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default=AIMemoryStatus.ACTIVE, index=True)
|
||||
actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
)
|
||||
44
app/modules/ai_memory/routes.py
Normal file
44
app/modules/ai_memory/routes.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.ai_memory.constants import AIMemoryResponseKey, AIMemoryStatus
|
||||
from app.modules.ai_memory.schemas import AIMemoryRecallRequest
|
||||
from app.modules.ai_memory.service import AIMemoryService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
|
||||
|
||||
@router.get("/memory")
|
||||
def list_memory(
|
||||
scope: str | None = None,
|
||||
subject: str | None = None,
|
||||
status: str = AIMemoryStatus.ACTIVE,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return {
|
||||
AIMemoryResponseKey.ITEMS: AIMemoryService(db).list_entries(
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
status_filter=status,
|
||||
limit=limit,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/memory/recall")
|
||||
def recall_memory(
|
||||
payload: AIMemoryRecallRequest,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
items = AIMemoryService(db).recall(
|
||||
query=payload.query,
|
||||
scope=payload.scope,
|
||||
subject=payload.subject,
|
||||
limit=payload.limit,
|
||||
actor=principal.actor,
|
||||
)
|
||||
return {AIMemoryResponseKey.ITEMS: items}
|
||||
29
app/modules/ai_memory/schemas.py
Normal file
29
app/modules/ai_memory/schemas.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.modules.ai_memory.constants import AIMemoryScope
|
||||
|
||||
|
||||
class AIMemoryRecallRequest(BaseModel):
|
||||
query: str = Field(..., min_length=1)
|
||||
scope: str = AIMemoryScope.GLOBAL
|
||||
subject: str | None = None
|
||||
limit: int = Field(default=5, ge=1, le=50)
|
||||
|
||||
|
||||
class AIMemoryRead(BaseModel):
|
||||
code: str
|
||||
scope: str
|
||||
subject: str
|
||||
content: str
|
||||
summary: str | None
|
||||
tags: list[Any] | None
|
||||
source: str
|
||||
importance: int
|
||||
status: str
|
||||
actor: str
|
||||
last_used_at: str | None
|
||||
expires_at: str | None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
277
app/modules/ai_memory/service.py
Normal file
277
app/modules/ai_memory/service.py
Normal file
@@ -0,0 +1,277 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.pagination import bounded_limit
|
||||
from app.core.time import utc_now
|
||||
from app.modules.ai_memory.constants import (
|
||||
AI_MEMORY_CODE_PREFIX,
|
||||
AI_MEMORY_MAX_CONTENT_LENGTH,
|
||||
AI_MEMORY_MAX_SUMMARY_LENGTH,
|
||||
AI_MEMORY_MIN_AUTO_WRITE_LENGTH,
|
||||
AIMemoryPayloadKey,
|
||||
AIMemoryScope,
|
||||
AIMemorySource,
|
||||
AIMemoryStatus,
|
||||
AIMemoryText,
|
||||
)
|
||||
from app.modules.ai_memory.models import AIMemoryEntry
|
||||
from app.modules.audit.constants import (
|
||||
AuditAction,
|
||||
AuditRiskLevel,
|
||||
AuditSource,
|
||||
AuditTargetType,
|
||||
)
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.business.service import serialize_model
|
||||
from app.modules.events.constants import EventAggregateType, EventSource, EventType
|
||||
from app.modules.events.service import EventService
|
||||
|
||||
|
||||
class AIMemoryService:
|
||||
"""Store and recall audited local AI memory for read-only operations."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.audit = AuditService(db)
|
||||
|
||||
def list_entries(
|
||||
self,
|
||||
scope: str | None = None,
|
||||
subject: str | None = None,
|
||||
status_filter: str = AIMemoryStatus.ACTIVE,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
stmt = (
|
||||
select(AIMemoryEntry)
|
||||
.where(AIMemoryEntry.status == status_filter)
|
||||
.order_by(AIMemoryEntry.importance.desc(), AIMemoryEntry.id.desc())
|
||||
.limit(bounded_limit(limit))
|
||||
)
|
||||
if scope:
|
||||
stmt = stmt.where(AIMemoryEntry.scope == scope)
|
||||
if subject:
|
||||
stmt = stmt.where(AIMemoryEntry.subject == subject)
|
||||
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
||||
|
||||
def recall(
|
||||
self,
|
||||
query: str,
|
||||
scope: str = AIMemoryScope.GLOBAL,
|
||||
subject: str | None = None,
|
||||
limit: int | None = None,
|
||||
actor: str = ActorValue.API,
|
||||
) -> list[dict[str, Any]]:
|
||||
settings = get_settings()
|
||||
if not settings.ai_memory_enabled:
|
||||
return []
|
||||
limit_value = bounded_limit(limit or settings.ai_memory_recall_limit)
|
||||
now = utc_now()
|
||||
stmt = (
|
||||
select(AIMemoryEntry)
|
||||
.where(
|
||||
AIMemoryEntry.status == AIMemoryStatus.ACTIVE,
|
||||
or_(AIMemoryEntry.expires_at.is_(None), AIMemoryEntry.expires_at > now),
|
||||
AIMemoryEntry.scope.in_({AIMemoryScope.GLOBAL, scope}),
|
||||
)
|
||||
.order_by(AIMemoryEntry.importance.desc(), AIMemoryEntry.id.desc())
|
||||
.limit(limit_value * 3)
|
||||
)
|
||||
if subject:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
AIMemoryEntry.subject == subject,
|
||||
AIMemoryEntry.scope == AIMemoryScope.GLOBAL,
|
||||
)
|
||||
)
|
||||
candidates = list(self.db.execute(stmt).scalars())
|
||||
items = [item for item in candidates if _matches_query(item, query)]
|
||||
if not items:
|
||||
items = candidates[:limit_value]
|
||||
items = items[:limit_value]
|
||||
for item in items:
|
||||
item.last_used_at = now
|
||||
self.db.commit()
|
||||
result = [serialize_model(item) for item in items]
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.AI_MEMORY,
|
||||
action=AuditAction.AI_MEMORY_RECALL,
|
||||
target_type=AuditTargetType.AI_MEMORY,
|
||||
risk_level=AuditRiskLevel.LOW,
|
||||
request_payload={
|
||||
AIMemoryPayloadKey.QUERY: query,
|
||||
AIMemoryPayloadKey.SCOPE: scope,
|
||||
AIMemoryPayloadKey.SUBJECT: subject,
|
||||
AIMemoryPayloadKey.LIMIT: limit_value,
|
||||
},
|
||||
response_payload={AIMemoryPayloadKey.COUNT: len(result)},
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def auto_write(
|
||||
self,
|
||||
prompt: str,
|
||||
context: dict[str, Any],
|
||||
answer: str,
|
||||
actor: str = ActorValue.API,
|
||||
) -> AIMemoryEntry | None:
|
||||
settings = get_settings()
|
||||
if not settings.ai_memory_enabled or not settings.ai_memory_auto_write_enabled:
|
||||
return None
|
||||
content = _build_memory_content(prompt, context, answer)
|
||||
if len(content) < AI_MEMORY_MIN_AUTO_WRITE_LENGTH:
|
||||
return None
|
||||
scope = str(context.get(AIMemoryPayloadKey.SCOPE) or AIMemoryText.DEFAULT_SCOPE)
|
||||
subject = str(context.get(AIMemoryPayloadKey.SUBJECT) or AIMemoryText.DEFAULT_SUBJECT)
|
||||
if _contains_forbidden_value(
|
||||
{
|
||||
"prompt": prompt,
|
||||
"context": context,
|
||||
"answer": answer,
|
||||
},
|
||||
settings.ai_memory_forbidden_keys,
|
||||
):
|
||||
record = self._create_entry(
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
content=str(AIMemoryText.REJECTED_SECRET),
|
||||
summary=str(AIMemoryText.REJECTED_SECRET),
|
||||
tags=[str(AIMemoryText.AUTO_TAG)],
|
||||
source=AIMemorySource.AUTO,
|
||||
importance=0,
|
||||
status_value=AIMemoryStatus.REJECTED,
|
||||
actor=actor,
|
||||
)
|
||||
return record
|
||||
summary = _truncate(answer, AI_MEMORY_MAX_SUMMARY_LENGTH)
|
||||
record = self._create_entry(
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
content=_truncate(content, AI_MEMORY_MAX_CONTENT_LENGTH),
|
||||
summary=summary,
|
||||
tags=[str(AIMemoryText.AUTO_TAG)],
|
||||
source=AIMemorySource.AUTO,
|
||||
importance=1,
|
||||
status_value=AIMemoryStatus.ACTIVE,
|
||||
actor=actor,
|
||||
)
|
||||
return record
|
||||
|
||||
def count_by_status(self) -> dict[str, int]:
|
||||
rows = self.db.execute(
|
||||
select(AIMemoryEntry.status, func.count()).group_by(AIMemoryEntry.status)
|
||||
).all()
|
||||
return {str(status_value): int(count) for status_value, count in rows}
|
||||
|
||||
def _create_entry(
|
||||
self,
|
||||
scope: str,
|
||||
subject: str,
|
||||
content: str,
|
||||
summary: str | None,
|
||||
tags: list[str],
|
||||
source: str,
|
||||
importance: int,
|
||||
status_value: str,
|
||||
actor: str,
|
||||
) -> AIMemoryEntry:
|
||||
record = AIMemoryEntry(
|
||||
code=f"{AI_MEMORY_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}",
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
content=content,
|
||||
summary=summary,
|
||||
tags=tags,
|
||||
source=source,
|
||||
importance=importance,
|
||||
status=status_value,
|
||||
actor=actor,
|
||||
)
|
||||
self.db.add(record)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.AI_MEMORY,
|
||||
action=AuditAction.AI_MEMORY_WRITE,
|
||||
target_type=AuditTargetType.AI_MEMORY,
|
||||
target_id=record.code,
|
||||
risk_level=AuditRiskLevel.LOW,
|
||||
request_payload={
|
||||
AIMemoryPayloadKey.SCOPE: scope,
|
||||
AIMemoryPayloadKey.SUBJECT: subject,
|
||||
AIMemoryPayloadKey.SOURCE: source,
|
||||
AIMemoryPayloadKey.STATUS: status_value,
|
||||
},
|
||||
response_payload={AIMemoryPayloadKey.CODE: record.code},
|
||||
)
|
||||
)
|
||||
EventService(self.db).emit(
|
||||
event_type=EventType.AI_MEMORY_WRITTEN,
|
||||
source=EventSource.AI_MEMORY,
|
||||
aggregate_type=EventAggregateType.AI_MEMORY_ENTRY,
|
||||
aggregate_id=record.code,
|
||||
actor=actor,
|
||||
payload={
|
||||
AIMemoryPayloadKey.CODE: record.code,
|
||||
AIMemoryPayloadKey.SCOPE: scope,
|
||||
AIMemoryPayloadKey.SUBJECT: subject,
|
||||
AIMemoryPayloadKey.STATUS: status_value,
|
||||
},
|
||||
idempotency_key=f"ai-memory:{record.code}",
|
||||
dispatch=True,
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def _build_memory_content(prompt: str, context: dict[str, Any], answer: str) -> str:
|
||||
context_text = ", ".join(
|
||||
f"{key}={value}" for key, value in sorted(context.items(), key=lambda item: str(item[0]))
|
||||
)
|
||||
return f"prompt: {prompt}\ncontext: {context_text}\nanswer: {answer}"
|
||||
|
||||
|
||||
def _contains_forbidden_value(value: Any, forbidden_keys: list[str]) -> bool:
|
||||
forbidden = {item.lower() for item in forbidden_keys}
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
if str(key).lower() in forbidden:
|
||||
return True
|
||||
if _contains_forbidden_value(item, forbidden_keys):
|
||||
return True
|
||||
return False
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return any(_contains_forbidden_value(item, forbidden_keys) for item in value)
|
||||
if isinstance(value, str):
|
||||
lowered = value.lower()
|
||||
return any(item in lowered for item in forbidden)
|
||||
return False
|
||||
|
||||
|
||||
def _matches_query(entry: AIMemoryEntry, query: str) -> bool:
|
||||
query_text = query.lower().strip()
|
||||
if not query_text:
|
||||
return True
|
||||
text = " ".join(
|
||||
[
|
||||
entry.subject or "",
|
||||
entry.content or "",
|
||||
entry.summary or "",
|
||||
" ".join(str(item) for item in (entry.tags or [])),
|
||||
]
|
||||
).lower()
|
||||
return any(token in text for token in query_text.split())
|
||||
|
||||
|
||||
def _truncate(value: str, max_length: int) -> str:
|
||||
if len(value) <= max_length:
|
||||
return value
|
||||
return value[:max_length]
|
||||
@@ -14,6 +14,11 @@ class AuditAction(StrEnum):
|
||||
LEGACY_SYNC_TASKS = "sync_tasks"
|
||||
RISK_EVENT_ACTION = "risk_event_action"
|
||||
REPORT_PUSH = "report_push"
|
||||
AI_MEMORY_RECALL = "ai.memory_recall"
|
||||
AI_MEMORY_WRITE = "ai.memory_write"
|
||||
ENTERPRISE_ANALYTICS = "enterprise_analytics"
|
||||
EVENT_DISPATCH = "event.dispatch"
|
||||
HEARTBEAT = "heartbeat"
|
||||
|
||||
|
||||
class AuditRiskLevel(StrEnum):
|
||||
@@ -29,6 +34,9 @@ class AuditSource(StrEnum):
|
||||
FEISHU = "feishu"
|
||||
LEGACY_MYSQL = "legacy_mysql"
|
||||
REPORTS = "reports"
|
||||
EVENTS = "events"
|
||||
AI_MEMORY = "ai_memory"
|
||||
OBSERVABILITY = "observability"
|
||||
|
||||
|
||||
class AuditTargetType(StrEnum):
|
||||
@@ -36,6 +44,10 @@ class AuditTargetType(StrEnum):
|
||||
OPENCLAW_TOOL = "openclaw_tool"
|
||||
RISK_EVENTS = "risk-events"
|
||||
WORK_REPORTS = "work-reports"
|
||||
ENTERPRISE_ANALYTICS = "enterprise-analytics"
|
||||
AI_MEMORY = "ai-memory"
|
||||
DOMAIN_EVENT = "domain-event"
|
||||
HEARTBEAT = "heartbeat"
|
||||
|
||||
|
||||
class AuditStatus(StrEnum):
|
||||
|
||||
@@ -3,6 +3,8 @@ from typing import Any
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.ai_memory.constants import AIMemoryStatus
|
||||
from app.modules.ai_memory.models import AIMemoryEntry
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.business.constants import DONE_STATUSES, PROJECT_CLOSED_STATUSES, StatusValue
|
||||
from app.modules.business.models import (
|
||||
@@ -16,6 +18,8 @@ from app.modules.business.models import (
|
||||
from app.modules.business.service import serialize_model
|
||||
from app.modules.events.constants import EventStatus
|
||||
from app.modules.events.models import DomainEvent
|
||||
from app.modules.observability.constants import ObservabilityMetricKey
|
||||
from app.modules.observability.service import ObservabilityService
|
||||
from app.modules.reports.constants import ReportPushStatus
|
||||
from app.modules.risk.service import RiskService
|
||||
from app.modules.workflows.constants import WorkflowStatus
|
||||
@@ -23,7 +27,7 @@ from app.modules.workflows.models import WorkflowInstance
|
||||
|
||||
|
||||
class DashboardService:
|
||||
"""Build lightweight operational dashboard data for V2."""
|
||||
"""Build lightweight operational dashboard data for V2/V3."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
@@ -49,6 +53,8 @@ class DashboardService:
|
||||
WorkflowInstance,
|
||||
WorkflowInstance.status == WorkflowStatus.FAILED,
|
||||
)
|
||||
active_ai_memory = self._count(AIMemoryEntry, AIMemoryEntry.status == AIMemoryStatus.ACTIVE)
|
||||
heartbeat_summary = ObservabilityService(self.db).heartbeat_summary()
|
||||
latest_reports = self.db.execute(
|
||||
select(WorkReport).order_by(WorkReport.id.desc()).limit(5)
|
||||
).scalars()
|
||||
@@ -73,6 +79,8 @@ class DashboardService:
|
||||
"failed_events": failed_events,
|
||||
"running_workflows": running_workflows,
|
||||
"failed_workflows": failed_workflows,
|
||||
"active_ai_memory": active_ai_memory,
|
||||
"stale_heartbeats": heartbeat_summary[ObservabilityMetricKey.STALE],
|
||||
"risk_level": risk_summary["risk_level"],
|
||||
"risk_score": float(risk_summary["risk_score"]),
|
||||
},
|
||||
|
||||
@@ -11,20 +11,29 @@ class EventType(StrEnum):
|
||||
RISK_ACTION_RECORDED = "risk.action_recorded"
|
||||
REPORT_PUSH_SUCCEEDED = "report.push_succeeded"
|
||||
REPORT_PUSH_FAILED = "report.push_failed"
|
||||
REPORT_GENERATED = "report.generated"
|
||||
LEGACY_SYNC_COMPLETED = "legacy.sync_completed"
|
||||
LEGACY_SYNC_FAILED = "legacy.sync_failed"
|
||||
AI_MEMORY_WRITTEN = "ai.memory_written"
|
||||
ENTERPRISE_ANALYTICS_GENERATED = "enterprise.analytics_generated"
|
||||
|
||||
|
||||
class EventSource(StrEnum):
|
||||
RISK = "risk"
|
||||
REPORTS = "reports"
|
||||
LEGACY_MYSQL = "legacy_mysql"
|
||||
AI_MEMORY = "ai_memory"
|
||||
ANALYTICS = "analytics"
|
||||
API = "api"
|
||||
|
||||
|
||||
class EventAggregateType(StrEnum):
|
||||
RISK_EVENT = "risk-event"
|
||||
REPORT_PUSH_RUN = "report-push-run"
|
||||
WORK_REPORT = "work-report"
|
||||
LEGACY_SYNC_RUN = "legacy-sync-run"
|
||||
AI_MEMORY_ENTRY = "ai-memory-entry"
|
||||
ENTERPRISE_ANALYTICS = "enterprise-analytics"
|
||||
|
||||
|
||||
class EventResponseKey(StrEnum):
|
||||
@@ -44,10 +53,13 @@ class EventPayloadKey(StrEnum):
|
||||
UPDATED = "updated"
|
||||
SKIPPED = "skipped"
|
||||
ERROR_MESSAGE = "error_message"
|
||||
ATTEMPTS = "attempts"
|
||||
HANDLED = "handled"
|
||||
|
||||
|
||||
class EventErrorDetail(StrEnum):
|
||||
EVENT_NOT_FOUND = "Domain event not found"
|
||||
EVENT_NOT_RETRYABLE = "Domain event is not retryable"
|
||||
|
||||
|
||||
EVENT_CODE_PREFIX = "EVT"
|
||||
|
||||
@@ -29,5 +29,13 @@ class DomainEvent(Base):
|
||||
index=True,
|
||||
)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
next_attempt_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
locked_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
locked_by: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
max_attempts: Mapped[int] = mapped_column(Integer, default=3)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
processed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
|
||||
@@ -2,8 +2,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.operation_guard import require_operations_enabled
|
||||
from app.core.security import require_api_key
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.events.constants import EventResponseKey
|
||||
from app.modules.events.service import EventService, _serialize_event
|
||||
|
||||
@@ -31,14 +30,25 @@ def dispatch_event(
|
||||
event_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return {EventResponseKey.EVENT: _serialize_event(EventService(db).dispatch_event(event_id))}
|
||||
|
||||
|
||||
@router.post("/{event_id}/retry")
|
||||
def retry_event(
|
||||
event_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
return {
|
||||
EventResponseKey.EVENT: _serialize_event(
|
||||
EventService(db).retry_event(event_id, actor=principal.actor)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/dispatch-pending")
|
||||
def dispatch_pending(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return {EventResponseKey.ITEMS: EventService(db).dispatch_pending(limit=limit)}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.pagination import bounded_limit
|
||||
from app.core.time import utc_now
|
||||
from app.modules.audit.constants import (
|
||||
AuditAction,
|
||||
AuditRiskLevel,
|
||||
AuditSource,
|
||||
AuditTargetType,
|
||||
)
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.events.constants import (
|
||||
EVENT_CODE_PREFIX,
|
||||
EventAggregateType,
|
||||
@@ -51,8 +62,10 @@ class EventService:
|
||||
return self.dispatch_event(existing.event_id)
|
||||
return existing
|
||||
|
||||
settings = get_settings()
|
||||
now = utc_now()
|
||||
record = DomainEvent(
|
||||
event_id=f"{EVENT_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}",
|
||||
event_id=f"{EVENT_CODE_PREFIX}-{now:%Y%m%d%H%M%S%f}",
|
||||
event_type=event_type,
|
||||
source=source,
|
||||
aggregate_type=aggregate_type,
|
||||
@@ -60,6 +73,8 @@ class EventService:
|
||||
actor=actor,
|
||||
payload=payload or {},
|
||||
idempotency_key=idempotency_key,
|
||||
next_attempt_at=now,
|
||||
max_attempts=settings.event_dispatch_max_attempts,
|
||||
)
|
||||
self.db.add(record)
|
||||
self.db.commit()
|
||||
@@ -98,39 +113,120 @@ class EventService:
|
||||
)
|
||||
return record
|
||||
|
||||
def dispatch_event(self, event_id: str) -> DomainEvent:
|
||||
def dispatch_event(self, event_id: str, worker_id: str | None = None) -> DomainEvent:
|
||||
record = self.get_event(event_id)
|
||||
if record.status == EventStatus.PROCESSED:
|
||||
return record
|
||||
if not self._can_attempt(record):
|
||||
return record
|
||||
settings = get_settings()
|
||||
now = utc_now()
|
||||
lock_owner = worker_id or f"api:{uuid4().hex}"
|
||||
record.locked_by = lock_owner
|
||||
record.locked_until = now + timedelta(seconds=settings.event_dispatch_lock_seconds)
|
||||
record.status = EventStatus.PENDING
|
||||
record.attempts += 1
|
||||
try:
|
||||
self._handle_event(record)
|
||||
except Exception as exc:
|
||||
record.status = EventStatus.FAILED
|
||||
retryable = record.attempts < self._max_attempts(record)
|
||||
record.status = EventStatus.PENDING if retryable else EventStatus.FAILED
|
||||
record.last_error = str(exc)
|
||||
record.next_attempt_at = (
|
||||
utc_now() + timedelta(seconds=settings.event_dispatch_retry_delay_seconds)
|
||||
if retryable
|
||||
else None
|
||||
)
|
||||
record.locked_by = None
|
||||
record.locked_until = None
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
self._audit_dispatch(record)
|
||||
return record
|
||||
record.status = EventStatus.PROCESSED
|
||||
record.last_error = None
|
||||
record.processed_at = utc_now()
|
||||
record.next_attempt_at = None
|
||||
record.locked_by = None
|
||||
record.locked_until = None
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
self._audit_dispatch(record)
|
||||
return record
|
||||
|
||||
def dispatch_pending(self, limit: int = 100) -> list[dict[str, Any]]:
|
||||
def dispatch_pending(
|
||||
self,
|
||||
limit: int = 100,
|
||||
worker_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
now = utc_now()
|
||||
stmt = (
|
||||
select(DomainEvent)
|
||||
.where(DomainEvent.status == EventStatus.PENDING)
|
||||
.where(
|
||||
DomainEvent.status == EventStatus.PENDING,
|
||||
or_(
|
||||
DomainEvent.next_attempt_at.is_(None),
|
||||
DomainEvent.next_attempt_at <= now,
|
||||
),
|
||||
or_(
|
||||
DomainEvent.locked_until.is_(None),
|
||||
DomainEvent.locked_until <= now,
|
||||
),
|
||||
or_(
|
||||
DomainEvent.max_attempts.is_(None),
|
||||
DomainEvent.attempts < DomainEvent.max_attempts,
|
||||
),
|
||||
)
|
||||
.order_by(DomainEvent.id.asc())
|
||||
.limit(bounded_limit(limit))
|
||||
)
|
||||
records = list(self.db.execute(stmt).scalars())
|
||||
return [_serialize_event(self.dispatch_event(record.event_id)) for record in records]
|
||||
lock_owner = worker_id or f"worker:{uuid4().hex}"
|
||||
return [
|
||||
_serialize_event(self.dispatch_event(record.event_id, worker_id=lock_owner))
|
||||
for record in records
|
||||
]
|
||||
|
||||
def retry_event(self, event_id: str, actor: str = ActorValue.API) -> DomainEvent:
|
||||
record = self.get_event(event_id)
|
||||
if record.status == EventStatus.PROCESSED or not self._can_attempt(record):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=EventErrorDetail.EVENT_NOT_RETRYABLE,
|
||||
)
|
||||
record.status = EventStatus.PENDING
|
||||
record.actor = actor
|
||||
record.last_error = None
|
||||
record.locked_by = None
|
||||
record.locked_until = None
|
||||
record.next_attempt_at = utc_now()
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
return record
|
||||
|
||||
def _handle_event(self, record: DomainEvent) -> None:
|
||||
if record.event_type == EventType.RISK_ACTION_RECORDED:
|
||||
self._handle_risk_action(record)
|
||||
return
|
||||
if record.event_type in {
|
||||
EventType.REPORT_PUSH_SUCCEEDED,
|
||||
EventType.REPORT_PUSH_FAILED,
|
||||
EventType.REPORT_GENERATED,
|
||||
}:
|
||||
self._handle_report_event(record)
|
||||
return
|
||||
if record.event_type in {
|
||||
EventType.LEGACY_SYNC_COMPLETED,
|
||||
EventType.LEGACY_SYNC_FAILED,
|
||||
}:
|
||||
self._handle_legacy_sync_event(record)
|
||||
return
|
||||
if record.event_type == EventType.AI_MEMORY_WRITTEN:
|
||||
self._handle_ai_memory_event(record)
|
||||
return
|
||||
if record.event_type == EventType.ENTERPRISE_ANALYTICS_GENERATED:
|
||||
self._handle_enterprise_analytics_event(record)
|
||||
return
|
||||
|
||||
def _handle_risk_action(self, record: DomainEvent) -> None:
|
||||
from app.modules.risk.constants import RiskEventActionValue
|
||||
@@ -154,3 +250,98 @@ class EventService:
|
||||
actor=record.actor,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
def _handle_report_event(self, record: DomainEvent) -> None:
|
||||
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
|
||||
|
||||
workflow_status = (
|
||||
WorkflowStatus.FAILED
|
||||
if record.event_type == EventType.REPORT_PUSH_FAILED
|
||||
else WorkflowStatus.COMPLETED
|
||||
)
|
||||
self._track_operational_workflow(
|
||||
record,
|
||||
workflow_type=WorkflowType.REPORT_DELIVERY,
|
||||
workflow_status=workflow_status,
|
||||
)
|
||||
|
||||
def _handle_legacy_sync_event(self, record: DomainEvent) -> None:
|
||||
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
|
||||
|
||||
workflow_status = (
|
||||
WorkflowStatus.FAILED
|
||||
if record.event_type == EventType.LEGACY_SYNC_FAILED
|
||||
else WorkflowStatus.COMPLETED
|
||||
)
|
||||
self._track_operational_workflow(
|
||||
record,
|
||||
workflow_type=WorkflowType.LEGACY_SYNC_MONITOR,
|
||||
workflow_status=workflow_status,
|
||||
)
|
||||
|
||||
def _handle_ai_memory_event(self, record: DomainEvent) -> None:
|
||||
from app.modules.ai_memory.constants import AIMemoryPayloadKey, AIMemoryStatus
|
||||
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
|
||||
|
||||
payload = record.payload or {}
|
||||
workflow_status = (
|
||||
WorkflowStatus.BLOCKED
|
||||
if payload.get(AIMemoryPayloadKey.STATUS) == AIMemoryStatus.REJECTED
|
||||
else WorkflowStatus.COMPLETED
|
||||
)
|
||||
self._track_operational_workflow(
|
||||
record,
|
||||
workflow_type=WorkflowType.AI_MEMORY_CAPTURE,
|
||||
workflow_status=workflow_status,
|
||||
)
|
||||
|
||||
def _handle_enterprise_analytics_event(self, record: DomainEvent) -> None:
|
||||
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
|
||||
|
||||
self._track_operational_workflow(
|
||||
record,
|
||||
workflow_type=WorkflowType.ENTERPRISE_ANALYTICS,
|
||||
workflow_status=WorkflowStatus.COMPLETED,
|
||||
)
|
||||
|
||||
def _track_operational_workflow(
|
||||
self,
|
||||
record: DomainEvent,
|
||||
workflow_type: str,
|
||||
workflow_status: str,
|
||||
) -> None:
|
||||
from app.modules.workflows.service import WorkflowService
|
||||
|
||||
WorkflowService(self.db).start_or_update(
|
||||
workflow_type=workflow_type,
|
||||
aggregate_type=record.aggregate_type,
|
||||
aggregate_id=record.aggregate_id,
|
||||
status_value=workflow_status,
|
||||
action=record.event_type,
|
||||
actor=record.actor,
|
||||
payload=record.payload or {},
|
||||
)
|
||||
|
||||
def _audit_dispatch(self, record: DomainEvent) -> None:
|
||||
AuditService(self.db).log(
|
||||
AuditLogCreate(
|
||||
actor=record.actor,
|
||||
source=AuditSource.EVENTS,
|
||||
action=AuditAction.EVENT_DISPATCH,
|
||||
target_type=AuditTargetType.DOMAIN_EVENT,
|
||||
target_id=record.event_id,
|
||||
risk_level=AuditRiskLevel.LOW,
|
||||
response_payload={
|
||||
EventPayloadKey.STATUS: record.status,
|
||||
EventPayloadKey.ATTEMPTS: record.attempts,
|
||||
EventPayloadKey.ERROR_MESSAGE: record.last_error,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def _can_attempt(self, record: DomainEvent) -> bool:
|
||||
return record.attempts < self._max_attempts(record)
|
||||
|
||||
@staticmethod
|
||||
def _max_attempts(record: DomainEvent) -> int:
|
||||
return record.max_attempts or get_settings().event_dispatch_max_attempts
|
||||
|
||||
@@ -9,6 +9,10 @@ class ObservabilityKey(StrEnum):
|
||||
REDIS = "redis"
|
||||
EVENTS = "events"
|
||||
WORKFLOWS = "workflows"
|
||||
AI_MEMORY = "ai_memory"
|
||||
HEARTBEATS = "heartbeats"
|
||||
SCHEDULER = "scheduler"
|
||||
WORKER = "worker"
|
||||
|
||||
|
||||
class ObservabilityStatus(StrEnum):
|
||||
@@ -20,6 +24,23 @@ class ObservabilityStatus(StrEnum):
|
||||
|
||||
class ObservabilityMetricKey(StrEnum):
|
||||
ERROR = "error"
|
||||
ITEMS = "items"
|
||||
COMPONENT = "component"
|
||||
INSTANCE_ID = "instance_id"
|
||||
PENDING = "pending"
|
||||
FAILED = "failed"
|
||||
RUNNING = "running"
|
||||
TOTAL = "total"
|
||||
ACTIVE = "active"
|
||||
STALE = "stale"
|
||||
LAST_SEEN_AT = "last_seen_at"
|
||||
|
||||
|
||||
class HeartbeatComponent(StrEnum):
|
||||
API = "api"
|
||||
SCHEDULER = "scheduler"
|
||||
WORKER = "worker"
|
||||
|
||||
|
||||
class HeartbeatStatus(StrEnum):
|
||||
OK = "ok"
|
||||
|
||||
23
app/modules/observability/models.py
Normal file
23
app/modules/observability/models.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db_base import Base
|
||||
from app.core.time import utc_now
|
||||
|
||||
|
||||
class SystemHeartbeat(Base):
|
||||
__tablename__ = "system_heartbeats"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
component: Mapped[str] = mapped_column(String(128), index=True)
|
||||
instance_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), index=True)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
)
|
||||
@@ -1,16 +1,30 @@
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.time import utc_now
|
||||
from app.modules.ai_memory.service import AIMemoryService
|
||||
from app.modules.audit.constants import (
|
||||
AuditAction,
|
||||
AuditRiskLevel,
|
||||
AuditSource,
|
||||
AuditTargetType,
|
||||
)
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.events.constants import EventStatus
|
||||
from app.modules.events.service import EventService
|
||||
from app.modules.observability.constants import (
|
||||
HeartbeatStatus,
|
||||
ObservabilityKey,
|
||||
ObservabilityMetricKey,
|
||||
ObservabilityStatus,
|
||||
)
|
||||
from app.modules.observability.models import SystemHeartbeat
|
||||
from app.modules.workflows.constants import WorkflowStatus
|
||||
from app.modules.workflows.service import WorkflowService
|
||||
|
||||
@@ -30,6 +44,7 @@ class ObservabilityService:
|
||||
ObservabilityKey.REDIS: self._redis_check(),
|
||||
ObservabilityKey.EVENTS: self._events_check(),
|
||||
ObservabilityKey.WORKFLOWS: self._workflows_check(),
|
||||
ObservabilityKey.HEARTBEATS: self._heartbeats_check(),
|
||||
}
|
||||
degraded = any(
|
||||
item[ObservabilityKey.STATUS]
|
||||
@@ -48,9 +63,75 @@ class ObservabilityService:
|
||||
ObservabilityKey.METRICS: {
|
||||
ObservabilityKey.EVENTS: EventService(self.db).count_by_status(),
|
||||
ObservabilityKey.WORKFLOWS: WorkflowService(self.db).count_by_status(),
|
||||
ObservabilityKey.AI_MEMORY: AIMemoryService(self.db).count_by_status(),
|
||||
ObservabilityKey.HEARTBEATS: self.heartbeat_summary(),
|
||||
}
|
||||
}
|
||||
|
||||
def record_heartbeat(
|
||||
self,
|
||||
component: str,
|
||||
instance_id: str,
|
||||
status_value: str = HeartbeatStatus.OK,
|
||||
actor: str = ActorValue.SYSTEM,
|
||||
) -> dict[str, Any]:
|
||||
now = utc_now()
|
||||
record = self.db.execute(
|
||||
select(SystemHeartbeat).where(
|
||||
SystemHeartbeat.component == component,
|
||||
SystemHeartbeat.instance_id == instance_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
record = SystemHeartbeat(
|
||||
component=component,
|
||||
instance_id=instance_id,
|
||||
status=status_value,
|
||||
last_seen_at=now,
|
||||
)
|
||||
self.db.add(record)
|
||||
else:
|
||||
record.status = status_value
|
||||
record.last_seen_at = now
|
||||
record.updated_at = now
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
AuditService(self.db).log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.OBSERVABILITY,
|
||||
action=AuditAction.HEARTBEAT,
|
||||
target_type=AuditTargetType.HEARTBEAT,
|
||||
target_id=f"{component}:{instance_id}",
|
||||
risk_level=AuditRiskLevel.LOW,
|
||||
response_payload={
|
||||
ObservabilityMetricKey.COMPONENT: component,
|
||||
ObservabilityMetricKey.INSTANCE_ID: instance_id,
|
||||
ObservabilityKey.STATUS: status_value,
|
||||
ObservabilityMetricKey.LAST_SEEN_AT: record.last_seen_at.isoformat(),
|
||||
},
|
||||
)
|
||||
)
|
||||
return self._serialize_heartbeat(record)
|
||||
|
||||
def heartbeat_summary(self) -> dict[str, Any]:
|
||||
records = list(self.db.execute(select(SystemHeartbeat)).scalars())
|
||||
threshold = self._heartbeat_stale_threshold()
|
||||
stale = [item for item in records if item.last_seen_at < threshold]
|
||||
active = len(records) - len(stale)
|
||||
last_seen_at = max((item.last_seen_at for item in records), default=None)
|
||||
return {
|
||||
ObservabilityMetricKey.TOTAL: len(records),
|
||||
ObservabilityMetricKey.ACTIVE: active,
|
||||
ObservabilityMetricKey.STALE: len(stale),
|
||||
ObservabilityMetricKey.LAST_SEEN_AT: (
|
||||
last_seen_at.isoformat() if last_seen_at else None
|
||||
),
|
||||
ObservabilityMetricKey.ITEMS: [
|
||||
self._serialize_heartbeat(item) for item in records
|
||||
],
|
||||
}
|
||||
|
||||
def _database_check(self) -> dict[str, Any]:
|
||||
try:
|
||||
self.db.execute(text("select 1")).scalar()
|
||||
@@ -97,3 +178,34 @@ class ObservabilityService:
|
||||
ObservabilityMetricKey.RUNNING: counts.get(WorkflowStatus.RUNNING, 0),
|
||||
ObservabilityMetricKey.FAILED: failed,
|
||||
}
|
||||
|
||||
def _heartbeats_check(self) -> dict[str, Any]:
|
||||
summary = self.heartbeat_summary()
|
||||
total = summary[ObservabilityMetricKey.TOTAL]
|
||||
stale = summary[ObservabilityMetricKey.STALE]
|
||||
if total == 0:
|
||||
return {ObservabilityKey.STATUS: ObservabilityStatus.SKIPPED}
|
||||
return {
|
||||
ObservabilityKey.STATUS: (
|
||||
ObservabilityStatus.DEGRADED if stale else ObservabilityStatus.OK
|
||||
),
|
||||
ObservabilityMetricKey.TOTAL: total,
|
||||
ObservabilityMetricKey.STALE: stale,
|
||||
ObservabilityMetricKey.LAST_SEEN_AT: summary[
|
||||
ObservabilityMetricKey.LAST_SEEN_AT
|
||||
],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_heartbeat(record: SystemHeartbeat) -> dict[str, Any]:
|
||||
return {
|
||||
ObservabilityMetricKey.COMPONENT: record.component,
|
||||
ObservabilityMetricKey.INSTANCE_ID: record.instance_id,
|
||||
ObservabilityKey.STATUS: record.status,
|
||||
ObservabilityMetricKey.LAST_SEEN_AT: record.last_seen_at.isoformat(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _heartbeat_stale_threshold() -> Any:
|
||||
settings = get_settings()
|
||||
return utc_now() - timedelta(seconds=settings.heartbeat_interval_seconds * 3)
|
||||
|
||||
@@ -13,6 +13,7 @@ class ReportTitle(StrEnum):
|
||||
WORK_DAILY = "经营日报"
|
||||
WORK_WEEKLY = "经营周报"
|
||||
PROJECT_LIFECYCLE = "项目全生命周期报告"
|
||||
ENTERPRISE_ANALYTICS = "企业只读运营分析"
|
||||
|
||||
|
||||
class ReportStatus(StrEnum):
|
||||
@@ -70,6 +71,19 @@ class LifecycleResponseKey(StrEnum):
|
||||
PROJECT_LIFECYCLE_REPORT = "project_lifecycle_report"
|
||||
|
||||
|
||||
class EnterpriseAnalyticsKey(StrEnum):
|
||||
CODE = "code"
|
||||
TITLE = "title"
|
||||
FILTERS = "filters"
|
||||
FINANCE = "finance"
|
||||
PROCUREMENT = "procurement"
|
||||
PERFORMANCE = "performance"
|
||||
OPERATIONS = "operations"
|
||||
RECOMMENDATIONS = "recommendations"
|
||||
LINES = "lines"
|
||||
CONTENT = "content"
|
||||
|
||||
|
||||
class ReportResponseKey(StrEnum):
|
||||
REPORT = "report"
|
||||
DATA = "data"
|
||||
@@ -142,6 +156,14 @@ class MetricKey(StrEnum):
|
||||
EVENTS_BY_LEVEL = "events_by_level"
|
||||
SCORE = "score"
|
||||
LEVEL = "level"
|
||||
READINESS_SCORE = "readiness_score"
|
||||
PAYMENT_EXPOSURE = "payment_exposure"
|
||||
DELIVERY_RISK = "delivery_risk"
|
||||
CONFIRMED = "confirmed"
|
||||
CONFIRMED_RATE = "confirmed_rate"
|
||||
AVERAGE_AUTO_SCORE = "average_auto_score"
|
||||
AVERAGE_CONFIRMED_SCORE = "average_confirmed_score"
|
||||
WEIGHT_TOTAL = "weight_total"
|
||||
|
||||
|
||||
class WorkReportMetricKey(StrEnum):
|
||||
|
||||
@@ -52,6 +52,24 @@ def project_lifecycle_report(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/enterprise-analytics")
|
||||
def enterprise_analytics(
|
||||
project_code: str | None = None,
|
||||
owner: str | None = None,
|
||||
period_start: date | None = None,
|
||||
period_end: date | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
return ReportService(db).enterprise_analytics(
|
||||
project_code=project_code,
|
||||
owner=owner,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
actor=principal.actor,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/attendance-summary")
|
||||
def attendance_summary(
|
||||
work_date: date | None = None,
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.modules.business.models import (
|
||||
AttendanceRecord,
|
||||
Expense,
|
||||
FundAccount,
|
||||
PerformanceMetric,
|
||||
Procurement,
|
||||
Project,
|
||||
ReportPushRun,
|
||||
@@ -55,13 +56,14 @@ from app.modules.reports.constants import (
|
||||
LifecycleResponseKey,
|
||||
LifecycleSection,
|
||||
MetricKey,
|
||||
ReportResponseKey,
|
||||
ReportPushStatus,
|
||||
ReportErrorDetail,
|
||||
ReportPushStatus,
|
||||
ReportResponseKey,
|
||||
ReportStatus,
|
||||
ReportText,
|
||||
ReportTitle,
|
||||
ReportType,
|
||||
EnterpriseAnalyticsKey,
|
||||
WorkReportMetricKey,
|
||||
)
|
||||
from app.modules.risk.constants import RiskSummaryKey, risk_level_for_score
|
||||
@@ -387,6 +389,169 @@ class ReportService:
|
||||
report[LifecycleResponseKey.AI_ANALYSIS] = self._lifecycle_ai_analysis(report, actor)
|
||||
return report
|
||||
|
||||
def enterprise_analytics(
|
||||
self,
|
||||
project_code: str | None = None,
|
||||
owner: str | None = None,
|
||||
period_start: date | None = None,
|
||||
period_end: date | None = None,
|
||||
actor: str = ActorValue.API,
|
||||
) -> dict[str, Any]:
|
||||
"""Build V3 read-only finance, procurement, performance, and operations analytics."""
|
||||
|
||||
code = _next_code("ANALYTICS")
|
||||
lifecycle = self.project_lifecycle_report(
|
||||
project_code=project_code,
|
||||
owner=owner,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
include_ai=False,
|
||||
actor=actor,
|
||||
)
|
||||
metrics = lifecycle[LifecycleResponseKey.METRICS]
|
||||
projects = metrics[LifecycleSection.PROJECTS]
|
||||
procurements = metrics[LifecycleSection.PROCUREMENTS]
|
||||
expenses = metrics[LifecycleSection.EXPENSES]
|
||||
funds = metrics[LifecycleSection.FUNDS]
|
||||
tasks = metrics[LifecycleSection.TASKS]
|
||||
risks = metrics[LifecycleSection.RISKS]
|
||||
health = metrics[LifecycleSection.HEALTH]
|
||||
finance = {
|
||||
MetricKey.BUDGET_TOTAL: projects[MetricKey.BUDGET_TOTAL],
|
||||
MetricKey.ACTUAL_TOTAL: projects[MetricKey.ACTUAL_TOTAL],
|
||||
MetricKey.BUDGET_USAGE_RATE: projects[MetricKey.BUDGET_USAGE_RATE],
|
||||
MetricKey.CURRENT_BALANCE_TOTAL: funds[MetricKey.CURRENT_BALANCE_TOTAL],
|
||||
MetricKey.NET_POSITION: funds[MetricKey.NET_POSITION],
|
||||
MetricKey.RISK_ACCOUNTS: funds[MetricKey.RISK_ACCOUNTS],
|
||||
MetricKey.PAYMENT_EXPOSURE: (
|
||||
procurements[MetricKey.ACTUAL_TOTAL] + expenses[MetricKey.AMOUNT_TOTAL]
|
||||
),
|
||||
}
|
||||
procurement = {
|
||||
MetricKey.TOTAL: procurements[MetricKey.TOTAL],
|
||||
MetricKey.PENDING_APPROVAL: procurements[MetricKey.PENDING_APPROVAL],
|
||||
MetricKey.PENDING_DELIVERY: procurements[MetricKey.PENDING_DELIVERY],
|
||||
MetricKey.UNPAID: procurements[MetricKey.UNPAID],
|
||||
MetricKey.EXPECTED_TOTAL: procurements[MetricKey.EXPECTED_TOTAL],
|
||||
MetricKey.ACTUAL_TOTAL: procurements[MetricKey.ACTUAL_TOTAL],
|
||||
MetricKey.DELIVERY_RISK: procurements[MetricKey.PENDING_DELIVERY],
|
||||
}
|
||||
performance = self._enterprise_performance_stats()
|
||||
operations = {
|
||||
MetricKey.READINESS_SCORE: health[MetricKey.SCORE],
|
||||
MetricKey.LEVEL: health[MetricKey.LEVEL],
|
||||
MetricKey.COMPLETION_RATE: tasks[MetricKey.COMPLETION_RATE],
|
||||
MetricKey.OVERDUE_TASKS: risks[MetricKey.OVERDUE_TASKS],
|
||||
MetricKey.DELAYED_PROJECTS: risks[MetricKey.DELAYED_PROJECTS],
|
||||
MetricKey.OVER_BUDGET_PROJECTS: risks[MetricKey.OVER_BUDGET_PROJECTS],
|
||||
MetricKey.OPEN_EVENTS: risks[MetricKey.OPEN_EVENTS],
|
||||
MetricKey.HIGH_EVENTS: risks[MetricKey.HIGH_EVENTS],
|
||||
}
|
||||
recommendations = lifecycle[LifecycleResponseKey.RECOMMENDATIONS]
|
||||
lines = self._enterprise_analytics_lines(
|
||||
lifecycle[LifecycleResponseKey.FILTERS],
|
||||
finance,
|
||||
procurement,
|
||||
performance,
|
||||
operations,
|
||||
recommendations,
|
||||
)
|
||||
report = _json_safe(
|
||||
{
|
||||
EnterpriseAnalyticsKey.CODE: code,
|
||||
EnterpriseAnalyticsKey.TITLE: ReportTitle.ENTERPRISE_ANALYTICS,
|
||||
EnterpriseAnalyticsKey.FILTERS: lifecycle[LifecycleResponseKey.FILTERS],
|
||||
EnterpriseAnalyticsKey.FINANCE: finance,
|
||||
EnterpriseAnalyticsKey.PROCUREMENT: procurement,
|
||||
EnterpriseAnalyticsKey.PERFORMANCE: performance,
|
||||
EnterpriseAnalyticsKey.OPERATIONS: operations,
|
||||
EnterpriseAnalyticsKey.RECOMMENDATIONS: recommendations,
|
||||
EnterpriseAnalyticsKey.LINES: lines,
|
||||
EnterpriseAnalyticsKey.CONTENT: "\n".join(lines),
|
||||
}
|
||||
)
|
||||
AuditService(self.db).log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.REPORTS,
|
||||
action=AuditAction.ENTERPRISE_ANALYTICS,
|
||||
target_type=AuditTargetType.ENTERPRISE_ANALYTICS,
|
||||
target_id=code,
|
||||
response_payload=report,
|
||||
)
|
||||
)
|
||||
EventService(self.db).emit(
|
||||
event_type=EventType.ENTERPRISE_ANALYTICS_GENERATED,
|
||||
source=EventSource.ANALYTICS,
|
||||
aggregate_type=EventAggregateType.ENTERPRISE_ANALYTICS,
|
||||
aggregate_id=code,
|
||||
actor=actor,
|
||||
payload={
|
||||
EventPayloadKey.CODE: code,
|
||||
EventPayloadKey.STATUS: ReportStatus.GENERATED,
|
||||
},
|
||||
idempotency_key=f"enterprise-analytics:{code}",
|
||||
dispatch=True,
|
||||
)
|
||||
return report
|
||||
|
||||
def _enterprise_performance_stats(self) -> dict[str, Any]:
|
||||
total = self._count(PerformanceMetric)
|
||||
confirmed = self._count(PerformanceMetric, PerformanceMetric.confirmed_score.is_not(None))
|
||||
return {
|
||||
MetricKey.TOTAL: total,
|
||||
MetricKey.CONFIRMED: confirmed,
|
||||
MetricKey.CONFIRMED_RATE: _rate(confirmed, total),
|
||||
MetricKey.AVERAGE_AUTO_SCORE: self._avg(PerformanceMetric.auto_score),
|
||||
MetricKey.AVERAGE_CONFIRMED_SCORE: self._avg(PerformanceMetric.confirmed_score),
|
||||
MetricKey.WEIGHT_TOTAL: self._sum(PerformanceMetric.weight),
|
||||
MetricKey.BY_STATUS: self._group_counts(PerformanceMetric, PerformanceMetric.status),
|
||||
}
|
||||
|
||||
def _enterprise_analytics_lines(
|
||||
self,
|
||||
filters: dict[str, Any],
|
||||
finance: dict[str, Any],
|
||||
procurement: dict[str, Any],
|
||||
performance: dict[str, Any],
|
||||
operations: dict[str, Any],
|
||||
recommendations: list[str],
|
||||
) -> list[str]:
|
||||
scope = (
|
||||
"、".join(f"{key}={value}" for key, value in filters.items() if value)
|
||||
or ReportText.DEFAULT_SCOPE
|
||||
)
|
||||
lines = [
|
||||
f"- 范围:{scope}",
|
||||
(
|
||||
f"- 财务:预算 {_money(finance[MetricKey.BUDGET_TOTAL])},"
|
||||
f"实际 {_money(finance[MetricKey.ACTUAL_TOTAL])},"
|
||||
f"净头寸 {_money(finance[MetricKey.NET_POSITION])},"
|
||||
f"支付暴露 {_money(finance[MetricKey.PAYMENT_EXPOSURE])}"
|
||||
),
|
||||
(
|
||||
f"- 采购:总数 {procurement[MetricKey.TOTAL]},"
|
||||
f"待审批 {procurement[MetricKey.PENDING_APPROVAL]},"
|
||||
f"待交付 {procurement[MetricKey.PENDING_DELIVERY]},"
|
||||
f"未付款 {procurement[MetricKey.UNPAID]}"
|
||||
),
|
||||
(
|
||||
f"- 绩效:指标 {performance[MetricKey.TOTAL]},"
|
||||
f"已确认 {performance[MetricKey.CONFIRMED]},"
|
||||
f"确认率 {performance[MetricKey.CONFIRMED_RATE]}%,"
|
||||
f"平均自动分 {performance[MetricKey.AVERAGE_AUTO_SCORE]}"
|
||||
),
|
||||
(
|
||||
f"- 运营:准备度 {operations[MetricKey.READINESS_SCORE]},"
|
||||
f"任务完成率 {operations[MetricKey.COMPLETION_RATE]}%,"
|
||||
f"逾期任务 {operations[MetricKey.OVERDUE_TASKS]},"
|
||||
f"打开风险 {operations[MetricKey.OPEN_EVENTS]}"
|
||||
),
|
||||
ReportText.ACTION_HEADER,
|
||||
]
|
||||
lines.extend(f" - {item}" for item in recommendations)
|
||||
return lines
|
||||
|
||||
def _lifecycle_filters(
|
||||
self,
|
||||
project_code: str | None,
|
||||
@@ -985,6 +1150,19 @@ class ReportService:
|
||||
response_payload=record_data,
|
||||
)
|
||||
)
|
||||
EventService(self.db).emit(
|
||||
event_type=EventType.REPORT_GENERATED,
|
||||
source=EventSource.REPORTS,
|
||||
aggregate_type=EventAggregateType.WORK_REPORT,
|
||||
aggregate_id=record.code,
|
||||
actor=actor,
|
||||
payload={
|
||||
EventPayloadKey.CODE: record.code,
|
||||
EventPayloadKey.STATUS: record.status,
|
||||
},
|
||||
idempotency_key=f"report-generated:{record.code}",
|
||||
dispatch=True,
|
||||
)
|
||||
|
||||
return {ReportResponseKey.REPORT: report, ReportResponseKey.DATA: record_data}
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ from enum import StrEnum
|
||||
|
||||
class WorkflowType(StrEnum):
|
||||
RISK_EVENT_REVIEW = "risk_event_review"
|
||||
REPORT_DELIVERY = "report_delivery"
|
||||
LEGACY_SYNC_MONITOR = "legacy_sync_monitor"
|
||||
ENTERPRISE_ANALYTICS = "enterprise_analytics"
|
||||
AI_MEMORY_CAPTURE = "ai_memory_capture"
|
||||
|
||||
|
||||
class WorkflowStatus(StrEnum):
|
||||
|
||||
25
app/tasks.py
25
app/tasks.py
@@ -1,4 +1,5 @@
|
||||
from typing import Any
|
||||
from socket import gethostname
|
||||
|
||||
from celery import Celery
|
||||
|
||||
@@ -40,6 +41,30 @@ def push_daily_brief(
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(name="events.dispatch_pending")
|
||||
def dispatch_pending_events(
|
||||
limit: int | None = None,
|
||||
actor: str = ActorValue.WORKER,
|
||||
) -> list[dict[str, Any]]:
|
||||
from app.modules.events.service import EventService
|
||||
from app.modules.observability.constants import HeartbeatComponent
|
||||
from app.modules.observability.service import ObservabilityService
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ObservabilityService(db).record_heartbeat(
|
||||
component=HeartbeatComponent.WORKER,
|
||||
instance_id=gethostname(),
|
||||
actor=actor,
|
||||
)
|
||||
return EventService(db).dispatch_pending(
|
||||
limit=limit or settings.event_dispatch_batch_size,
|
||||
worker_id=f"{actor}:{gethostname()}",
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(name="reports.push_project_weekly")
|
||||
def push_project_weekly(
|
||||
receive_id: str | None = None,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from app.core.database import Base, engine
|
||||
from app.modules.ai_memory.models import AIMemoryEntry
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.business.models import (
|
||||
AttendanceRecord,
|
||||
@@ -19,6 +20,7 @@ from app.modules.business.models import (
|
||||
)
|
||||
from app.modules.feishu.models import FeishuEventReceipt
|
||||
from app.modules.events.models import DomainEvent
|
||||
from app.modules.observability.models import SystemHeartbeat
|
||||
from app.modules.workflows.models import WorkflowAction, WorkflowInstance
|
||||
|
||||
_MODELS = [
|
||||
@@ -42,6 +44,8 @@ _MODELS = [
|
||||
DomainEvent,
|
||||
WorkflowInstance,
|
||||
WorkflowAction,
|
||||
AIMemoryEntry,
|
||||
SystemHeartbeat,
|
||||
]
|
||||
|
||||
|
||||
|
||||
17
app/tools/run_scheduler.py
Normal file
17
app/tools/run_scheduler.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from time import sleep
|
||||
|
||||
from app.core.scheduler import create_scheduler
|
||||
|
||||
|
||||
def main() -> None:
|
||||
scheduler = create_scheduler()
|
||||
scheduler.start()
|
||||
try:
|
||||
while True:
|
||||
sleep(60)
|
||||
except KeyboardInterrupt:
|
||||
scheduler.shutdown(wait=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user