1175 lines
44 KiB
Python
1175 lines
44 KiB
Python
"""AI Steward 上下文缓存服务(Redis)与失效钩子。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any, Optional
|
||
|
||
from sqlalchemy import and_, case, event, func, inspect, or_, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..models import Project, ProjectSignalLink, Signal, Task, User, UserProject, UserWorkload
|
||
|
||
from ..utils.timezone import now_cn_aware
|
||
try:
|
||
import redis.asyncio as redis_async
|
||
from redis import Redis as RedisSync
|
||
|
||
REDIS_AVAILABLE = True
|
||
except Exception: # pragma: no cover
|
||
REDIS_AVAILABLE = False
|
||
redis_async = None
|
||
RedisSync = None
|
||
|
||
|
||
SYSTEM_TTL_SECONDS = 30 * 60
|
||
USER_TTL_SECONDS = 5 * 60
|
||
SESSION_TTL_SECONDS = 60 * 60
|
||
PROJECT_LIST_TTL_SECONDS = 10 * 60
|
||
SIGNAL_PENDING_TTL_SECONDS = 5 * 60
|
||
SESSION_MAX_MESSAGES = 20
|
||
DONE_TASK_STATUSES = {"done", "completed", "closed", "resolved", "archived"}
|
||
PENDING_SIGNAL_STATUSES = {"pending", "todo", "open", "待处理"}
|
||
|
||
|
||
def _safe_int(value: Any, fallback: int = 0) -> int:
|
||
try:
|
||
return int(value)
|
||
except Exception:
|
||
return fallback
|
||
|
||
|
||
def _json_loads(raw: Optional[str], default: Any) -> Any:
|
||
if not raw:
|
||
return default
|
||
try:
|
||
return json.loads(raw)
|
||
except Exception:
|
||
return default
|
||
|
||
|
||
class ContextCacheService:
|
||
def __init__(self, redis_url: Optional[str] = None) -> None:
|
||
self.redis_url = redis_url or os.getenv("REDIS_URL")
|
||
self._redis: Optional[Any] = None
|
||
|
||
@staticmethod
|
||
def _system_key(tenant_id: int) -> str:
|
||
return f"pmflow:context:system:{tenant_id}"
|
||
|
||
@staticmethod
|
||
def _user_key(tenant_id: int, user_id: int) -> str:
|
||
return f"pmflow:context:user:{tenant_id}:{user_id}"
|
||
|
||
@staticmethod
|
||
def _session_key(tenant_id: int, session_id: int) -> str:
|
||
return f"pmflow:qa:session:{tenant_id}:{session_id}"
|
||
|
||
@staticmethod
|
||
def _project_list_key(tenant_id: int) -> str:
|
||
return f"pmflow:project:list:{tenant_id}"
|
||
|
||
@staticmethod
|
||
def _signal_pending_key(tenant_id: int) -> str:
|
||
return f"pmflow:signal:pending_count:{tenant_id}"
|
||
|
||
async def _get_redis(self):
|
||
if not REDIS_AVAILABLE or not self.redis_url:
|
||
return None
|
||
if self._redis is None:
|
||
self._redis = redis_async.from_url(self.redis_url, decode_responses=True)
|
||
return self._redis
|
||
|
||
async def _get_json(self, key: str) -> Optional[dict[str, Any]]:
|
||
redis = await self._get_redis()
|
||
if redis is None:
|
||
return None
|
||
try:
|
||
raw = await redis.get(key)
|
||
except Exception:
|
||
return None
|
||
if not raw:
|
||
return None
|
||
payload = _json_loads(raw, None)
|
||
return payload if isinstance(payload, dict) else None
|
||
|
||
async def _set_json(self, key: str, value: dict[str, Any], ttl: int) -> None:
|
||
redis = await self._get_redis()
|
||
if redis is None:
|
||
return
|
||
try:
|
||
await redis.setex(key, ttl, json.dumps(value, ensure_ascii=False))
|
||
except Exception:
|
||
pass
|
||
|
||
async def get_json_key(self, key: str) -> Optional[dict[str, Any]]:
|
||
return await self._get_json(key)
|
||
|
||
async def set_json_key(self, key: str, value: dict[str, Any], ttl: int) -> None:
|
||
await self._set_json(key, value, ttl)
|
||
|
||
async def delete_keys(self, *keys: str) -> None:
|
||
valid_keys = [str(key).strip() for key in keys if str(key).strip()]
|
||
if not valid_keys:
|
||
return
|
||
redis = await self._get_redis()
|
||
if redis is None:
|
||
return
|
||
try:
|
||
await redis.delete(*valid_keys)
|
||
except Exception:
|
||
pass
|
||
|
||
@staticmethod
|
||
def _is_system_snapshot_complete(payload: Optional[dict[str, Any]]) -> bool:
|
||
if not isinstance(payload, dict):
|
||
return False
|
||
return isinstance(payload.get("assistant_profile"), dict) and isinstance(payload.get("platform_profile"), dict)
|
||
|
||
async def get_system_snapshot(self, *, db: AsyncSession, tenant_id: int) -> dict[str, Any]:
|
||
tid = _safe_int(tenant_id, 1)
|
||
key = self._system_key(tid)
|
||
cached = await self._get_json(key)
|
||
if self._is_system_snapshot_complete(cached):
|
||
return cached
|
||
|
||
payload = await self._build_system_snapshot(db=db, tenant_id=tid)
|
||
await self._set_json(key, payload, SYSTEM_TTL_SECONDS)
|
||
return payload
|
||
|
||
async def get_user_snapshot(
|
||
self,
|
||
*,
|
||
db: AsyncSession,
|
||
tenant_id: int,
|
||
user_id: Optional[int],
|
||
) -> dict[str, Any]:
|
||
if user_id is None:
|
||
return {}
|
||
|
||
tid = _safe_int(tenant_id, 1)
|
||
uid = _safe_int(user_id, 0)
|
||
if uid <= 0:
|
||
return {}
|
||
|
||
key = self._user_key(tid, uid)
|
||
cached = await self._get_json(key)
|
||
if cached is not None:
|
||
return cached
|
||
|
||
payload = await self._build_user_snapshot(db=db, tenant_id=tid, user_id=uid)
|
||
await self._set_json(key, payload, USER_TTL_SECONDS)
|
||
return payload
|
||
|
||
async def _build_system_snapshot(self, *, db: AsyncSession, tenant_id: int) -> dict[str, Any]:
|
||
projects = await self._get_project_list_cached(db=db, tenant_id=tenant_id)
|
||
team = await self._build_team_members_snapshot(db=db, tenant_id=tenant_id)
|
||
pending = await self._get_pending_signal_cached(db=db, tenant_id=tenant_id)
|
||
assistant_profile = self._build_assistant_profile()
|
||
platform_profile = self._build_platform_profile()
|
||
return {
|
||
"tenant_id": tenant_id,
|
||
"assistant_profile": assistant_profile,
|
||
"platform_profile": platform_profile,
|
||
"projects": projects.get("items", []),
|
||
"team_members": team,
|
||
"pending_signals": pending,
|
||
"snapshot_at": now_cn_aware().isoformat(),
|
||
}
|
||
|
||
@staticmethod
|
||
def _build_assistant_profile() -> dict[str, Any]:
|
||
return {
|
||
"name": "PMFlow AI 管家",
|
||
"identity": "PMFlow 内置的项目管理与技术协同助手",
|
||
"responsibilities": [
|
||
"识别需求并路由到项目/信号",
|
||
"结合结构化数据与知识库回答问题",
|
||
"辅助任务拆解、分配与优先级判断",
|
||
"输出基于证据的系统状态与风险提示",
|
||
],
|
||
"principles": [
|
||
"只基于可验证上下文回答",
|
||
"证据不足时明确说明不足字段",
|
||
"优先使用系统快照中的实时结构化数据",
|
||
],
|
||
}
|
||
|
||
@staticmethod
|
||
def _count_python_files(target: Path) -> int:
|
||
if not target.exists() or not target.is_dir():
|
||
return 0
|
||
return sum(1 for p in target.rglob("*.py") if p.is_file())
|
||
|
||
@staticmethod
|
||
def _estimate_api_routes(api_dir: Path) -> int:
|
||
if not api_dir.exists() or not api_dir.is_dir():
|
||
return 0
|
||
markers = (
|
||
"@router.get(",
|
||
"@router.post(",
|
||
"@router.put(",
|
||
"@router.patch(",
|
||
"@router.delete(",
|
||
"@router.websocket(",
|
||
)
|
||
route_count = 0
|
||
for py_file in api_dir.rglob("*.py"):
|
||
if not py_file.is_file() or py_file.name.startswith("__"):
|
||
continue
|
||
try:
|
||
text = py_file.read_text(encoding="utf-8", errors="ignore")
|
||
except Exception:
|
||
continue
|
||
for marker in markers:
|
||
route_count += text.count(marker)
|
||
return route_count
|
||
|
||
@staticmethod
|
||
def _detect_database_stack() -> str:
|
||
url = str(os.getenv("DATABASE_URL") or "").lower()
|
||
if "postgresql" in url:
|
||
return "PostgreSQL (pgvector-ready)"
|
||
if "mysql" in url:
|
||
return "MySQL"
|
||
if "sqlite" in url:
|
||
return "SQLite"
|
||
return "Unknown"
|
||
|
||
@staticmethod
|
||
def _default_feature_catalog_items() -> list[dict[str, Any]]:
|
||
return [
|
||
{
|
||
"route": "/workloads",
|
||
"name": "团队负载",
|
||
"summary": "查看团队成员负载、瓶颈与调配建议。",
|
||
"page_component": "WorkloadsPage",
|
||
"page_file": "frontend/src/pages/WorkloadsPage.tsx",
|
||
"apis": ["/workloads/", "/workloads/ai-optimize", "/workloads/ai-optimize/apply"],
|
||
"aliases": ["workloads", "负载", "工作量", "capacity", "团队负载"],
|
||
},
|
||
{
|
||
"route": "/projects",
|
||
"name": "项目列表",
|
||
"summary": "管理项目清单、状态、入口跳转与总览信息。",
|
||
"page_component": "ProjectsPage",
|
||
"page_file": "frontend/src/pages/ProjectsPage.tsx",
|
||
"apis": ["/projects/", "/projects/?summary=1"],
|
||
"aliases": ["projects", "项目", "项目列表", "project list"],
|
||
},
|
||
{
|
||
"route": "/ai-steward",
|
||
"name": "AI 管家",
|
||
"summary": "AI 任务协同与治理中心,包含简报、冲突检测、问答历史。",
|
||
"page_component": "AIStewardPage",
|
||
"page_file": "frontend/src/pages/AIStewardPage.tsx",
|
||
"apis": ["/ai-steward/", "/ai-steward/rag/ask", "/rag/qa-sessions", "/rag/qa-records"],
|
||
"aliases": ["ai steward", "ai管家", "问答", "rag", "智能问答"],
|
||
},
|
||
{
|
||
"route": "/ai-config",
|
||
"name": "智能配置",
|
||
"summary": "配置飞书/企微/GitHub/知识库与模型参数,决定 AI 数据源与行为。",
|
||
"page_component": "AIConfigPage",
|
||
"page_file": "frontend/src/pages/AIConfigPage.tsx",
|
||
"apis": [
|
||
"/ai-steward/config/suggest",
|
||
"/feishu/config",
|
||
"/wecom/config",
|
||
"/rag/knowledge-base/docs",
|
||
"/rag/knowledge-base/upload",
|
||
"/rag/knowledge-base/urls",
|
||
],
|
||
"aliases": [
|
||
"智能配置",
|
||
"ai配置",
|
||
"配置中心",
|
||
"integration",
|
||
"集成配置",
|
||
"knowledge base",
|
||
],
|
||
},
|
||
{
|
||
"route": "/skills",
|
||
"name": "能力管理",
|
||
"summary": "管理能力标签、人员能力画像、任务匹配与推荐。",
|
||
"page_component": "SkillsPage",
|
||
"page_file": "frontend/src/pages/SkillsPage.tsx",
|
||
"apis": ["/skills/tags", "/skills/recommend", "/skills/users/{id}/bindings"],
|
||
"aliases": ["skills", "能力", "技能", "ability", "能力管理"],
|
||
},
|
||
{
|
||
"route": "/users",
|
||
"name": "用户管理",
|
||
"summary": "管理用户档案、角色、状态与权限入口。",
|
||
"page_component": "UsersPage",
|
||
"page_file": "frontend/src/pages/UsersPage.tsx",
|
||
"apis": ["/users/", "/users/{id}", "/users/bulk-delete", "/users/bulk-assign"],
|
||
"aliases": ["users", "成员管理", "用户", "人员管理"],
|
||
},
|
||
]
|
||
|
||
@staticmethod
|
||
def _extract_router_import_map(router_text: str) -> dict[str, str]:
|
||
mapping: dict[str, str] = {}
|
||
for component, page_rel in re.findall(
|
||
r'import\s+([A-Za-z_][A-Za-z0-9_]*)\s+from\s+"@/pages/([^"]+)";',
|
||
str(router_text or ""),
|
||
):
|
||
rel = str(page_rel).strip()
|
||
if not rel:
|
||
continue
|
||
if not rel.endswith(".tsx") and not rel.endswith(".ts"):
|
||
rel = f"{rel}.tsx"
|
||
mapping[str(component).strip()] = f"frontend/src/pages/{rel}"
|
||
return mapping
|
||
|
||
@staticmethod
|
||
def _extract_router_entries(router_text: str) -> list[tuple[str, str]]:
|
||
entries: list[tuple[str, str]] = []
|
||
for route, component in re.findall(
|
||
r'\{\s*path:\s*"([^"]+)"\s*,\s*element:\s*<([A-Za-z_][A-Za-z0-9_]*)\s*/?>\s*\}',
|
||
str(router_text or ""),
|
||
):
|
||
clean_route = str(route or "").strip()
|
||
if not clean_route:
|
||
continue
|
||
if not clean_route.startswith("/"):
|
||
clean_route = f"/{clean_route}"
|
||
entries.append((clean_route, str(component).strip()))
|
||
return entries
|
||
|
||
@staticmethod
|
||
def _extract_sidebar_labels(sidebar_text: str) -> dict[str, str]:
|
||
labels: dict[str, str] = {}
|
||
for route, label in re.findall(
|
||
r'\{\s*to:\s*"([^"]+)"\s*,\s*label:\s*"([^"]+)"',
|
||
str(sidebar_text or ""),
|
||
):
|
||
clean_route = str(route or "").strip()
|
||
if clean_route and not clean_route.startswith("/"):
|
||
clean_route = f"/{clean_route}"
|
||
if clean_route:
|
||
labels[clean_route] = str(label or "").strip()
|
||
return labels
|
||
|
||
@staticmethod
|
||
def _route_feature_summary(route: str) -> tuple[str, list[str], list[str]]:
|
||
route_map: dict[str, tuple[str, list[str], list[str]]] = {
|
||
"/workloads": (
|
||
"查看团队成员负载、瓶颈与调配建议。",
|
||
["/workloads/", "/workloads/ai-optimize", "/workloads/ai-optimize/apply"],
|
||
["workloads", "负载", "工作量", "capacity"],
|
||
),
|
||
"/projects": (
|
||
"管理项目清单、状态、入口跳转与总览信息。",
|
||
["/projects/", "/projects/?summary=1"],
|
||
["projects", "项目", "项目列表", "project list"],
|
||
),
|
||
"/ai-steward": (
|
||
"AI 任务协同与治理中心,包含简报、冲突检测、问答历史。",
|
||
["/ai-steward/", "/ai-steward/rag/ask", "/rag/qa-sessions", "/rag/qa-records"],
|
||
["ai steward", "ai管家", "问答", "rag", "智能问答"],
|
||
),
|
||
"/ai-config": (
|
||
"配置飞书/企微/GitHub/知识库与模型参数,决定 AI 数据源与行为。",
|
||
[
|
||
"/ai-steward/config/suggest",
|
||
"/feishu/config",
|
||
"/wecom/config",
|
||
"/rag/knowledge-base/docs",
|
||
"/rag/knowledge-base/upload",
|
||
"/rag/knowledge-base/urls",
|
||
],
|
||
["智能配置", "ai配置", "配置中心", "integration", "集成配置", "knowledge base"],
|
||
),
|
||
"/skills": (
|
||
"管理能力标签、人员能力画像、任务匹配与推荐。",
|
||
["/skills/tags", "/skills/recommend", "/skills/users/{id}/bindings"],
|
||
["skills", "能力", "技能", "ability", "能力管理"],
|
||
),
|
||
"/users": (
|
||
"管理用户档案、角色、状态与权限入口。",
|
||
["/users/", "/users/{id}", "/users/bulk-delete", "/users/bulk-assign"],
|
||
["users", "成员管理", "用户", "人员管理"],
|
||
),
|
||
}
|
||
if route in route_map:
|
||
return route_map[route]
|
||
return (
|
||
"系统功能页面入口。",
|
||
[],
|
||
[route.strip("/"), route.replace("/", "_").strip("_")],
|
||
)
|
||
|
||
@staticmethod
|
||
def _normalize_aliases(*groups: list[str]) -> list[str]:
|
||
merged: list[str] = []
|
||
for group in groups:
|
||
for item in group:
|
||
alias = str(item or "").strip().lower()
|
||
if not alias:
|
||
continue
|
||
if alias not in merged:
|
||
merged.append(alias)
|
||
return merged
|
||
|
||
def _build_feature_catalog(
|
||
self,
|
||
*,
|
||
backend_root: Path,
|
||
) -> dict[str, Any]:
|
||
workspace_root = backend_root.parent
|
||
frontend_src = workspace_root / "frontend" / "src"
|
||
router_file = frontend_src / "router" / "index.tsx"
|
||
sidebar_file = frontend_src / "components" / "layout" / "Sidebar.tsx"
|
||
|
||
if not frontend_src.exists():
|
||
fallback_items = self._default_feature_catalog_items()
|
||
return {
|
||
"items": fallback_items,
|
||
"count": len(fallback_items),
|
||
"source": "fallback",
|
||
}
|
||
|
||
router_text = ""
|
||
sidebar_text = ""
|
||
try:
|
||
router_text = router_file.read_text(encoding="utf-8", errors="ignore")
|
||
except Exception:
|
||
router_text = ""
|
||
try:
|
||
sidebar_text = sidebar_file.read_text(encoding="utf-8", errors="ignore")
|
||
except Exception:
|
||
sidebar_text = ""
|
||
|
||
import_map = self._extract_router_import_map(router_text)
|
||
router_entries = self._extract_router_entries(router_text)
|
||
sidebar_labels = self._extract_sidebar_labels(sidebar_text)
|
||
|
||
merged: dict[str, dict[str, Any]] = {}
|
||
for route, component in router_entries:
|
||
page_file = import_map.get(component) or ""
|
||
default_name = component.replace("Page", "")
|
||
name = sidebar_labels.get(route) or default_name
|
||
summary, apis, aliases = self._route_feature_summary(route)
|
||
route_aliases = [route.strip("/"), route.replace("/", "_").strip("_"), component]
|
||
merged[route] = {
|
||
"route": route,
|
||
"name": name,
|
||
"summary": summary,
|
||
"page_component": component,
|
||
"page_file": page_file,
|
||
"apis": apis,
|
||
"aliases": self._normalize_aliases(aliases, route_aliases, [name]),
|
||
}
|
||
|
||
for route, label in sidebar_labels.items():
|
||
if route not in merged:
|
||
summary, apis, aliases = self._route_feature_summary(route)
|
||
merged[route] = {
|
||
"route": route,
|
||
"name": label,
|
||
"summary": summary,
|
||
"page_component": "",
|
||
"page_file": "",
|
||
"apis": apis,
|
||
"aliases": self._normalize_aliases(aliases, [route.strip("/"), label]),
|
||
}
|
||
else:
|
||
merged[route]["name"] = label
|
||
merged[route]["aliases"] = self._normalize_aliases(
|
||
list(merged[route].get("aliases") or []),
|
||
[label],
|
||
)
|
||
|
||
items = sorted(merged.values(), key=lambda x: str(x.get("route") or ""))
|
||
if not items:
|
||
items = self._default_feature_catalog_items()
|
||
source = "fallback"
|
||
else:
|
||
source = "frontend_scan"
|
||
return {
|
||
"items": items,
|
||
"count": len(items),
|
||
"source": source,
|
||
}
|
||
|
||
def _build_platform_profile(self) -> dict[str, Any]:
|
||
backend_root = Path(__file__).resolve().parents[2]
|
||
workspace_root = backend_root.parent
|
||
app_root = backend_root / "app"
|
||
api_dir = app_root / "api"
|
||
services_dir = app_root / "services"
|
||
models_dir = app_root / "models"
|
||
schemas_dir = app_root / "schemas"
|
||
dependencies_dir = app_root / "dependencies"
|
||
core_dir = app_root / "core"
|
||
config_dir = app_root / "config"
|
||
|
||
module_counts = {
|
||
"api": self._count_python_files(api_dir),
|
||
"services": self._count_python_files(services_dir),
|
||
"models": self._count_python_files(models_dir),
|
||
"schemas": self._count_python_files(schemas_dir),
|
||
"dependencies": self._count_python_files(dependencies_dir),
|
||
"core": self._count_python_files(core_dir),
|
||
"config": self._count_python_files(config_dir),
|
||
}
|
||
app_python_files = self._count_python_files(app_root)
|
||
route_count = self._estimate_api_routes(api_dir)
|
||
running_in_docker = Path("/.dockerenv").exists()
|
||
redis_enabled = bool(str(os.getenv("REDIS_URL") or "").strip())
|
||
feature_catalog = self._build_feature_catalog(backend_root=backend_root)
|
||
frontend_src = workspace_root / "frontend" / "src"
|
||
frontend_ts_files = 0
|
||
if frontend_src.exists():
|
||
frontend_ts_files = sum(
|
||
1
|
||
for p in frontend_src.rglob("*")
|
||
if p.is_file() and p.suffix.lower() in {".ts", ".tsx", ".js", ".jsx"}
|
||
)
|
||
|
||
return {
|
||
"runtime": "docker" if running_in_docker else "local",
|
||
"backend": {
|
||
"framework": "FastAPI",
|
||
"orm": "SQLAlchemy 2.0",
|
||
"python": "3.11+",
|
||
"module_counts": module_counts,
|
||
"api_route_count_estimate": route_count,
|
||
},
|
||
"infrastructure": {
|
||
"database": self._detect_database_stack(),
|
||
"cache": "Redis" if redis_enabled else "Disabled/Unknown",
|
||
},
|
||
"frontend": {
|
||
"framework": "React 18 + TypeScript + Vite",
|
||
"source_root": str(frontend_src),
|
||
"route_count": int(feature_catalog.get("count") or 0),
|
||
"source_file_count": int(frontend_ts_files),
|
||
},
|
||
"feature_catalog": feature_catalog.get("items") if isinstance(feature_catalog.get("items"), list) else [],
|
||
"source_scan": {
|
||
"backend_root": str(backend_root),
|
||
"app_python_files": app_python_files,
|
||
"feature_catalog_source": str(feature_catalog.get("source") or "unknown"),
|
||
"scanned_at": now_cn_aware().isoformat(),
|
||
},
|
||
}
|
||
|
||
async def _get_project_list_cached(self, *, db: AsyncSession, tenant_id: int) -> dict[str, Any]:
|
||
key = self._project_list_key(tenant_id)
|
||
cached = await self._get_json(key)
|
||
if cached is not None:
|
||
return cached
|
||
|
||
project_rows = (
|
||
await db.execute(
|
||
select(
|
||
Project.id,
|
||
Project.name,
|
||
Project.title,
|
||
Project.status,
|
||
Project.owner_id,
|
||
User.name.label("owner_name"),
|
||
)
|
||
.outerjoin(
|
||
User,
|
||
and_(
|
||
User.tenant_id == tenant_id,
|
||
User.id == Project.owner_id,
|
||
),
|
||
)
|
||
.where(Project.tenant_id == tenant_id)
|
||
.order_by(Project.updated_at.desc(), Project.id.desc())
|
||
)
|
||
).all()
|
||
|
||
project_ids = [int(row.id) for row in project_rows]
|
||
signal_count_map: dict[int, int] = {}
|
||
if project_ids:
|
||
signal_rows = (
|
||
await db.execute(
|
||
select(ProjectSignalLink.project_id, func.count(ProjectSignalLink.id))
|
||
.where(
|
||
and_(
|
||
ProjectSignalLink.tenant_id == tenant_id,
|
||
ProjectSignalLink.project_id.in_(project_ids),
|
||
)
|
||
)
|
||
.group_by(ProjectSignalLink.project_id)
|
||
)
|
||
).all()
|
||
signal_count_map = {int(pid): int(cnt or 0) for pid, cnt in signal_rows}
|
||
|
||
task_count_map: dict[int, int] = {}
|
||
task_done_map: dict[int, int] = {}
|
||
if project_ids:
|
||
done_case = case((func.lower(Task.status).in_(DONE_TASK_STATUSES), 1), else_=0)
|
||
task_rows = (
|
||
await db.execute(
|
||
select(
|
||
Task.project_id,
|
||
func.count(Task.id).label("task_total"),
|
||
func.sum(done_case).label("done_count"),
|
||
)
|
||
.where(
|
||
and_(
|
||
Task.tenant_id == tenant_id,
|
||
Task.project_id.in_(project_ids),
|
||
)
|
||
)
|
||
.group_by(Task.project_id)
|
||
)
|
||
).all()
|
||
task_count_map = {int(pid): int(total or 0) for pid, total, _ in task_rows}
|
||
task_done_map = {int(pid): int(done or 0) for pid, _, done in task_rows}
|
||
|
||
items: list[dict[str, Any]] = []
|
||
for row in project_rows:
|
||
pid = int(row.id)
|
||
task_total = int(task_count_map.get(pid, 0))
|
||
done_total = int(task_done_map.get(pid, 0))
|
||
completion_rate = round((done_total / task_total) * 100, 2) if task_total > 0 else 0.0
|
||
title = str(row.title or row.name or f"Project#{pid}")
|
||
items.append(
|
||
{
|
||
"id": pid,
|
||
"name": title,
|
||
"status": str(row.status or "unknown"),
|
||
"current_stage": str(row.status or "unknown"),
|
||
"owner_id": _safe_int(row.owner_id, 0) or None,
|
||
"owner_name": str(row.owner_name or "未知"),
|
||
"signal_count": int(signal_count_map.get(pid, 0)),
|
||
"task_count": task_total,
|
||
"completion_rate": completion_rate,
|
||
}
|
||
)
|
||
|
||
payload = {
|
||
"items": items,
|
||
"count": len(items),
|
||
"snapshot_at": now_cn_aware().isoformat(),
|
||
}
|
||
await self._set_json(key, payload, PROJECT_LIST_TTL_SECONDS)
|
||
return payload
|
||
|
||
async def _build_team_members_snapshot(self, *, db: AsyncSession, tenant_id: int) -> list[dict[str, Any]]:
|
||
users = (
|
||
await db.execute(
|
||
select(User.id, User.name, User.role, User.workload).where(
|
||
and_(
|
||
User.tenant_id == tenant_id,
|
||
User.is_active.is_(True),
|
||
)
|
||
)
|
||
)
|
||
).all()
|
||
if not users:
|
||
return []
|
||
|
||
user_ids = [int(u.id) for u in users]
|
||
workload_rows = (
|
||
await db.execute(
|
||
select(UserWorkload.user_id, UserWorkload.workload_ratio).where(
|
||
and_(
|
||
UserWorkload.tenant_id == tenant_id,
|
||
UserWorkload.user_id.in_(user_ids),
|
||
)
|
||
)
|
||
)
|
||
).all()
|
||
workload_map = {int(uid): float(ratio or 0.0) for uid, ratio in workload_rows}
|
||
|
||
project_rows = (
|
||
await db.execute(
|
||
select(UserProject.user_id, UserProject.project_id).where(
|
||
and_(
|
||
UserProject.tenant_id == tenant_id,
|
||
UserProject.user_id.in_(user_ids),
|
||
)
|
||
)
|
||
)
|
||
).all()
|
||
projects_map: dict[int, list[int]] = {}
|
||
for uid, pid in project_rows:
|
||
projects_map.setdefault(int(uid), []).append(int(pid))
|
||
|
||
result: list[dict[str, Any]] = []
|
||
for row in users:
|
||
uid = int(row.id)
|
||
ratio = workload_map.get(uid)
|
||
if ratio is None:
|
||
ratio = round(float(row.workload or 0) / 100.0, 2)
|
||
result.append(
|
||
{
|
||
"id": uid,
|
||
"name": str(row.name or f"User#{uid}"),
|
||
"role": str(row.role or "unknown"),
|
||
"workload_ratio": float(ratio),
|
||
"assigned_projects": projects_map.get(uid, []),
|
||
}
|
||
)
|
||
return result
|
||
|
||
async def _get_pending_signal_cached(self, *, db: AsyncSession, tenant_id: int) -> dict[str, Any]:
|
||
key = self._signal_pending_key(tenant_id)
|
||
cached = await self._get_json(key)
|
||
if cached is not None:
|
||
return cached
|
||
|
||
pending_filter = or_(
|
||
Signal.processed.is_(False),
|
||
func.lower(Signal.status).in_(PENDING_SIGNAL_STATUSES),
|
||
)
|
||
p0_case = case(
|
||
(
|
||
func.lower(Signal.priority).in_({"critical", "p0", "urgent", "highest"}),
|
||
1,
|
||
),
|
||
else_=0,
|
||
)
|
||
p1_case = case((func.lower(Signal.priority).in_({"high", "p1"}), 1), else_=0)
|
||
p2_case = case((func.lower(Signal.priority).in_({"medium", "p2", "normal"}), 1), else_=0)
|
||
|
||
row = (
|
||
await db.execute(
|
||
select(
|
||
func.count(Signal.id),
|
||
func.sum(p0_case),
|
||
func.sum(p1_case),
|
||
func.sum(p2_case),
|
||
).where(
|
||
and_(
|
||
Signal.tenant_id == tenant_id,
|
||
pending_filter,
|
||
)
|
||
)
|
||
)
|
||
).first()
|
||
total, p0_count, p1_count, p2_count = row or (0, 0, 0, 0)
|
||
|
||
payload = {
|
||
"total": int(total or 0),
|
||
"p0_count": int(p0_count or 0),
|
||
"p1_count": int(p1_count or 0),
|
||
"p2_count": int(p2_count or 0),
|
||
"snapshot_at": now_cn_aware().isoformat(),
|
||
}
|
||
await self._set_json(key, payload, SIGNAL_PENDING_TTL_SECONDS)
|
||
return payload
|
||
|
||
async def _build_user_snapshot(
|
||
self,
|
||
*,
|
||
db: AsyncSession,
|
||
tenant_id: int,
|
||
user_id: int,
|
||
) -> dict[str, Any]:
|
||
owned_rows = (
|
||
await db.execute(
|
||
select(Project.id).where(
|
||
and_(
|
||
Project.tenant_id == tenant_id,
|
||
Project.owner_id == user_id,
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
member_rows = (
|
||
await db.execute(
|
||
select(UserProject.project_id).where(
|
||
and_(
|
||
UserProject.tenant_id == tenant_id,
|
||
UserProject.user_id == user_id,
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
project_ids = sorted({_safe_int(pid, 0) for pid in [*owned_rows, *member_rows] if _safe_int(pid, 0) > 0})
|
||
|
||
project_rows: list[Any] = []
|
||
if project_ids:
|
||
project_rows = (
|
||
await db.execute(
|
||
select(Project.id, Project.name, Project.title, Project.status).where(
|
||
and_(
|
||
Project.tenant_id == tenant_id,
|
||
Project.id.in_(project_ids),
|
||
)
|
||
)
|
||
)
|
||
).all()
|
||
|
||
open_tasks = (
|
||
await db.execute(
|
||
select(Task.id, Task.project_id, Task.title, Task.status, Task.due_date).where(
|
||
and_(
|
||
Task.tenant_id == tenant_id,
|
||
Task.assignee_id == user_id,
|
||
func.lower(Task.status).notin_(DONE_TASK_STATUSES),
|
||
)
|
||
)
|
||
)
|
||
).all()
|
||
|
||
pending_signals: list[dict[str, Any]] = []
|
||
if project_ids:
|
||
pending_rows = (
|
||
await db.execute(
|
||
select(Signal.id, Signal.title, Signal.priority, Signal.status, Signal.matched_project_id).where(
|
||
and_(
|
||
Signal.tenant_id == tenant_id,
|
||
Signal.matched_project_id.in_(project_ids),
|
||
or_(
|
||
Signal.processed.is_(False),
|
||
func.lower(Signal.status).in_(PENDING_SIGNAL_STATUSES),
|
||
),
|
||
)
|
||
).limit(60)
|
||
)
|
||
).all()
|
||
pending_signals = [
|
||
{
|
||
"id": int(row.id),
|
||
"title": str(row.title or ""),
|
||
"priority": str(row.priority or ""),
|
||
"status": str(row.status or ""),
|
||
"project_id": _safe_int(row.matched_project_id, 0) or None,
|
||
}
|
||
for row in pending_rows
|
||
]
|
||
|
||
workload_row = (
|
||
await db.execute(
|
||
select(UserWorkload.workload_ratio).where(
|
||
and_(
|
||
UserWorkload.tenant_id == tenant_id,
|
||
UserWorkload.user_id == user_id,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if workload_row is None:
|
||
fallback = (
|
||
await db.execute(
|
||
select(User.workload).where(
|
||
and_(
|
||
User.tenant_id == tenant_id,
|
||
User.id == user_id,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
workload_ratio = round(float(fallback or 0) / 100.0, 2)
|
||
else:
|
||
workload_ratio = float(workload_row or 0.0)
|
||
|
||
return {
|
||
"user_id": user_id,
|
||
"projects": [
|
||
{
|
||
"id": int(row.id),
|
||
"name": str(row.title or row.name or f"Project#{int(row.id)}"),
|
||
"status": str(row.status or "unknown"),
|
||
}
|
||
for row in project_rows
|
||
],
|
||
"assigned_tasks": [
|
||
{
|
||
"id": int(row.id),
|
||
"project_id": int(row.project_id),
|
||
"title": str(row.title or ""),
|
||
"status": str(row.status or ""),
|
||
"due_date": row.due_date.isoformat() if row.due_date else None,
|
||
}
|
||
for row in open_tasks
|
||
],
|
||
"pending_signals": pending_signals,
|
||
"workload_ratio": workload_ratio,
|
||
"snapshot_at": now_cn_aware().isoformat(),
|
||
}
|
||
|
||
async def get_session_messages(self, *, tenant_id: int, session_id: Optional[int]) -> list[dict[str, str]]:
|
||
if session_id is None:
|
||
return []
|
||
sid = _safe_int(session_id, 0)
|
||
if sid <= 0:
|
||
return []
|
||
|
||
redis = await self._get_redis()
|
||
if redis is None:
|
||
return []
|
||
key = self._session_key(_safe_int(tenant_id, 1), sid)
|
||
try:
|
||
raw_rows = await redis.lrange(key, -SESSION_MAX_MESSAGES, -1)
|
||
except Exception:
|
||
return []
|
||
messages: list[dict[str, str]] = []
|
||
for raw in raw_rows:
|
||
row = _json_loads(raw, None)
|
||
if not isinstance(row, dict):
|
||
continue
|
||
role = str(row.get("role") or "").strip().lower()
|
||
content = str(row.get("content") or "").strip()
|
||
if role in {"user", "assistant"} and content:
|
||
messages.append({"role": role, "content": content})
|
||
return messages
|
||
|
||
async def append_session_messages(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
session_id: Optional[int],
|
||
question: str,
|
||
answer: str,
|
||
) -> None:
|
||
if session_id is None:
|
||
return
|
||
sid = _safe_int(session_id, 0)
|
||
if sid <= 0:
|
||
return
|
||
|
||
redis = await self._get_redis()
|
||
if redis is None:
|
||
return
|
||
|
||
key = self._session_key(_safe_int(tenant_id, 1), sid)
|
||
user_payload = json.dumps({"role": "user", "content": str(question or "")[:3000]}, ensure_ascii=False)
|
||
assistant_payload = json.dumps({"role": "assistant", "content": str(answer or "")[:4000]}, ensure_ascii=False)
|
||
try:
|
||
pipe = redis.pipeline()
|
||
pipe.rpush(key, user_payload, assistant_payload)
|
||
pipe.ltrim(key, -SESSION_MAX_MESSAGES, -1)
|
||
pipe.expire(key, SESSION_TTL_SECONDS)
|
||
await pipe.execute()
|
||
except Exception:
|
||
return
|
||
|
||
async def invalidate_system_context(self, *, tenant_id: int) -> None:
|
||
redis = await self._get_redis()
|
||
if redis is None:
|
||
return
|
||
tid = _safe_int(tenant_id, 1)
|
||
keys = [
|
||
self._system_key(tid),
|
||
self._project_list_key(tid),
|
||
self._signal_pending_key(tid),
|
||
]
|
||
try:
|
||
await redis.delete(*keys)
|
||
except Exception:
|
||
pass
|
||
|
||
async def invalidate_user_context(self, *, tenant_id: int, user_id: int) -> None:
|
||
redis = await self._get_redis()
|
||
if redis is None:
|
||
return
|
||
uid = _safe_int(user_id, 0)
|
||
if uid <= 0:
|
||
return
|
||
try:
|
||
await redis.delete(self._user_key(_safe_int(tenant_id, 1), uid))
|
||
except Exception:
|
||
pass
|
||
|
||
@staticmethod
|
||
def messages_to_turns(messages: list[dict[str, str]]) -> list[dict[str, str]]:
|
||
turns: list[dict[str, str]] = []
|
||
pending_question: Optional[str] = None
|
||
for row in messages:
|
||
role = str(row.get("role") or "").lower()
|
||
content = str(row.get("content") or "")
|
||
if role == "user":
|
||
if pending_question:
|
||
turns.append({"question": pending_question, "answer": ""})
|
||
pending_question = content
|
||
continue
|
||
if role == "assistant":
|
||
if pending_question is None:
|
||
continue
|
||
turns.append({"question": pending_question, "answer": content})
|
||
pending_question = None
|
||
if pending_question:
|
||
turns.append({"question": pending_question, "answer": ""})
|
||
return turns
|
||
|
||
|
||
class ContextCacheInvalidator:
|
||
def __init__(self, redis_url: Optional[str] = None) -> None:
|
||
self.redis_url = redis_url or os.getenv("REDIS_URL")
|
||
self._redis: Optional[Any] = None
|
||
|
||
def _get_redis(self):
|
||
if not REDIS_AVAILABLE or not self.redis_url or RedisSync is None:
|
||
return None
|
||
if self._redis is None:
|
||
self._redis = RedisSync.from_url(
|
||
self.redis_url,
|
||
decode_responses=True,
|
||
socket_connect_timeout=1,
|
||
socket_timeout=1,
|
||
)
|
||
return self._redis
|
||
|
||
@staticmethod
|
||
def _system_key(tenant_id: int) -> str:
|
||
return ContextCacheService._system_key(_safe_int(tenant_id, 1))
|
||
|
||
@staticmethod
|
||
def _project_list_key(tenant_id: int) -> str:
|
||
return ContextCacheService._project_list_key(_safe_int(tenant_id, 1))
|
||
|
||
@staticmethod
|
||
def _signal_pending_key(tenant_id: int) -> str:
|
||
return ContextCacheService._signal_pending_key(_safe_int(tenant_id, 1))
|
||
|
||
@staticmethod
|
||
def _user_key(tenant_id: int, user_id: int) -> str:
|
||
return ContextCacheService._user_key(_safe_int(tenant_id, 1), _safe_int(user_id, 0))
|
||
|
||
def invalidate(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
invalidate_system: bool,
|
||
invalidate_project_list: bool,
|
||
invalidate_signal_pending: bool,
|
||
user_ids: Optional[set[int]] = None,
|
||
) -> None:
|
||
redis = self._get_redis()
|
||
if redis is None:
|
||
return
|
||
|
||
tid = _safe_int(tenant_id, 1)
|
||
keys: list[str] = []
|
||
if invalidate_system:
|
||
keys.append(self._system_key(tid))
|
||
if invalidate_project_list:
|
||
keys.append(self._project_list_key(tid))
|
||
if invalidate_signal_pending:
|
||
keys.append(self._signal_pending_key(tid))
|
||
if user_ids:
|
||
for uid in user_ids:
|
||
if _safe_int(uid, 0) > 0:
|
||
keys.append(self._user_key(tid, uid))
|
||
if not keys:
|
||
return
|
||
try:
|
||
redis.delete(*keys)
|
||
except Exception:
|
||
return
|
||
|
||
|
||
def register_context_cache_invalidation_hooks() -> None:
|
||
if getattr(register_context_cache_invalidation_hooks, "_registered", False):
|
||
return
|
||
|
||
invalidator = ContextCacheInvalidator()
|
||
|
||
@event.listens_for(Session, "after_flush")
|
||
def _collect_cache_invalidation(session: Session, _flush_context) -> None: # pragma: no cover
|
||
payload = session.info.get("_pmflow_cache_invalidation")
|
||
if not isinstance(payload, dict):
|
||
payload = {
|
||
"tenant_ids": set(),
|
||
"invalidate_system": False,
|
||
"invalidate_project_list": False,
|
||
"invalidate_signal_pending": False,
|
||
"user_ids": set(),
|
||
}
|
||
session.info["_pmflow_cache_invalidation"] = payload
|
||
|
||
objects = set(session.new).union(session.dirty).union(session.deleted)
|
||
for obj in objects:
|
||
tenant_id = _safe_int(getattr(obj, "tenant_id", 0), 0)
|
||
if tenant_id > 0:
|
||
payload["tenant_ids"].add(tenant_id)
|
||
|
||
if isinstance(obj, Project):
|
||
payload["invalidate_system"] = True
|
||
payload["invalidate_project_list"] = True
|
||
continue
|
||
|
||
if isinstance(obj, ProjectSignalLink):
|
||
payload["invalidate_system"] = True
|
||
payload["invalidate_project_list"] = True
|
||
continue
|
||
|
||
if isinstance(obj, Signal):
|
||
payload["invalidate_system"] = True
|
||
payload["invalidate_signal_pending"] = True
|
||
continue
|
||
|
||
if isinstance(obj, Task):
|
||
payload["invalidate_system"] = True
|
||
state = inspect(obj)
|
||
assignee_hist = state.attrs.assignee_id.history if hasattr(state.attrs, "assignee_id") else None
|
||
if assignee_hist is not None:
|
||
for old_value in assignee_hist.deleted:
|
||
uid = _safe_int(old_value, 0)
|
||
if uid > 0:
|
||
payload["user_ids"].add(uid)
|
||
for new_value in assignee_hist.added:
|
||
uid = _safe_int(new_value, 0)
|
||
if uid > 0:
|
||
payload["user_ids"].add(uid)
|
||
uid = _safe_int(getattr(obj, "assignee_id", 0), 0)
|
||
if uid > 0:
|
||
payload["user_ids"].add(uid)
|
||
continue
|
||
|
||
if isinstance(obj, UserProject):
|
||
payload["invalidate_system"] = True
|
||
payload["invalidate_project_list"] = True
|
||
uid = _safe_int(getattr(obj, "user_id", 0), 0)
|
||
if uid > 0:
|
||
payload["user_ids"].add(uid)
|
||
continue
|
||
|
||
if isinstance(obj, User):
|
||
payload["invalidate_system"] = True
|
||
uid = _safe_int(getattr(obj, "id", 0), 0)
|
||
if uid > 0:
|
||
payload["user_ids"].add(uid)
|
||
continue
|
||
|
||
if isinstance(obj, UserWorkload):
|
||
payload["invalidate_system"] = True
|
||
uid = _safe_int(getattr(obj, "user_id", 0), 0)
|
||
if uid > 0:
|
||
payload["user_ids"].add(uid)
|
||
continue
|
||
|
||
@event.listens_for(Session, "after_commit")
|
||
def _flush_cache_invalidation(session: Session) -> None: # pragma: no cover
|
||
payload = session.info.pop("_pmflow_cache_invalidation", None)
|
||
if not isinstance(payload, dict):
|
||
return
|
||
tenant_ids = payload.get("tenant_ids") or set()
|
||
if not tenant_ids:
|
||
tenant_ids = {_safe_int(os.getenv("DEFAULT_TENANT_ID", "1"), 1)}
|
||
for tenant_id in tenant_ids:
|
||
invalidator.invalidate(
|
||
tenant_id=_safe_int(tenant_id, 1),
|
||
invalidate_system=bool(payload.get("invalidate_system")),
|
||
invalidate_project_list=bool(payload.get("invalidate_project_list")),
|
||
invalidate_signal_pending=bool(payload.get("invalidate_signal_pending")),
|
||
user_ids=set(payload.get("user_ids") or set()),
|
||
)
|
||
|
||
@event.listens_for(Session, "after_rollback")
|
||
def _clear_cache_invalidation(session: Session) -> None: # pragma: no cover
|
||
session.info.pop("_pmflow_cache_invalidation", None)
|
||
|
||
setattr(register_context_cache_invalidation_hooks, "_registered", True)
|
||
|
||
|
||
context_cache_service = ContextCacheService()
|