PMFlow/backend/app/services/ai_service.py

591 lines
21 KiB
Python
Raw Permalink 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.

"""
PMFlow AI 核心能力封装
技术栈:LangChain 0.3+、OpenAI SDK、Redis
- 封装 GPT-4o mini 调用(流式/非流式)
- Prompt 集中在 config/ai_prompts
- AI 调用日志写入 ai_call_logs
- Redis 结果缓存(1 小时 TTL)
- 指数退避重试
"""
import hashlib
import json
import os
import time
from datetime import datetime
from typing import Any, AsyncGenerator, Optional, Union
from sqlalchemy import and_, select
# LangChain & OpenAI
try:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
from langchain_core.language_models.chat_models import BaseChatModel
LANGCHAIN_AVAILABLE = True
except ImportError:
LANGCHAIN_AVAILABLE = False
BaseChatModel = object
# Redis
try:
import redis.asyncio as redis
REDIS_AVAILABLE = True
except ImportError:
REDIS_AVAILABLE = False
# Token 定价 (GPT-4o-mini, 2024)
INPUT_PRICE_PER_1K = 0.00015
OUTPUT_PRICE_PER_1K = 0.0006
CACHE_TTL_SECONDS = 3600 # 1 小时
PROMPT_PROFILE_CACHE_TTL_SECONDS = 60
from ..config.ai_prompts import build_default_role_prompt_bundle
from ..database import AsyncSessionLocal
from ..models import Integration
def _t2s(v: Union[str, int]) -> str:
"""统一转为 str"""
return str(v) if v is not None else ""
def _hash_input(fn: str, *args, **kwargs) -> str:
"""生成缓存 key"""
payload = json.dumps({"fn": fn, "args": args, "kwargs": kwargs}, sort_keys=True, default=str)
return hashlib.sha256(payload.encode()).hexdigest()
def _estimate_cost(prompt_tokens: int, completion_tokens: int) -> float:
return (prompt_tokens * INPUT_PRICE_PER_1K + completion_tokens * OUTPUT_PRICE_PER_1K) / 1000
def _tenant_to_int(tenant_id: Union[str, int]) -> int:
try:
return int(tenant_id)
except (TypeError, ValueError):
return 1
class AIService:
"""PMFlow AI 服务"""
def __init__(
self,
api_key: Optional[str] = None,
model: str = "gpt-4o-mini",
redis_url: Optional[str] = None,
max_retries: int = 3,
):
self.api_key = api_key or os.getenv("OPENAI_API_KEY", "")
self.model = model
self.redis_url = redis_url or os.getenv("REDIS_URL")
self.max_retries = max_retries
self._llm: Optional[BaseChatModel] = None
self._redis: Optional[Any] = None
self._prompt_profile_cache: dict[int, tuple[float, tuple[str, str]]] = {}
def _get_llm(self) -> BaseChatModel:
if not LANGCHAIN_AVAILABLE:
raise RuntimeError("请安装 langchain-openai: pip install langchain-openai langchain-core")
if not self.api_key:
raise RuntimeError("请设置 OPENAI_API_KEY 环境变量")
if self._llm is None:
self._llm = ChatOpenAI(
model=self.model,
api_key=self.api_key,
temperature=0.3,
)
return self._llm
async def _get_redis(self):
if not REDIS_AVAILABLE or not self.redis_url:
return None
if self._redis is None:
self._redis = redis.from_url(self.redis_url, decode_responses=True)
return self._redis
async def _cache_get(self, key: str) -> Optional[str]:
r = await self._get_redis()
if r is None:
return None
try:
return await r.get(key)
except Exception:
return None
async def _cache_set(self, key: str, value: str, ttl: int = CACHE_TTL_SECONDS):
r = await self._get_redis()
if r is None:
return
try:
await r.setex(key, ttl, value)
except Exception:
pass
async def _resolve_active_system_prompt(
self,
*,
tenant_id: Union[str, int],
db=None,
) -> tuple[str, str]:
tenant_int = _tenant_to_int(tenant_id)
now = time.time()
cached = self._prompt_profile_cache.get(tenant_int)
if cached and cached[0] > now:
return cached[1]
role_name = ""
system_prompt = ""
try:
async def _load_profile(session) -> tuple[str, str]:
row = (
await session.execute(
select(Integration).where(
and_(
Integration.tenant_id == int(tenant_int),
Integration.type == "ai_prompt_profiles",
)
)
)
).scalar_one_or_none()
bundle = row.config if row is not None and isinstance(row.config, dict) else {}
active_key = str(bundle.get("active_role_key") or "").strip()
roles = bundle.get("roles") if isinstance(bundle.get("roles"), list) else []
selected = None
for item in roles:
if not isinstance(item, dict):
continue
if str(item.get("role_key") or "").strip() == active_key:
selected = item
break
if selected is None:
for item in roles:
if isinstance(item, dict) and bool(item.get("enabled", True)):
selected = item
break
if not isinstance(selected, dict):
return ("", "")
return (
str(selected.get("role_name") or "").strip(),
str(selected.get("system_prompt") or "").strip(),
)
if db is not None:
role_name, system_prompt = await _load_profile(db)
else:
async with AsyncSessionLocal() as session:
role_name, system_prompt = await _load_profile(session)
except Exception:
role_name = ""
system_prompt = ""
if len(system_prompt) < 20:
fallback = build_default_role_prompt_bundle()
active_key = str(fallback.get("active_role_key") or "").strip()
roles = fallback.get("roles") if isinstance(fallback.get("roles"), list) else []
selected = None
for row in roles:
if not isinstance(row, dict):
continue
if str(row.get("role_key") or "").strip() == active_key:
selected = row
break
if selected is None and roles and isinstance(roles[0], dict):
selected = roles[0]
if isinstance(selected, dict):
role_name = str(selected.get("role_name") or role_name or "默认角色").strip()
system_prompt = str(selected.get("system_prompt") or "").strip()
result = (role_name, system_prompt)
self._prompt_profile_cache[tenant_int] = (now + PROMPT_PROFILE_CACHE_TTL_SECONDS, result)
return result
async def _log_call(
self,
db,
tenant_id: str,
function_name: str,
input_hash: str,
status: str,
prompt_tokens: int = 0,
completion_tokens: int = 0,
latency_ms: Optional[int] = None,
error_message: Optional[str] = None,
):
try:
from ..models import AICallLog
total = prompt_tokens + completion_tokens
cost = _estimate_cost(prompt_tokens, completion_tokens)
log = AICallLog(
tenant_id=_t2s(tenant_id),
function_name=function_name,
input_hash=input_hash,
status=status,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total,
cost_usd=cost,
latency_ms=latency_ms,
error_message=error_message,
)
db.add(log)
await db.commit()
except Exception:
pass
async def _invoke_with_retry(
self,
prompt: str,
db=None,
tenant_id: str = "default",
function_name: str = "unknown",
use_cache: bool = True,
cache_key: Optional[str] = None,
project_id: Optional[int] = None,
) -> str:
"""带缓存、重试、日志的 LLM 调用"""
input_hash = cache_key or _hash_input(function_name, prompt)
if use_cache and db is None:
cached = await self._cache_get(input_hash)
if cached:
return cached
# 预算前置检查(仅在有 db 会话时启用)
if db is not None:
try:
from .budget_enforcement_service import BudgetEnforcementService
tenant_int = _tenant_to_int(tenant_id)
# 预估:按 prompt 长度粗略估算,保守给定 completion 预算
estimated_prompt_tokens = max(len(prompt) // 4, 1)
estimated_completion_tokens = 1200
estimated_cost = _estimate_cost(estimated_prompt_tokens, estimated_completion_tokens)
budget_service = BudgetEnforcementService(db)
budget_check = await budget_service.check_budget_before_ai_call(
tenant_id=tenant_int,
estimated_cost_usd=estimated_cost,
project_id=project_id,
)
if not budget_check.get("allowed", True):
raise RuntimeError("预算超限,AI 调用被阻止(hard limit)")
except RuntimeError:
raise
except Exception:
# 预算系统异常不阻断主流程,避免影响现有可用性
pass
llm = self._get_llm()
_, system_prompt = await self._resolve_active_system_prompt(
tenant_id=tenant_id,
db=db,
)
last_err = None
for attempt in range(self.max_retries):
try:
t0 = time.perf_counter()
messages = [HumanMessage(content=prompt)]
if system_prompt:
messages = [SystemMessage(content=system_prompt), *messages]
resp = await llm.ainvoke(messages)
latency_ms = int((time.perf_counter() - t0) * 1000)
text = resp.content if hasattr(resp, "content") else str(resp)
pt = getattr(resp, "response_metadata", None) or {}
usage = (pt or {}).get("usage", {}) or (getattr(resp, "response_metadata", None) or {}).get("usage", {})
prompt_tokens = usage.get("input_tokens", 0) or usage.get("prompt_tokens", 0)
completion_tokens = usage.get("output_tokens", 0) or usage.get("completion_tokens", 0)
if db:
await self._log_call(
db, tenant_id, function_name, input_hash, "success",
prompt_tokens, completion_tokens, latency_ms,
)
# 按实际 token 成本累计预算
try:
from .budget_enforcement_service import BudgetEnforcementService
actual_cost = _estimate_cost(prompt_tokens, completion_tokens)
await BudgetEnforcementService(db).update_budget_usage_after_ai_call(
tenant_id=_tenant_to_int(tenant_id),
actual_cost_usd=actual_cost,
project_id=project_id,
)
except Exception:
pass
if use_cache:
await self._cache_set(input_hash, text)
return text
except Exception as e:
last_err = e
wait = 2 ** attempt
time.sleep(wait)
if db:
await self._log_call(
db, tenant_id, function_name, input_hash, "failed",
error_message=str(last_err),
)
raise last_err
def _parse_json(self, text: str) -> dict:
text = text.strip()
for p in ("```json", "```"):
if text.startswith(p):
text = text[len(p):]
if text.endswith("```"):
text = text[:-3]
return json.loads(text.strip())
# ========================================================================
# 核心函数
# ========================================================================
async def generate_charter_from_signal(
self,
signal_content: str,
tenant_id: Union[str, int] = "default",
db=None,
) -> dict:
"""从市场/模型信号生成需求宪章草案"""
from ..config.ai_prompts import PROMPT_GENERATE_CHARTER
tid = _t2s(tenant_id)
prompt = PROMPT_GENERATE_CHARTER.format(signal_content=signal_content)
text = await self._invoke_with_retry(
prompt, db=db, tenant_id=tid, function_name="generate_charter_from_signal",
)
return self._parse_json(text)
async def decompose_tasks_from_charter(
self,
charter_content: str,
project_id: Union[str, int],
tenant_id: Union[str, int] = "default",
db=None,
) -> dict:
"""从宪章拆解任务,自动识别所需技能标签,返回带技能要求的任务列表"""
from ..config.ai_prompts import PROMPT_DECOMPOSE_TASKS
tid = _t2s(tenant_id)
prompt = PROMPT_DECOMPOSE_TASKS.format(
charter_content=charter_content,
project_id=_t2s(project_id),
)
text = await self._invoke_with_retry(
prompt, db=db, tenant_id=tid, function_name="decompose_tasks_from_charter",
)
return self._parse_json(text)
async def recommend_assignees_for_task(
self,
task_content: str,
required_skill_tag_ids: list,
project_id: Union[str, int],
tenant_id: Union[str, int] = "default",
db=None,
candidates_json: Optional[str] = None,
) -> dict:
"""基于人员能力图谱、负载、熟练度,推荐 Top3 人员"""
from ..config.ai_prompts import PROMPT_RECOMMEND_ASSIGNEES
tid = _t2s(tenant_id)
candidates = candidates_json or "[]"
prompt = PROMPT_RECOMMEND_ASSIGNEES.format(
task_content=task_content,
required_skill_tag_ids=json.dumps(required_skill_tag_ids),
candidates_json=candidates,
)
text = await self._invoke_with_retry(
prompt, db=db, tenant_id=tid, function_name="recommend_assignees_for_task",
)
return self._parse_json(text)
async def update_user_skill_scores(
self,
task_id: Union[str, int],
tenant_id: Union[str, int] = "default",
db=None,
quality_score: float = 0.0,
efficiency_score: float = 0.0,
assignee_ids: Optional[list] = None,
skill_tag_ids: Optional[list] = None,
) -> dict:
"""任务完成后,根据质量/效率评分更新相关人员技能分"""
from sqlalchemy import select
from ..models import UserSkillBinding
from uuid import UUID
def to_uuid(v):
if v is None:
return None
if isinstance(v, UUID):
return v
s = str(v)
try:
return UUID(s) if len(s) == 36 else None
except ValueError:
return None
if db is None:
return {"updated_count": 0, "updates": []}
tid = _t2s(tenant_id)
updates = []
if assignee_ids and skill_tag_ids:
for uid in assignee_ids:
for sid in skill_tag_ids:
uid_u = to_uuid(uid)
sid_u = to_uuid(sid)
if not uid_u or not sid_u:
continue
try:
r = await db.execute(
select(UserSkillBinding).where(
UserSkillBinding.tenant_id == tid,
UserSkillBinding.user_id == uid_u,
UserSkillBinding.skill_tag_id == sid_u,
)
)
b = r.scalar_one_or_none()
if b:
b.quality_score = min(100, max(0, (b.quality_score or 0) + quality_score))
b.efficiency_score = min(100, max(0, (b.efficiency_score or 0) + efficiency_score))
b.last_used_at = datetime.utcnow()
await db.commit()
updates.append({
"user_id": str(uid),
"skill_tag_id": str(sid),
"quality_score_delta": quality_score,
"efficiency_score_delta": efficiency_score,
"last_used_at": b.last_used_at.isoformat(),
})
except Exception:
pass
return {"updated_count": len(updates), "updates": updates}
async def calculate_alignment_score(
self,
user_id: Union[str, int],
truth_node_id: Union[str, int],
tenant_id: Union[str, int] = "default",
db=None,
truth_content: str = "",
user_context: str = "",
) -> dict:
"""计算用户对真理节点的认知对齐度(0-100)"""
from ..config.ai_prompts import PROMPT_ALIGNMENT_SCORE
from sqlalchemy import select
from ..models import TruthNode
tid = _t2s(tenant_id)
if db and not truth_content:
r = await db.execute(
select(TruthNode).where(
TruthNode.id == truth_node_id,
TruthNode.tenant_id == tid,
)
)
tn = r.scalar_one_or_none()
if tn:
truth_content = tn.content or tn.title or ""
prompt = PROMPT_ALIGNMENT_SCORE.format(
user_id=_t2s(user_id),
truth_node_id=_t2s(truth_node_id),
truth_content=truth_content or "(无内容)",
user_context=user_context or "(无)",
)
text = await self._invoke_with_retry(
prompt, db=db, tenant_id=tid, function_name="calculate_alignment_score",
)
data = self._parse_json(text)
data["user_id"] = _t2s(user_id)
data["truth_node_id"] = _t2s(truth_node_id)
return data
async def detect_cognitive_conflicts(
self,
truth_node_id: Union[str, int],
tenant_id: Union[str, int] = "default",
db=None,
truth_content: str = "",
user_viewpoints: str = "",
) -> dict:
"""识别真理节点下的隐性认知冲突"""
from ..config.ai_prompts import PROMPT_COGNITIVE_CONFLICTS
from sqlalchemy import select
from ..models import TruthNode
tid = _t2s(tenant_id)
if db and not truth_content:
r = await db.execute(
select(TruthNode).where(
TruthNode.id == truth_node_id,
TruthNode.tenant_id == tid,
)
)
tn = r.scalar_one_or_none()
if tn:
truth_content = tn.content or tn.title or ""
prompt = PROMPT_COGNITIVE_CONFLICTS.format(
truth_node_id=_t2s(truth_node_id),
truth_content=truth_content or "(无内容)",
user_viewpoints=user_viewpoints or "(无)",
)
text = await self._invoke_with_retry(
prompt, db=db, tenant_id=tid, function_name="detect_cognitive_conflicts",
)
data = self._parse_json(text)
data["truth_node_id"] = _t2s(truth_node_id)
data["total_count"] = len(data.get("conflicts", []))
return data
async def evaluate_model_requirement(
self,
charter_content: str,
model_version_id: Union[str, int],
tenant_id: Union[str, int] = "default",
db=None,
model_info: str = "",
) -> dict:
"""评估需求与模型的匹配度"""
from ..config.ai_prompts import PROMPT_EVALUATE_MODEL_REQUIREMENT
tid = _t2s(tenant_id)
prompt = PROMPT_EVALUATE_MODEL_REQUIREMENT.format(
charter_content=charter_content,
model_version_id=_t2s(model_version_id),
model_info=model_info or "(未提供)",
)
text = await self._invoke_with_retry(
prompt, db=db, tenant_id=tid, function_name="evaluate_model_requirement",
)
return self._parse_json(text)
# ========================================================================
# 流式调用
# ========================================================================
async def stream_chat(self, prompt: str) -> AsyncGenerator[str, None]:
"""流式生成"""
llm = self._get_llm()
async for chunk in llm.astream([HumanMessage(content=prompt)]):
if hasattr(chunk, "content") and chunk.content:
yield chunk.content
# 单例
_ai_service: Optional[AIService] = None
def get_ai_service() -> AIService:
global _ai_service
if _ai_service is None:
_ai_service = AIService()
return _ai_service