```
feat: 添加生命周期报告和AI规则管理功能 - 在Dockerfile中添加pillow依赖包用于图像处理 - 实现生命周期报告调度任务,支持日报和周报两种类型 - 新增TASK_RUN_LIFECYCLE任务常量和相关配置选项 - 扩展AI Agent服务以支持用户规则,并在分析时应用规则 - 添加AI用户规则创建、更新和查询接口 - 增加项目生命周期和财务需求分析技能 - 扩展现有模型以支持更完整的业务数据字段 - 实现飞书图片上传功能用于报告展示 ```
This commit is contained in:
@@ -18,6 +18,7 @@ class AIMemorySource(StrEnum):
|
||||
AUTO = "auto"
|
||||
HERMES = "hermes"
|
||||
API = "api"
|
||||
USER_RULE = "user_rule"
|
||||
|
||||
|
||||
class AIMemoryResponseKey(StrEnum):
|
||||
@@ -53,3 +54,5 @@ AI_MEMORY_CODE_PREFIX = "MEM"
|
||||
AI_MEMORY_MAX_CONTENT_LENGTH = 2000
|
||||
AI_MEMORY_MAX_SUMMARY_LENGTH = 500
|
||||
AI_MEMORY_MIN_AUTO_WRITE_LENGTH = 12
|
||||
AI_USER_RULE_MAX_PRIORITY = 100
|
||||
AI_USER_RULE_MIN_PRIORITY = 1
|
||||
|
||||
@@ -2,9 +2,13 @@ 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.core.security import ApiPrincipal, require_api_key, require_operations_enabled
|
||||
from app.modules.ai_memory.constants import AIMemoryResponseKey, AIMemoryStatus
|
||||
from app.modules.ai_memory.schemas import AIMemoryRecallRequest
|
||||
from app.modules.ai_memory.schemas import (
|
||||
AIMemoryRecallRequest,
|
||||
AIUserRuleCreate,
|
||||
AIUserRuleUpdate,
|
||||
)
|
||||
from app.modules.ai_memory.service import AIMemoryService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
@@ -42,3 +46,60 @@ def recall_memory(
|
||||
actor=principal.actor,
|
||||
)
|
||||
return {AIMemoryResponseKey.ITEMS: items}
|
||||
|
||||
|
||||
@router.get("/rules")
|
||||
def list_rules(
|
||||
scope: str | None = None,
|
||||
subject: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return {
|
||||
AIMemoryResponseKey.ITEMS: AIMemoryService(db).list_rules(
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
status_filter=status,
|
||||
limit=limit,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/rules")
|
||||
def create_rule(
|
||||
payload: AIUserRuleCreate,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return {
|
||||
AIMemoryResponseKey.DATA: AIMemoryService(db).create_rule(
|
||||
content=payload.content,
|
||||
scope=payload.scope,
|
||||
subject=payload.subject,
|
||||
priority=payload.priority,
|
||||
tags=payload.tags,
|
||||
actor=principal.actor,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/rules/{code}")
|
||||
def update_rule(
|
||||
code: str,
|
||||
payload: AIUserRuleUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return {
|
||||
AIMemoryResponseKey.DATA: AIMemoryService(db).update_rule(
|
||||
code=code,
|
||||
content=payload.content,
|
||||
priority=payload.priority,
|
||||
tags=payload.tags,
|
||||
enabled=payload.enabled,
|
||||
actor=principal.actor,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,3 +27,18 @@ class AIMemoryRead(BaseModel):
|
||||
expires_at: str | None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class AIUserRuleCreate(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=2000)
|
||||
scope: str = AIMemoryScope.GLOBAL
|
||||
subject: str = Field(default="company", min_length=1, max_length=128)
|
||||
priority: int = Field(default=50, ge=1, le=100)
|
||||
tags: list[str] = Field(default_factory=list, max_length=20)
|
||||
|
||||
|
||||
class AIUserRuleUpdate(BaseModel):
|
||||
content: str | None = Field(default=None, min_length=1, max_length=2000)
|
||||
priority: int | None = Field(default=None, ge=1, le=100)
|
||||
tags: list[str] | None = Field(default=None, max_length=20)
|
||||
enabled: bool | None = None
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -17,6 +19,8 @@ from app.modules.ai_memory.constants import (
|
||||
AIMemorySource,
|
||||
AIMemoryStatus,
|
||||
AIMemoryText,
|
||||
AI_USER_RULE_MAX_PRIORITY,
|
||||
AI_USER_RULE_MIN_PRIORITY,
|
||||
)
|
||||
from app.modules.ai_memory.models import AIMemoryEntry
|
||||
from app.modules.audit.constants import (
|
||||
@@ -164,6 +168,144 @@ class AIMemoryService:
|
||||
)
|
||||
return record
|
||||
|
||||
def list_rules(
|
||||
self,
|
||||
scope: str | None = None,
|
||||
subject: str | None = None,
|
||||
status_filter: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
stmt = (
|
||||
select(AIMemoryEntry)
|
||||
.where(AIMemoryEntry.source == AIMemorySource.USER_RULE)
|
||||
.order_by(AIMemoryEntry.importance.desc(), AIMemoryEntry.id.asc())
|
||||
.limit(bounded_limit(limit))
|
||||
)
|
||||
if scope:
|
||||
stmt = stmt.where(AIMemoryEntry.scope == scope)
|
||||
if subject:
|
||||
stmt = stmt.where(AIMemoryEntry.subject == subject)
|
||||
if status_filter:
|
||||
stmt = stmt.where(AIMemoryEntry.status == status_filter)
|
||||
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
||||
|
||||
def active_rules(
|
||||
self,
|
||||
scope: str = AIMemoryScope.GLOBAL,
|
||||
subject: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
stmt = (
|
||||
select(AIMemoryEntry)
|
||||
.where(
|
||||
AIMemoryEntry.source == AIMemorySource.USER_RULE,
|
||||
AIMemoryEntry.status == AIMemoryStatus.ACTIVE,
|
||||
AIMemoryEntry.scope.in_({AIMemoryScope.GLOBAL, scope}),
|
||||
)
|
||||
.order_by(AIMemoryEntry.importance.desc(), AIMemoryEntry.id.asc())
|
||||
.limit(min(bounded_limit(limit), 50))
|
||||
)
|
||||
if subject:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
AIMemoryEntry.scope == AIMemoryScope.GLOBAL,
|
||||
AIMemoryEntry.subject == subject,
|
||||
)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"code": item.code,
|
||||
"scope": item.scope,
|
||||
"subject": item.subject,
|
||||
"rule": item.content,
|
||||
"priority": item.importance,
|
||||
}
|
||||
for item in self.db.execute(stmt).scalars()
|
||||
]
|
||||
|
||||
def create_rule(
|
||||
self,
|
||||
content: str,
|
||||
scope: str,
|
||||
subject: str,
|
||||
priority: int,
|
||||
tags: list[str] | None,
|
||||
actor: str,
|
||||
) -> dict[str, Any]:
|
||||
self._validate_rule(content, priority)
|
||||
record = self._create_entry(
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
content=_truncate(content.strip(), AI_MEMORY_MAX_CONTENT_LENGTH),
|
||||
summary=_truncate(content.strip(), AI_MEMORY_MAX_SUMMARY_LENGTH),
|
||||
tags=["user-rule", *(tags or [])],
|
||||
source=AIMemorySource.USER_RULE,
|
||||
importance=priority,
|
||||
status_value=AIMemoryStatus.ACTIVE,
|
||||
actor=actor,
|
||||
audit_action=AuditAction.AI_RULE_CREATE,
|
||||
)
|
||||
return serialize_model(record)
|
||||
|
||||
def update_rule(
|
||||
self,
|
||||
code: str,
|
||||
content: str | None,
|
||||
priority: int | None,
|
||||
tags: list[str] | None,
|
||||
enabled: bool | None,
|
||||
actor: str,
|
||||
) -> dict[str, Any]:
|
||||
record = self.db.execute(
|
||||
select(AIMemoryEntry).where(
|
||||
AIMemoryEntry.code == code,
|
||||
AIMemoryEntry.source == AIMemorySource.USER_RULE,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AI rule not found")
|
||||
if content is not None:
|
||||
self._validate_rule(content, priority or record.importance)
|
||||
record.content = _truncate(content.strip(), AI_MEMORY_MAX_CONTENT_LENGTH)
|
||||
record.summary = _truncate(content.strip(), AI_MEMORY_MAX_SUMMARY_LENGTH)
|
||||
if priority is not None:
|
||||
self._validate_rule(record.content, priority)
|
||||
record.importance = priority
|
||||
if tags is not None:
|
||||
record.tags = ["user-rule", *tags]
|
||||
if enabled is not None:
|
||||
record.status = AIMemoryStatus.ACTIVE if enabled else AIMemoryStatus.ARCHIVED
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.AI_MEMORY,
|
||||
action=AuditAction.AI_RULE_UPDATE,
|
||||
target_type=AuditTargetType.AI_MEMORY,
|
||||
target_id=record.code,
|
||||
risk_level=AuditRiskLevel.MEDIUM,
|
||||
response_payload={
|
||||
AIMemoryPayloadKey.CODE: record.code,
|
||||
AIMemoryPayloadKey.STATUS: record.status,
|
||||
AIMemoryPayloadKey.IMPORTANCE: record.importance,
|
||||
},
|
||||
)
|
||||
)
|
||||
return serialize_model(record)
|
||||
|
||||
def _validate_rule(self, content: str, priority: int) -> None:
|
||||
if not AI_USER_RULE_MIN_PRIORITY <= priority <= AI_USER_RULE_MAX_PRIORITY:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="AI rule priority is out of range",
|
||||
)
|
||||
if _contains_forbidden_value(content, get_settings().ai_memory_forbidden_keys):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="AI rule contains secret-like content",
|
||||
)
|
||||
|
||||
def count_by_status(self) -> dict[str, int]:
|
||||
rows = self.db.execute(
|
||||
select(AIMemoryEntry.status, func.count()).group_by(AIMemoryEntry.status)
|
||||
@@ -181,9 +323,13 @@ class AIMemoryService:
|
||||
importance: int,
|
||||
status_value: str,
|
||||
actor: str,
|
||||
audit_action: str = AuditAction.AI_MEMORY_WRITE,
|
||||
) -> AIMemoryEntry:
|
||||
record = AIMemoryEntry(
|
||||
code=f"{AI_MEMORY_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}",
|
||||
code=(
|
||||
f"{AI_MEMORY_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}-"
|
||||
f"{uuid4().hex[:8]}"
|
||||
),
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
content=content,
|
||||
@@ -201,7 +347,7 @@ class AIMemoryService:
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.AI_MEMORY,
|
||||
action=AuditAction.AI_MEMORY_WRITE,
|
||||
action=audit_action,
|
||||
target_type=AuditTargetType.AI_MEMORY,
|
||||
target_id=record.code,
|
||||
risk_level=AuditRiskLevel.LOW,
|
||||
|
||||
Reference in New Issue
Block a user