"""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()