PMFlow/backend/app/services/context_cache.py

1156 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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
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
@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": datetime.now(timezone.utc).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": datetime.now(timezone.utc).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": datetime.now(timezone.utc).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": datetime.now(timezone.utc).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": datetime.now(timezone.utc).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()