```
feat: 添加AI记忆模块和事件调度系统 - 新增AI记忆模块,支持本地记忆召回和自动写入功能 - 实现事件调度系统,支持批量处理待定事件和重试机制 - 集成心跳监控机制,跟踪API、调度器和工作节点状态 - 扩展仪表板数据统计,包含AI记忆条目和心跳概要 - 添加企业运营分析报告功能,提供财务、采购等多维度分析 - 更新配置设置,增加事件调度和AI记忆相关参数 - 优化任务队列,添加事件分发任务类型 - 扩展审计日志,记录AI记忆操作和事件调度行为 - 实现领域事件模型,支持事件持久化和状态管理 - 添加观察性服务,监控系统组件健康状况 ```
This commit is contained in:
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]
|
||||
Reference in New Issue
Block a user