```
feat: 添加AI记忆模块和事件调度系统 - 新增AI记忆模块,支持本地记忆召回和自动写入功能 - 实现事件调度系统,支持批量处理待定事件和重试机制 - 集成心跳监控机制,跟踪API、调度器和工作节点状态 - 扩展仪表板数据统计,包含AI记忆条目和心跳概要 - 添加企业运营分析报告功能,提供财务、采购等多维度分析 - 更新配置设置,增加事件调度和AI记忆相关参数 - 优化任务队列,添加事件分发任务类型 - 扩展审计日志,记录AI记忆操作和事件调度行为 - 实现领域事件模型,支持事件持久化和状态管理 - 添加观察性服务,监控系统组件健康状况 ```
This commit is contained in:
@@ -5,10 +5,12 @@ from sqlalchemy import engine_from_config, pool
|
|||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.db_base import Base
|
from app.core.db_base import Base
|
||||||
|
from app.modules.ai_memory import models as ai_memory_models
|
||||||
from app.modules.audit import models as audit_models
|
from app.modules.audit import models as audit_models
|
||||||
from app.modules.business import models as business_models
|
from app.modules.business import models as business_models
|
||||||
from app.modules.events import models as event_models
|
from app.modules.events import models as event_models
|
||||||
from app.modules.feishu import models as feishu_models
|
from app.modules.feishu import models as feishu_models
|
||||||
|
from app.modules.observability import models as observability_models
|
||||||
from app.modules.workflows import models as workflow_models
|
from app.modules.workflows import models as workflow_models
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
@@ -21,10 +23,12 @@ settings = get_settings()
|
|||||||
|
|
||||||
# Keep imports referenced so SQLAlchemy model classes register with Base.metadata.
|
# Keep imports referenced so SQLAlchemy model classes register with Base.metadata.
|
||||||
_REGISTERED_MODEL_MODULES = (
|
_REGISTERED_MODEL_MODULES = (
|
||||||
|
ai_memory_models,
|
||||||
audit_models,
|
audit_models,
|
||||||
business_models,
|
business_models,
|
||||||
event_models,
|
event_models,
|
||||||
feishu_models,
|
feishu_models,
|
||||||
|
observability_models,
|
||||||
workflow_models,
|
workflow_models,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Complete V3 read-only operations foundation.
|
||||||
|
|
||||||
|
Revision ID: 202607090001
|
||||||
|
Revises: 202607080002
|
||||||
|
Create Date: 2026-07-09
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202607090001"
|
||||||
|
down_revision = "202607080002"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
DOMAIN_EVENTS_TABLE = "domain_events"
|
||||||
|
AI_MEMORY_ENTRIES_TABLE = "ai_memory_entries"
|
||||||
|
SYSTEM_HEARTBEATS_TABLE = "system_heartbeats"
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(table_name: str) -> bool:
|
||||||
|
inspector = inspect(op.get_bind())
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _column_names(table_name: str) -> set[str]:
|
||||||
|
inspector = inspect(op.get_bind())
|
||||||
|
if table_name not in inspector.get_table_names():
|
||||||
|
return set()
|
||||||
|
return {column["name"] for column in inspector.get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_column_if_missing(table_name: str, column: sa.Column) -> None:
|
||||||
|
if column.name not in _column_names(table_name):
|
||||||
|
op.add_column(table_name, column)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_indexes(table_name: str, indexes: list[tuple[str, bool]]) -> None:
|
||||||
|
for column_name, unique in indexes:
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_{table_name}_{column_name}"),
|
||||||
|
table_name,
|
||||||
|
[column_name],
|
||||||
|
unique=unique,
|
||||||
|
if_not_exists=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for column in [
|
||||||
|
sa.Column("next_attempt_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("locked_until", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("locked_by", sa.String(length=128), nullable=True),
|
||||||
|
sa.Column("max_attempts", sa.Integer(), nullable=True),
|
||||||
|
]:
|
||||||
|
_add_column_if_missing(DOMAIN_EVENTS_TABLE, column)
|
||||||
|
_create_indexes(
|
||||||
|
DOMAIN_EVENTS_TABLE,
|
||||||
|
[
|
||||||
|
("next_attempt_at", False),
|
||||||
|
("locked_until", False),
|
||||||
|
("locked_by", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _table_exists(AI_MEMORY_ENTRIES_TABLE):
|
||||||
|
op.create_table(
|
||||||
|
AI_MEMORY_ENTRIES_TABLE,
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("code", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("scope", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("subject", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("content", sa.Text(), nullable=False),
|
||||||
|
sa.Column("summary", sa.Text(), nullable=True),
|
||||||
|
sa.Column("tags", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("source", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("importance", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("actor", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("last_used_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("expires_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
_create_indexes(
|
||||||
|
AI_MEMORY_ENTRIES_TABLE,
|
||||||
|
[
|
||||||
|
("code", True),
|
||||||
|
("scope", False),
|
||||||
|
("subject", False),
|
||||||
|
("source", False),
|
||||||
|
("importance", False),
|
||||||
|
("status", False),
|
||||||
|
("actor", False),
|
||||||
|
("last_used_at", False),
|
||||||
|
("expires_at", False),
|
||||||
|
("created_at", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _table_exists(SYSTEM_HEARTBEATS_TABLE):
|
||||||
|
op.create_table(
|
||||||
|
SYSTEM_HEARTBEATS_TABLE,
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("component", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("instance_id", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("last_seen_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
_create_indexes(
|
||||||
|
SYSTEM_HEARTBEATS_TABLE,
|
||||||
|
[
|
||||||
|
("component", False),
|
||||||
|
("instance_id", False),
|
||||||
|
("status", False),
|
||||||
|
("last_seen_at", False),
|
||||||
|
("created_at", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table_name in [SYSTEM_HEARTBEATS_TABLE, AI_MEMORY_ENTRIES_TABLE]:
|
||||||
|
if _table_exists(table_name):
|
||||||
|
op.drop_table(table_name)
|
||||||
|
|
||||||
|
event_columns = _column_names(DOMAIN_EVENTS_TABLE)
|
||||||
|
for column_name in ["locked_by", "locked_until", "next_attempt_at"]:
|
||||||
|
if column_name in event_columns:
|
||||||
|
op.drop_index(op.f(f"ix_{DOMAIN_EVENTS_TABLE}_{column_name}"), table_name=DOMAIN_EVENTS_TABLE)
|
||||||
|
op.drop_column(DOMAIN_EVENTS_TABLE, column_name)
|
||||||
|
if "max_attempts" in _column_names(DOMAIN_EVENTS_TABLE):
|
||||||
|
op.drop_column(DOMAIN_EVENTS_TABLE, "max_attempts")
|
||||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
from app.core.constants import ApiResponseKey, ApiStatus
|
from app.core.constants import ApiResponseKey, ApiStatus
|
||||||
from app.modules.ai_agent.routes import router as ai_router
|
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.audit.routes import router as audit_router
|
||||||
from app.modules.business.routes import router as business_router
|
from app.modules.business.routes import router as business_router
|
||||||
from app.modules.dashboard.routes import router as dashboard_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(legacy_mysql_router, prefix="/integrations/mysql", tags=["mysql"])
|
||||||
api_router.include_router(feishu_router, prefix="/integrations/feishu", tags=["feishu"])
|
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_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(reports_router, prefix="/reports", tags=["reports"])
|
||||||
api_router.include_router(risk_router, prefix="/risks", tags=["risks"])
|
api_router.include_router(risk_router, prefix="/risks", tags=["risks"])
|
||||||
api_router.include_router(audit_router, prefix="/audit", tags=["audit"])
|
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_project_sync_cron_minute: int = 0
|
||||||
legacy_task_sync_cron_hour: int = 2
|
legacy_task_sync_cron_hour: int = 2
|
||||||
legacy_task_sync_cron_minute: int = 30
|
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")
|
@field_validator("cors_origins", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse_cors_origins(cls, value: Any) -> list[str]:
|
def parse_cors_origins(cls, value: Any) -> list[str]:
|
||||||
@@ -98,6 +126,7 @@ class Settings(BaseSettings):
|
|||||||
@field_validator(
|
@field_validator(
|
||||||
"openclaw_allowed_tools",
|
"openclaw_allowed_tools",
|
||||||
"openclaw_allowed_actions",
|
"openclaw_allowed_actions",
|
||||||
|
"ai_memory_forbidden_keys",
|
||||||
mode="before",
|
mode="before",
|
||||||
)
|
)
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class ActorValue(StrEnum):
|
|||||||
AUDITOR = "auditor"
|
AUDITOR = "auditor"
|
||||||
SYSTEM = "system"
|
SYSTEM = "system"
|
||||||
SCHEDULER = "scheduler"
|
SCHEDULER = "scheduler"
|
||||||
|
WORKER = "worker"
|
||||||
FEISHU = "feishu"
|
FEISHU = "feishu"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
from socket import gethostname
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from app.core.constants import ActorValue
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
from app.core.constants import ActorValue
|
||||||
from app.modules.feishu.constants import FeishuReceiveIdType
|
from app.modules.feishu.constants import FeishuReceiveIdType
|
||||||
|
from app.modules.observability.constants import HeartbeatComponent
|
||||||
|
|
||||||
|
|
||||||
def attach_scheduler(app: FastAPI) -> None:
|
def attach_scheduler(app: FastAPI) -> None:
|
||||||
@@ -12,35 +16,53 @@ def attach_scheduler(app: FastAPI) -> None:
|
|||||||
if not settings.scheduler_enabled:
|
if not settings.scheduler_enabled:
|
||||||
return
|
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 apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
|
||||||
from app.core.database import SessionLocal
|
from app.core.database import SessionLocal
|
||||||
from app.core.task_queue import (
|
from app.core.task_queue import (
|
||||||
enqueue_daily_brief_push,
|
enqueue_daily_brief_push,
|
||||||
|
enqueue_event_dispatch,
|
||||||
enqueue_legacy_project_sync,
|
enqueue_legacy_project_sync,
|
||||||
enqueue_legacy_task_sync,
|
enqueue_legacy_task_sync,
|
||||||
enqueue_project_weekly_push,
|
enqueue_project_weekly_push,
|
||||||
)
|
)
|
||||||
|
from app.modules.observability.service import ObservabilityService
|
||||||
from app.modules.reports.service import ReportService
|
from app.modules.reports.service import ReportService
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
|
scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
|
||||||
|
|
||||||
def run_daily_brief() -> None:
|
def run_daily_brief() -> None:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
report = ReportService(db).daily_brief()
|
report = ReportService(db).daily_brief()
|
||||||
app.state.last_daily_brief = report
|
_set_state(app, "last_daily_brief", report)
|
||||||
if (
|
if (
|
||||||
settings.feishu_app_id
|
settings.feishu_app_id
|
||||||
and settings.feishu_app_secret
|
and settings.feishu_app_secret
|
||||||
and settings.feishu_default_chat_id
|
and settings.feishu_default_chat_id
|
||||||
):
|
):
|
||||||
if settings.task_queue_enabled:
|
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=settings.feishu_default_chat_id,
|
||||||
receive_id_type=FeishuReceiveIdType.CHAT_ID,
|
receive_id_type=FeishuReceiveIdType.CHAT_ID,
|
||||||
actor=ActorValue.SCHEDULER,
|
actor=ActorValue.SCHEDULER,
|
||||||
)
|
)
|
||||||
|
_set_state(app, "last_daily_brief_dispatch", dispatch)
|
||||||
return
|
return
|
||||||
ReportService(db).push_report(
|
ReportService(db).push_report(
|
||||||
report,
|
report,
|
||||||
@@ -55,18 +77,19 @@ def attach_scheduler(app: FastAPI) -> None:
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
report = ReportService(db).project_weekly()
|
report = ReportService(db).project_weekly()
|
||||||
app.state.last_project_weekly = report
|
_set_state(app, "last_project_weekly", report)
|
||||||
if (
|
if (
|
||||||
settings.feishu_app_id
|
settings.feishu_app_id
|
||||||
and settings.feishu_app_secret
|
and settings.feishu_app_secret
|
||||||
and settings.feishu_default_chat_id
|
and settings.feishu_default_chat_id
|
||||||
):
|
):
|
||||||
if settings.task_queue_enabled:
|
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=settings.feishu_default_chat_id,
|
||||||
receive_id_type=FeishuReceiveIdType.CHAT_ID,
|
receive_id_type=FeishuReceiveIdType.CHAT_ID,
|
||||||
actor=ActorValue.SCHEDULER,
|
actor=ActorValue.SCHEDULER,
|
||||||
)
|
)
|
||||||
|
_set_state(app, "last_project_weekly_dispatch", dispatch)
|
||||||
return
|
return
|
||||||
ReportService(db).push_report(
|
ReportService(db).push_report(
|
||||||
report,
|
report,
|
||||||
@@ -78,14 +101,31 @@ def attach_scheduler(app: FastAPI) -> None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
def run_legacy_project_sync() -> None:
|
def run_legacy_project_sync() -> None:
|
||||||
app.state.last_legacy_project_sync_dispatch = enqueue_legacy_project_sync(
|
dispatch = enqueue_legacy_project_sync(actor=ActorValue.SCHEDULER)
|
||||||
actor=ActorValue.SCHEDULER,
|
_set_state(app, "last_legacy_project_sync_dispatch", dispatch)
|
||||||
)
|
|
||||||
|
|
||||||
def run_legacy_task_sync() -> None:
|
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,
|
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(
|
scheduler.add_job(
|
||||||
run_daily_brief,
|
run_daily_brief,
|
||||||
@@ -104,6 +144,21 @@ def attach_scheduler(app: FastAPI) -> None:
|
|||||||
id="project_weekly_push",
|
id="project_weekly_push",
|
||||||
replace_existing=True,
|
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:
|
if settings.legacy_sync_enabled and settings.legacy_project_query:
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
run_legacy_project_sync,
|
run_legacy_project_sync,
|
||||||
@@ -122,11 +177,9 @@ def attach_scheduler(app: FastAPI) -> None:
|
|||||||
id="legacy_task_sync",
|
id="legacy_task_sync",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
return scheduler
|
||||||
|
|
||||||
@app.on_event("startup")
|
|
||||||
def start_scheduler() -> None:
|
|
||||||
scheduler.start()
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
def _set_state(app: FastAPI | None, key: str, value: Any) -> None:
|
||||||
def stop_scheduler() -> None:
|
if app is not None:
|
||||||
scheduler.shutdown(wait=False)
|
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_GENERATE_RISK_EVENTS = "risks.generate_events"
|
||||||
TASK_SYNC_LEGACY_PROJECTS = "legacy.sync_projects"
|
TASK_SYNC_LEGACY_PROJECTS = "legacy.sync_projects"
|
||||||
TASK_SYNC_LEGACY_TASKS = "legacy.sync_tasks"
|
TASK_SYNC_LEGACY_TASKS = "legacy.sync_tasks"
|
||||||
|
TASK_DISPATCH_PENDING_EVENTS = "events.dispatch_pending"
|
||||||
|
|
||||||
|
|
||||||
def dispatch_task(
|
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(
|
def enqueue_legacy_project_sync(
|
||||||
source_query: str | None = None,
|
source_query: str | None = None,
|
||||||
source_query_name: str | None = None,
|
source_query_name: str | None = None,
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class AIResponseKey(StrEnum):
|
|||||||
TEXT = "text"
|
TEXT = "text"
|
||||||
PIPELINE = "pipeline"
|
PIPELINE = "pipeline"
|
||||||
HERMES_RECALL = "hermes_recall"
|
HERMES_RECALL = "hermes_recall"
|
||||||
|
LOCAL_MEMORY = "local_memory"
|
||||||
|
MEMORY_WRITE = "memory_write"
|
||||||
HERMES_ANSWER = "hermes_answer"
|
HERMES_ANSWER = "hermes_answer"
|
||||||
HERMES_REMEMBER = "hermes_remember"
|
HERMES_REMEMBER = "hermes_remember"
|
||||||
OPENCLAW = "openclaw"
|
OPENCLAW = "openclaw"
|
||||||
@@ -49,6 +51,9 @@ class AIContextKey(StrEnum):
|
|||||||
OPENCLAW_SESSION_KEY = "openclaw_session_key"
|
OPENCLAW_SESSION_KEY = "openclaw_session_key"
|
||||||
AGENT_PIPELINE = "agent_pipeline"
|
AGENT_PIPELINE = "agent_pipeline"
|
||||||
HERMES_MEMORY = "hermes_memory"
|
HERMES_MEMORY = "hermes_memory"
|
||||||
|
LOCAL_MEMORY = "local_memory"
|
||||||
|
MEMORY_SCOPE = "memory_scope"
|
||||||
|
MEMORY_SUBJECT = "memory_subject"
|
||||||
OPENCLAW = "openclaw"
|
OPENCLAW = "openclaw"
|
||||||
MODE = "mode"
|
MODE = "mode"
|
||||||
USER_PROMPT = "user_prompt"
|
USER_PROMPT = "user_prompt"
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ from app.modules.ai_agent.constants import (
|
|||||||
AI_AUDIT_SENSITIVE_KEYS,
|
AI_AUDIT_SENSITIVE_KEYS,
|
||||||
AI_AUDIT_TRUNCATED_VALUE,
|
AI_AUDIT_TRUNCATED_VALUE,
|
||||||
AIToolAuditKey,
|
AIToolAuditKey,
|
||||||
|
AIContextKey,
|
||||||
AIProviderName,
|
AIProviderName,
|
||||||
AIRequestKey,
|
AIRequestKey,
|
||||||
AIResponseKey,
|
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.ai_agent.skills import AISkillId, get_ai_skill
|
||||||
from app.modules.audit.constants import (
|
from app.modules.audit.constants import (
|
||||||
AuditAction,
|
AuditAction,
|
||||||
@@ -44,11 +47,37 @@ class AIService:
|
|||||||
source: str = AuditSource.API,
|
source: str = AuditSource.API,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
adapter = get_adapter()
|
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 = {
|
response = {
|
||||||
AIResponseKey.PROVIDER: adapter.provider_name,
|
AIResponseKey.PROVIDER: adapter.provider_name,
|
||||||
AIResponseKey.ANSWER: result[AIResponseKey.ANSWER],
|
AIResponseKey.ANSWER: answer,
|
||||||
AIResponseKey.RAW: result.get(AIResponseKey.RAW, {}),
|
AIResponseKey.RAW: raw,
|
||||||
}
|
}
|
||||||
self.audit.log(
|
self.audit.log(
|
||||||
AuditLogCreate(
|
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:
|
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[:AI_AUDIT_MAX_TEXT_LENGTH] + AI_AUDIT_TRUNCATED_VALUE
|
||||||
return 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"
|
LEGACY_SYNC_TASKS = "sync_tasks"
|
||||||
RISK_EVENT_ACTION = "risk_event_action"
|
RISK_EVENT_ACTION = "risk_event_action"
|
||||||
REPORT_PUSH = "report_push"
|
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):
|
class AuditRiskLevel(StrEnum):
|
||||||
@@ -29,6 +34,9 @@ class AuditSource(StrEnum):
|
|||||||
FEISHU = "feishu"
|
FEISHU = "feishu"
|
||||||
LEGACY_MYSQL = "legacy_mysql"
|
LEGACY_MYSQL = "legacy_mysql"
|
||||||
REPORTS = "reports"
|
REPORTS = "reports"
|
||||||
|
EVENTS = "events"
|
||||||
|
AI_MEMORY = "ai_memory"
|
||||||
|
OBSERVABILITY = "observability"
|
||||||
|
|
||||||
|
|
||||||
class AuditTargetType(StrEnum):
|
class AuditTargetType(StrEnum):
|
||||||
@@ -36,6 +44,10 @@ class AuditTargetType(StrEnum):
|
|||||||
OPENCLAW_TOOL = "openclaw_tool"
|
OPENCLAW_TOOL = "openclaw_tool"
|
||||||
RISK_EVENTS = "risk-events"
|
RISK_EVENTS = "risk-events"
|
||||||
WORK_REPORTS = "work-reports"
|
WORK_REPORTS = "work-reports"
|
||||||
|
ENTERPRISE_ANALYTICS = "enterprise-analytics"
|
||||||
|
AI_MEMORY = "ai-memory"
|
||||||
|
DOMAIN_EVENT = "domain-event"
|
||||||
|
HEARTBEAT = "heartbeat"
|
||||||
|
|
||||||
|
|
||||||
class AuditStatus(StrEnum):
|
class AuditStatus(StrEnum):
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from typing import Any
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
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.audit.models import AuditLog
|
||||||
from app.modules.business.constants import DONE_STATUSES, PROJECT_CLOSED_STATUSES, StatusValue
|
from app.modules.business.constants import DONE_STATUSES, PROJECT_CLOSED_STATUSES, StatusValue
|
||||||
from app.modules.business.models import (
|
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.business.service import serialize_model
|
||||||
from app.modules.events.constants import EventStatus
|
from app.modules.events.constants import EventStatus
|
||||||
from app.modules.events.models import DomainEvent
|
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.reports.constants import ReportPushStatus
|
||||||
from app.modules.risk.service import RiskService
|
from app.modules.risk.service import RiskService
|
||||||
from app.modules.workflows.constants import WorkflowStatus
|
from app.modules.workflows.constants import WorkflowStatus
|
||||||
@@ -23,7 +27,7 @@ from app.modules.workflows.models import WorkflowInstance
|
|||||||
|
|
||||||
|
|
||||||
class DashboardService:
|
class DashboardService:
|
||||||
"""Build lightweight operational dashboard data for V2."""
|
"""Build lightweight operational dashboard data for V2/V3."""
|
||||||
|
|
||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session):
|
||||||
self.db = db
|
self.db = db
|
||||||
@@ -49,6 +53,8 @@ class DashboardService:
|
|||||||
WorkflowInstance,
|
WorkflowInstance,
|
||||||
WorkflowInstance.status == WorkflowStatus.FAILED,
|
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(
|
latest_reports = self.db.execute(
|
||||||
select(WorkReport).order_by(WorkReport.id.desc()).limit(5)
|
select(WorkReport).order_by(WorkReport.id.desc()).limit(5)
|
||||||
).scalars()
|
).scalars()
|
||||||
@@ -73,6 +79,8 @@ class DashboardService:
|
|||||||
"failed_events": failed_events,
|
"failed_events": failed_events,
|
||||||
"running_workflows": running_workflows,
|
"running_workflows": running_workflows,
|
||||||
"failed_workflows": failed_workflows,
|
"failed_workflows": failed_workflows,
|
||||||
|
"active_ai_memory": active_ai_memory,
|
||||||
|
"stale_heartbeats": heartbeat_summary[ObservabilityMetricKey.STALE],
|
||||||
"risk_level": risk_summary["risk_level"],
|
"risk_level": risk_summary["risk_level"],
|
||||||
"risk_score": float(risk_summary["risk_score"]),
|
"risk_score": float(risk_summary["risk_score"]),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,20 +11,29 @@ class EventType(StrEnum):
|
|||||||
RISK_ACTION_RECORDED = "risk.action_recorded"
|
RISK_ACTION_RECORDED = "risk.action_recorded"
|
||||||
REPORT_PUSH_SUCCEEDED = "report.push_succeeded"
|
REPORT_PUSH_SUCCEEDED = "report.push_succeeded"
|
||||||
REPORT_PUSH_FAILED = "report.push_failed"
|
REPORT_PUSH_FAILED = "report.push_failed"
|
||||||
|
REPORT_GENERATED = "report.generated"
|
||||||
LEGACY_SYNC_COMPLETED = "legacy.sync_completed"
|
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):
|
class EventSource(StrEnum):
|
||||||
RISK = "risk"
|
RISK = "risk"
|
||||||
REPORTS = "reports"
|
REPORTS = "reports"
|
||||||
LEGACY_MYSQL = "legacy_mysql"
|
LEGACY_MYSQL = "legacy_mysql"
|
||||||
|
AI_MEMORY = "ai_memory"
|
||||||
|
ANALYTICS = "analytics"
|
||||||
API = "api"
|
API = "api"
|
||||||
|
|
||||||
|
|
||||||
class EventAggregateType(StrEnum):
|
class EventAggregateType(StrEnum):
|
||||||
RISK_EVENT = "risk-event"
|
RISK_EVENT = "risk-event"
|
||||||
REPORT_PUSH_RUN = "report-push-run"
|
REPORT_PUSH_RUN = "report-push-run"
|
||||||
|
WORK_REPORT = "work-report"
|
||||||
LEGACY_SYNC_RUN = "legacy-sync-run"
|
LEGACY_SYNC_RUN = "legacy-sync-run"
|
||||||
|
AI_MEMORY_ENTRY = "ai-memory-entry"
|
||||||
|
ENTERPRISE_ANALYTICS = "enterprise-analytics"
|
||||||
|
|
||||||
|
|
||||||
class EventResponseKey(StrEnum):
|
class EventResponseKey(StrEnum):
|
||||||
@@ -44,10 +53,13 @@ class EventPayloadKey(StrEnum):
|
|||||||
UPDATED = "updated"
|
UPDATED = "updated"
|
||||||
SKIPPED = "skipped"
|
SKIPPED = "skipped"
|
||||||
ERROR_MESSAGE = "error_message"
|
ERROR_MESSAGE = "error_message"
|
||||||
|
ATTEMPTS = "attempts"
|
||||||
|
HANDLED = "handled"
|
||||||
|
|
||||||
|
|
||||||
class EventErrorDetail(StrEnum):
|
class EventErrorDetail(StrEnum):
|
||||||
EVENT_NOT_FOUND = "Domain event not found"
|
EVENT_NOT_FOUND = "Domain event not found"
|
||||||
|
EVENT_NOT_RETRYABLE = "Domain event is not retryable"
|
||||||
|
|
||||||
|
|
||||||
EVENT_CODE_PREFIX = "EVT"
|
EVENT_CODE_PREFIX = "EVT"
|
||||||
|
|||||||
@@ -29,5 +29,13 @@ class DomainEvent(Base):
|
|||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=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)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||||
processed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, 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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.operation_guard import require_operations_enabled
|
from app.core.security import ApiPrincipal, require_api_key
|
||||||
from app.core.security import require_api_key
|
|
||||||
from app.modules.events.constants import EventResponseKey
|
from app.modules.events.constants import EventResponseKey
|
||||||
from app.modules.events.service import EventService, _serialize_event
|
from app.modules.events.service import EventService, _serialize_event
|
||||||
|
|
||||||
@@ -31,14 +30,25 @@ def dispatch_event(
|
|||||||
event_id: str,
|
event_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
require_operations_enabled()
|
|
||||||
return {EventResponseKey.EVENT: _serialize_event(EventService(db).dispatch_event(event_id))}
|
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")
|
@router.post("/dispatch-pending")
|
||||||
def dispatch_pending(
|
def dispatch_pending(
|
||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
require_operations_enabled()
|
|
||||||
return {EventResponseKey.ITEMS: EventService(db).dispatch_pending(limit=limit)}
|
return {EventResponseKey.ITEMS: EventService(db).dispatch_pending(limit=limit)}
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
|
from datetime import timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
from app.core.constants import ActorValue
|
from app.core.constants import ActorValue
|
||||||
from app.core.pagination import bounded_limit
|
from app.core.pagination import bounded_limit
|
||||||
from app.core.time import utc_now
|
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 (
|
from app.modules.events.constants import (
|
||||||
EVENT_CODE_PREFIX,
|
EVENT_CODE_PREFIX,
|
||||||
EventAggregateType,
|
EventAggregateType,
|
||||||
@@ -51,8 +62,10 @@ class EventService:
|
|||||||
return self.dispatch_event(existing.event_id)
|
return self.dispatch_event(existing.event_id)
|
||||||
return existing
|
return existing
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
now = utc_now()
|
||||||
record = DomainEvent(
|
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,
|
event_type=event_type,
|
||||||
source=source,
|
source=source,
|
||||||
aggregate_type=aggregate_type,
|
aggregate_type=aggregate_type,
|
||||||
@@ -60,6 +73,8 @@ class EventService:
|
|||||||
actor=actor,
|
actor=actor,
|
||||||
payload=payload or {},
|
payload=payload or {},
|
||||||
idempotency_key=idempotency_key,
|
idempotency_key=idempotency_key,
|
||||||
|
next_attempt_at=now,
|
||||||
|
max_attempts=settings.event_dispatch_max_attempts,
|
||||||
)
|
)
|
||||||
self.db.add(record)
|
self.db.add(record)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
@@ -98,39 +113,120 @@ class EventService:
|
|||||||
)
|
)
|
||||||
return record
|
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)
|
record = self.get_event(event_id)
|
||||||
if record.status == EventStatus.PROCESSED:
|
if record.status == EventStatus.PROCESSED:
|
||||||
return record
|
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
|
record.attempts += 1
|
||||||
try:
|
try:
|
||||||
self._handle_event(record)
|
self._handle_event(record)
|
||||||
except Exception as exc:
|
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.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.commit()
|
||||||
self.db.refresh(record)
|
self.db.refresh(record)
|
||||||
|
self._audit_dispatch(record)
|
||||||
return record
|
return record
|
||||||
record.status = EventStatus.PROCESSED
|
record.status = EventStatus.PROCESSED
|
||||||
record.last_error = None
|
record.last_error = None
|
||||||
record.processed_at = utc_now()
|
record.processed_at = utc_now()
|
||||||
|
record.next_attempt_at = None
|
||||||
|
record.locked_by = None
|
||||||
|
record.locked_until = None
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(record)
|
self.db.refresh(record)
|
||||||
|
self._audit_dispatch(record)
|
||||||
return 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 = (
|
stmt = (
|
||||||
select(DomainEvent)
|
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())
|
.order_by(DomainEvent.id.asc())
|
||||||
.limit(bounded_limit(limit))
|
.limit(bounded_limit(limit))
|
||||||
)
|
)
|
||||||
records = list(self.db.execute(stmt).scalars())
|
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:
|
def _handle_event(self, record: DomainEvent) -> None:
|
||||||
if record.event_type == EventType.RISK_ACTION_RECORDED:
|
if record.event_type == EventType.RISK_ACTION_RECORDED:
|
||||||
self._handle_risk_action(record)
|
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:
|
def _handle_risk_action(self, record: DomainEvent) -> None:
|
||||||
from app.modules.risk.constants import RiskEventActionValue
|
from app.modules.risk.constants import RiskEventActionValue
|
||||||
@@ -154,3 +250,98 @@ class EventService:
|
|||||||
actor=record.actor,
|
actor=record.actor,
|
||||||
payload=payload,
|
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"
|
REDIS = "redis"
|
||||||
EVENTS = "events"
|
EVENTS = "events"
|
||||||
WORKFLOWS = "workflows"
|
WORKFLOWS = "workflows"
|
||||||
|
AI_MEMORY = "ai_memory"
|
||||||
|
HEARTBEATS = "heartbeats"
|
||||||
|
SCHEDULER = "scheduler"
|
||||||
|
WORKER = "worker"
|
||||||
|
|
||||||
|
|
||||||
class ObservabilityStatus(StrEnum):
|
class ObservabilityStatus(StrEnum):
|
||||||
@@ -20,6 +24,23 @@ class ObservabilityStatus(StrEnum):
|
|||||||
|
|
||||||
class ObservabilityMetricKey(StrEnum):
|
class ObservabilityMetricKey(StrEnum):
|
||||||
ERROR = "error"
|
ERROR = "error"
|
||||||
|
ITEMS = "items"
|
||||||
|
COMPONENT = "component"
|
||||||
|
INSTANCE_ID = "instance_id"
|
||||||
PENDING = "pending"
|
PENDING = "pending"
|
||||||
FAILED = "failed"
|
FAILED = "failed"
|
||||||
RUNNING = "running"
|
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 typing import Any
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import get_settings
|
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.constants import EventStatus
|
||||||
from app.modules.events.service import EventService
|
from app.modules.events.service import EventService
|
||||||
from app.modules.observability.constants import (
|
from app.modules.observability.constants import (
|
||||||
|
HeartbeatStatus,
|
||||||
ObservabilityKey,
|
ObservabilityKey,
|
||||||
ObservabilityMetricKey,
|
ObservabilityMetricKey,
|
||||||
ObservabilityStatus,
|
ObservabilityStatus,
|
||||||
)
|
)
|
||||||
|
from app.modules.observability.models import SystemHeartbeat
|
||||||
from app.modules.workflows.constants import WorkflowStatus
|
from app.modules.workflows.constants import WorkflowStatus
|
||||||
from app.modules.workflows.service import WorkflowService
|
from app.modules.workflows.service import WorkflowService
|
||||||
|
|
||||||
@@ -30,6 +44,7 @@ class ObservabilityService:
|
|||||||
ObservabilityKey.REDIS: self._redis_check(),
|
ObservabilityKey.REDIS: self._redis_check(),
|
||||||
ObservabilityKey.EVENTS: self._events_check(),
|
ObservabilityKey.EVENTS: self._events_check(),
|
||||||
ObservabilityKey.WORKFLOWS: self._workflows_check(),
|
ObservabilityKey.WORKFLOWS: self._workflows_check(),
|
||||||
|
ObservabilityKey.HEARTBEATS: self._heartbeats_check(),
|
||||||
}
|
}
|
||||||
degraded = any(
|
degraded = any(
|
||||||
item[ObservabilityKey.STATUS]
|
item[ObservabilityKey.STATUS]
|
||||||
@@ -48,9 +63,75 @@ class ObservabilityService:
|
|||||||
ObservabilityKey.METRICS: {
|
ObservabilityKey.METRICS: {
|
||||||
ObservabilityKey.EVENTS: EventService(self.db).count_by_status(),
|
ObservabilityKey.EVENTS: EventService(self.db).count_by_status(),
|
||||||
ObservabilityKey.WORKFLOWS: WorkflowService(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]:
|
def _database_check(self) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
self.db.execute(text("select 1")).scalar()
|
self.db.execute(text("select 1")).scalar()
|
||||||
@@ -97,3 +178,34 @@ class ObservabilityService:
|
|||||||
ObservabilityMetricKey.RUNNING: counts.get(WorkflowStatus.RUNNING, 0),
|
ObservabilityMetricKey.RUNNING: counts.get(WorkflowStatus.RUNNING, 0),
|
||||||
ObservabilityMetricKey.FAILED: failed,
|
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_DAILY = "经营日报"
|
||||||
WORK_WEEKLY = "经营周报"
|
WORK_WEEKLY = "经营周报"
|
||||||
PROJECT_LIFECYCLE = "项目全生命周期报告"
|
PROJECT_LIFECYCLE = "项目全生命周期报告"
|
||||||
|
ENTERPRISE_ANALYTICS = "企业只读运营分析"
|
||||||
|
|
||||||
|
|
||||||
class ReportStatus(StrEnum):
|
class ReportStatus(StrEnum):
|
||||||
@@ -70,6 +71,19 @@ class LifecycleResponseKey(StrEnum):
|
|||||||
PROJECT_LIFECYCLE_REPORT = "project_lifecycle_report"
|
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):
|
class ReportResponseKey(StrEnum):
|
||||||
REPORT = "report"
|
REPORT = "report"
|
||||||
DATA = "data"
|
DATA = "data"
|
||||||
@@ -142,6 +156,14 @@ class MetricKey(StrEnum):
|
|||||||
EVENTS_BY_LEVEL = "events_by_level"
|
EVENTS_BY_LEVEL = "events_by_level"
|
||||||
SCORE = "score"
|
SCORE = "score"
|
||||||
LEVEL = "level"
|
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):
|
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")
|
@router.get("/attendance-summary")
|
||||||
def attendance_summary(
|
def attendance_summary(
|
||||||
work_date: date | None = None,
|
work_date: date | None = None,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from app.modules.business.models import (
|
|||||||
AttendanceRecord,
|
AttendanceRecord,
|
||||||
Expense,
|
Expense,
|
||||||
FundAccount,
|
FundAccount,
|
||||||
|
PerformanceMetric,
|
||||||
Procurement,
|
Procurement,
|
||||||
Project,
|
Project,
|
||||||
ReportPushRun,
|
ReportPushRun,
|
||||||
@@ -55,13 +56,14 @@ from app.modules.reports.constants import (
|
|||||||
LifecycleResponseKey,
|
LifecycleResponseKey,
|
||||||
LifecycleSection,
|
LifecycleSection,
|
||||||
MetricKey,
|
MetricKey,
|
||||||
ReportResponseKey,
|
|
||||||
ReportPushStatus,
|
|
||||||
ReportErrorDetail,
|
ReportErrorDetail,
|
||||||
|
ReportPushStatus,
|
||||||
|
ReportResponseKey,
|
||||||
ReportStatus,
|
ReportStatus,
|
||||||
ReportText,
|
ReportText,
|
||||||
ReportTitle,
|
ReportTitle,
|
||||||
ReportType,
|
ReportType,
|
||||||
|
EnterpriseAnalyticsKey,
|
||||||
WorkReportMetricKey,
|
WorkReportMetricKey,
|
||||||
)
|
)
|
||||||
from app.modules.risk.constants import RiskSummaryKey, risk_level_for_score
|
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)
|
report[LifecycleResponseKey.AI_ANALYSIS] = self._lifecycle_ai_analysis(report, actor)
|
||||||
return report
|
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(
|
def _lifecycle_filters(
|
||||||
self,
|
self,
|
||||||
project_code: str | None,
|
project_code: str | None,
|
||||||
@@ -985,6 +1150,19 @@ class ReportService:
|
|||||||
response_payload=record_data,
|
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}
|
return {ReportResponseKey.REPORT: report, ReportResponseKey.DATA: record_data}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ from enum import StrEnum
|
|||||||
|
|
||||||
class WorkflowType(StrEnum):
|
class WorkflowType(StrEnum):
|
||||||
RISK_EVENT_REVIEW = "risk_event_review"
|
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):
|
class WorkflowStatus(StrEnum):
|
||||||
|
|||||||
25
app/tasks.py
25
app/tasks.py
@@ -1,4 +1,5 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
from socket import gethostname
|
||||||
|
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
|
||||||
@@ -40,6 +41,30 @@ def push_daily_brief(
|
|||||||
db.close()
|
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")
|
@celery_app.task(name="reports.push_project_weekly")
|
||||||
def push_project_weekly(
|
def push_project_weekly(
|
||||||
receive_id: str | None = None,
|
receive_id: str | None = None,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from app.core.database import Base, engine
|
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.audit.models import AuditLog
|
||||||
from app.modules.business.models import (
|
from app.modules.business.models import (
|
||||||
AttendanceRecord,
|
AttendanceRecord,
|
||||||
@@ -19,6 +20,7 @@ from app.modules.business.models import (
|
|||||||
)
|
)
|
||||||
from app.modules.feishu.models import FeishuEventReceipt
|
from app.modules.feishu.models import FeishuEventReceipt
|
||||||
from app.modules.events.models import DomainEvent
|
from app.modules.events.models import DomainEvent
|
||||||
|
from app.modules.observability.models import SystemHeartbeat
|
||||||
from app.modules.workflows.models import WorkflowAction, WorkflowInstance
|
from app.modules.workflows.models import WorkflowAction, WorkflowInstance
|
||||||
|
|
||||||
_MODELS = [
|
_MODELS = [
|
||||||
@@ -42,6 +44,8 @@ _MODELS = [
|
|||||||
DomainEvent,
|
DomainEvent,
|
||||||
WorkflowInstance,
|
WorkflowInstance,
|
||||||
WorkflowAction,
|
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()
|
||||||
@@ -81,6 +81,24 @@ services:
|
|||||||
migrate:
|
migrate:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
scheduler:
|
||||||
|
build: .
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-company_ai}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@db:5432/${POSTGRES_DB:-company_ai}
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
SCHEDULER_ENABLED: "true"
|
||||||
|
TASK_QUEUE_ENABLED: "true"
|
||||||
|
command: ["python", "-m", "app.tools.run_scheduler"]
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ from app.core.pagination import bounded_limit, bounded_offset
|
|||||||
from app.core.security import require_api_key, require_audit_api_key
|
from app.core.security import require_api_key, require_audit_api_key
|
||||||
from app.main import _allow_cors_credentials, app
|
from app.main import _allow_cors_credentials, app
|
||||||
from app.modules.audit.constants import AUDIT_REDACTED_VALUE
|
from app.modules.audit.constants import AUDIT_REDACTED_VALUE
|
||||||
|
from app.modules.ai_memory.constants import (
|
||||||
|
AIMemoryPayloadKey,
|
||||||
|
AIMemoryResponseKey,
|
||||||
|
AIMemoryStatus,
|
||||||
|
)
|
||||||
from app.modules.events.constants import (
|
from app.modules.events.constants import (
|
||||||
EventAggregateType,
|
EventAggregateType,
|
||||||
EventPayloadKey,
|
EventPayloadKey,
|
||||||
@@ -46,7 +51,13 @@ from app.modules.events.service import EventService
|
|||||||
from app.modules.business.registry import get_domain_model
|
from app.modules.business.registry import get_domain_model
|
||||||
from app.modules.business.service import _model_payload, serialize_model
|
from app.modules.business.service import _model_payload, serialize_model
|
||||||
from app.modules.legacy_mysql.service import LegacyMySQLService
|
from app.modules.legacy_mysql.service import LegacyMySQLService
|
||||||
|
from app.modules.observability.constants import (
|
||||||
|
HeartbeatComponent,
|
||||||
|
ObservabilityKey,
|
||||||
|
)
|
||||||
|
from app.modules.observability.service import ObservabilityService
|
||||||
from app.modules.reports.constants import (
|
from app.modules.reports.constants import (
|
||||||
|
EnterpriseAnalyticsKey,
|
||||||
LifecycleAttentionKey,
|
LifecycleAttentionKey,
|
||||||
LifecycleResponseKey,
|
LifecycleResponseKey,
|
||||||
LifecycleSection,
|
LifecycleSection,
|
||||||
@@ -168,6 +179,16 @@ def test_feishu_webhook_routes_message_event() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_v3_request_id_health_and_metrics() -> None:
|
def test_v3_request_id_health_and_metrics() -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
ObservabilityService(db).record_heartbeat(
|
||||||
|
component=HeartbeatComponent.WORKER,
|
||||||
|
instance_id="pytest-worker",
|
||||||
|
actor="pytest",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
response = client.get("/api/v1/health/live", headers={"X-Request-ID": "rid-v3-smoke"})
|
response = client.get("/api/v1/health/live", headers={"X-Request-ID": "rid-v3-smoke"})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers["X-Request-ID"] == "rid-v3-smoke"
|
assert response.headers["X-Request-ID"] == "rid-v3-smoke"
|
||||||
@@ -179,7 +200,9 @@ def test_v3_request_id_health_and_metrics() -> None:
|
|||||||
|
|
||||||
response = client.get("/api/v1/metrics", headers=headers)
|
response = client.get("/api/v1/metrics", headers=headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "metrics" in response.json()
|
metrics = response.json()["metrics"]
|
||||||
|
assert ObservabilityKey.EVENTS in metrics
|
||||||
|
assert ObservabilityKey.HEARTBEATS in metrics
|
||||||
|
|
||||||
|
|
||||||
def test_v3_event_idempotency_and_workflow_dispatch() -> None:
|
def test_v3_event_idempotency_and_workflow_dispatch() -> None:
|
||||||
@@ -228,6 +251,80 @@ def test_v3_event_idempotency_and_workflow_dispatch() -> None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_v3_event_retry_and_dispatch_pending_route() -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
event = EventService(db).emit(
|
||||||
|
event_type=EventType.REPORT_PUSH_FAILED,
|
||||||
|
source=EventSource.REPORTS,
|
||||||
|
aggregate_type=EventAggregateType.REPORT_PUSH_RUN,
|
||||||
|
aggregate_id="push-v3-retry",
|
||||||
|
actor="pytest",
|
||||||
|
payload={
|
||||||
|
EventPayloadKey.CODE: "push-v3-retry",
|
||||||
|
EventPayloadKey.STATUS: ReportPushStatus.FAILED,
|
||||||
|
},
|
||||||
|
idempotency_key="v3-report-push-retry",
|
||||||
|
)
|
||||||
|
event.status = EventStatus.FAILED
|
||||||
|
event.last_error = "transient"
|
||||||
|
db.commit()
|
||||||
|
event_id = event.event_id
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
retry_response = client.post(f"/api/v1/events/{event_id}/retry", headers=headers)
|
||||||
|
assert retry_response.status_code == 200
|
||||||
|
assert retry_response.json()["event"]["status"] == EventStatus.PENDING
|
||||||
|
|
||||||
|
dispatch_response = client.post("/api/v1/events/dispatch-pending", headers=headers)
|
||||||
|
assert dispatch_response.status_code == 200
|
||||||
|
dispatched = [
|
||||||
|
item for item in dispatch_response.json()["items"] if item["event_id"] == event_id
|
||||||
|
]
|
||||||
|
assert dispatched
|
||||||
|
assert dispatched[0]["status"] == EventStatus.PROCESSED
|
||||||
|
|
||||||
|
|
||||||
|
def test_v3_ai_memory_recall_and_auto_write() -> None:
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/ai/ask",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"prompt": "Summarize quarterly cash planning for project memory smoke",
|
||||||
|
"context": {
|
||||||
|
AIMemoryPayloadKey.SCOPE: "project",
|
||||||
|
AIMemoryPayloadKey.SUBJECT: "P-MEM-SMOKE",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data[AIResponseKey.PROVIDER] == AIProviderName.NOOP
|
||||||
|
assert data[AIResponseKey.RAW][AIResponseKey.MEMORY_WRITE][AIMemoryPayloadKey.STATUS] == (
|
||||||
|
AIMemoryStatus.ACTIVE
|
||||||
|
)
|
||||||
|
|
||||||
|
list_response = client.get(
|
||||||
|
"/api/v1/ai/memory?scope=project&subject=P-MEM-SMOKE",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert list_response.status_code == 200
|
||||||
|
assert list_response.json()[AIMemoryResponseKey.ITEMS]
|
||||||
|
|
||||||
|
recall_response = client.post(
|
||||||
|
"/api/v1/ai/memory/recall",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"query": "quarterly cash planning",
|
||||||
|
"scope": "project",
|
||||||
|
"subject": "P-MEM-SMOKE",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert recall_response.status_code == 200
|
||||||
|
assert recall_response.json()[AIMemoryResponseKey.ITEMS]
|
||||||
|
|
||||||
|
|
||||||
def test_v3_risk_action_routes_are_disabled_in_read_only_mode() -> None:
|
def test_v3_risk_action_routes_are_disabled_in_read_only_mode() -> None:
|
||||||
response = create_business_record(
|
response = create_business_record(
|
||||||
"risk-events",
|
"risk-events",
|
||||||
@@ -648,6 +745,43 @@ def test_project_lifecycle_report_summarizes_progress_cost_and_risk() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_v3_enterprise_analytics_returns_read_only_sections() -> None:
|
||||||
|
performance_response = create_business_record(
|
||||||
|
"performance-metrics",
|
||||||
|
{
|
||||||
|
"code": "PERF-V3-001",
|
||||||
|
"name": "V3 delivery score",
|
||||||
|
"weight": 20,
|
||||||
|
"auto_score": 82,
|
||||||
|
"confirmed_score": 78,
|
||||||
|
"status": StatusValue.REVIEWED,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert performance_response.status_code == 200
|
||||||
|
|
||||||
|
response = client.get("/api/v1/reports/enterprise-analytics", headers=headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data[EnterpriseAnalyticsKey.TITLE] == ReportTitle.ENTERPRISE_ANALYTICS
|
||||||
|
assert EnterpriseAnalyticsKey.FINANCE in data
|
||||||
|
assert EnterpriseAnalyticsKey.PROCUREMENT in data
|
||||||
|
assert EnterpriseAnalyticsKey.PERFORMANCE in data
|
||||||
|
assert EnterpriseAnalyticsKey.OPERATIONS in data
|
||||||
|
assert data[EnterpriseAnalyticsKey.PERFORMANCE][MetricKey.CONFIRMED] >= 1
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
workflow = db.execute(
|
||||||
|
select(WorkflowInstance).where(
|
||||||
|
WorkflowInstance.workflow_type == WorkflowType.ENTERPRISE_ANALYTICS,
|
||||||
|
WorkflowInstance.aggregate_id == data[EnterpriseAnalyticsKey.CODE],
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert workflow.status == WorkflowStatus.COMPLETED
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def test_work_report_counts_pending_approval_backlog_outside_period() -> None:
|
def test_work_report_counts_pending_approval_backlog_outside_period() -> None:
|
||||||
today = date.today()
|
today = date.today()
|
||||||
project_code = "P-BACKLOG-001"
|
project_code = "P-BACKLOG-001"
|
||||||
|
|||||||
Reference in New Issue
Block a user