feat: 添加AI记忆模块和事件调度系统

- 新增AI记忆模块,支持本地记忆召回和自动写入功能
- 实现事件调度系统,支持批量处理待定事件和重试机制
- 集成心跳监控机制,跟踪API、调度器和工作节点状态
- 扩展仪表板数据统计,包含AI记忆条目和心跳概要
- 添加企业运营分析报告功能,提供财务、采购等多维度分析
- 更新配置设置,增加事件调度和AI记忆相关参数
- 优化任务队列,添加事件分发任务类型
- 扩展审计日志,记录AI记忆操作和事件调度行为
- 实现领域事件模型,支持事件持久化和状态管理
- 添加观察性服务,监控系统组件健康状况
```
This commit is contained in:
2026-07-09 17:26:19 +08:00
parent 0a153b264a
commit 0cda45238a
33 changed files with 1591 additions and 34 deletions

View File

@@ -0,0 +1 @@

View 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

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

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

View 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

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