"""PMFlow OS AI answer feedback service.""" from __future__ import annotations from typing import Any, Optional from fastapi import HTTPException from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm.attributes import flag_modified from ..models import AICallLog, JudgeRun, OptimizerCandidate, PMFlowAIFeedbackEvent, PMFlowChangeSet, PMFlowThreadMessage from ..schemas.pmflow_os import PMFlowAIFeedbackCreateRequest, PMFlowAIFeedbackResponse from .pmflow_change_review_service import PMFlowChangeReviewService class PMFlowAIFeedbackService: def __init__(self, db: AsyncSession, *, tenant_id: int): self.db = db self.tenant_id = int(tenant_id) async def create_or_update( self, *, project_id: int, body: PMFlowAIFeedbackCreateRequest, user_id: int, ) -> PMFlowAIFeedbackResponse: target_type = body.target_type target_id = body.target_id.strip() message = await self._resolve_message(project_id=project_id, body=body) change_set = await self._resolve_change_set(project_id=project_id, body=body) if target_type == "thread_message" and message is None: raise HTTPException(status_code=404, detail="feedback_target_message_not_found") if target_type == "change_set" and change_set is None: raise HTTPException(status_code=404, detail="feedback_target_change_set_not_found") if target_type == "copilot_answer" and message is None and change_set is None: raise HTTPException(status_code=422, detail="feedback_target_anchor_required") prompt_meta = await self._prompt_meta(body=body, message=message, change_set=change_set) result = await self.db.execute( select(PMFlowAIFeedbackEvent).where( and_( PMFlowAIFeedbackEvent.tenant_id == self.tenant_id, PMFlowAIFeedbackEvent.project_id == int(project_id), PMFlowAIFeedbackEvent.actor_user_id == int(user_id), PMFlowAIFeedbackEvent.target_type == target_type, PMFlowAIFeedbackEvent.target_id == target_id, ) ) ) row = result.scalar_one_or_none() if row is None: row = PMFlowAIFeedbackEvent( tenant_id=self.tenant_id, project_id=int(project_id), actor_user_id=int(user_id), target_type=target_type, target_id=target_id, ) self.db.add(row) row.thread_id = self._int_or_none(body.thread_id) or (int(message.thread_id) if message is not None else None) or ( int(change_set.thread_id) if change_set is not None else None ) row.message_id = self._int_or_none(body.message_id) or (int(message.id) if message is not None else None) row.change_set_id = self._int_or_none(body.change_set_id) or (int(change_set.id) if change_set is not None else None) row.ai_call_log_id = prompt_meta.get("ai_call_log_id") row.answer_kind = body.answer_kind row.rating = body.rating row.reason_tags = [tag.strip()[:80] for tag in body.reason_tags if tag and tag.strip()][:12] row.comment = body.comment.strip() if body.comment else None row.scene_key = prompt_meta.get("scene_key") row.prompt_key = prompt_meta.get("prompt_key") row.prompt_release_id = prompt_meta.get("prompt_release_id") row.runtime_profile_id = prompt_meta.get("runtime_profile_id") row.model_provider = prompt_meta.get("model_provider") row.model_name = prompt_meta.get("model_name") current_metadata = row.feedback_metadata if isinstance(row.feedback_metadata, dict) else {} row.feedback_metadata = { **current_metadata, **(body.feedback_metadata or {}), "promptOptimizationUse": "quality_signal_for_scene_prompt_iteration", "source": "pmflow_os_command_center", } await self.db.flush() await self._ensure_prompt_optimization_candidate(row) await self.db.commit() await self.db.refresh(row) return self.serialize(row) async def list_project_feedback(self, *, project_id: int, user_id: Optional[int] = None) -> list[PMFlowAIFeedbackResponse]: clauses = [ PMFlowAIFeedbackEvent.tenant_id == self.tenant_id, PMFlowAIFeedbackEvent.project_id == int(project_id), ] if user_id is not None: clauses.append(PMFlowAIFeedbackEvent.actor_user_id == int(user_id)) result = await self.db.execute( select(PMFlowAIFeedbackEvent) .where(and_(*clauses)) .order_by(PMFlowAIFeedbackEvent.updated_at.desc(), PMFlowAIFeedbackEvent.id.desc()) ) return [self.serialize(row) for row in result.scalars().all()] async def _resolve_message( self, *, project_id: int, body: PMFlowAIFeedbackCreateRequest, ) -> Optional[PMFlowThreadMessage]: message_id = self._int_or_none(body.message_id) or ( self._int_or_none(body.target_id) if body.target_type == "thread_message" else None ) if message_id is None: return None result = await self.db.execute( select(PMFlowThreadMessage).where( and_( PMFlowThreadMessage.tenant_id == self.tenant_id, PMFlowThreadMessage.project_id == int(project_id), PMFlowThreadMessage.id == int(message_id), ) ) ) return result.scalar_one_or_none() async def _resolve_change_set( self, *, project_id: int, body: PMFlowAIFeedbackCreateRequest, ) -> Optional[PMFlowChangeSet]: change_set_id = self._int_or_none(body.change_set_id) or ( self._int_or_none(body.target_id) if body.target_type == "change_set" else None ) if change_set_id is None: return None result = await self.db.execute( select(PMFlowChangeSet).where( and_( PMFlowChangeSet.tenant_id == self.tenant_id, PMFlowChangeSet.project_id == int(project_id), PMFlowChangeSet.id == int(change_set_id), ) ) ) return result.scalar_one_or_none() async def _prompt_meta( self, *, body: PMFlowAIFeedbackCreateRequest, message: Optional[PMFlowThreadMessage], change_set: Optional[PMFlowChangeSet], ) -> dict[str, Any]: meta: dict[str, Any] = { "scene_key": body.scene_key, "prompt_key": body.prompt_key, "prompt_release_id": body.prompt_release_id, "runtime_profile_id": body.runtime_profile_id, "model_provider": body.model_provider, "model_name": body.model_name, "ai_call_log_id": self._int_or_none(body.ai_call_log_id), } if message is not None and isinstance(message.message_metadata, dict): msg_meta = message.message_metadata meta["scene_key"] = meta["scene_key"] or msg_meta.get("scene_key") or msg_meta.get("scene") meta["prompt_key"] = meta["prompt_key"] or msg_meta.get("prompt_key") meta["prompt_release_id"] = meta["prompt_release_id"] or self._int_or_none(msg_meta.get("prompt_release_id")) meta["runtime_profile_id"] = meta["runtime_profile_id"] or msg_meta.get("runtime_profile_id") meta["model_provider"] = meta["model_provider"] or msg_meta.get("provider") meta["model_name"] = meta["model_name"] or msg_meta.get("model") routing_meta = change_set.routing_meta if change_set is not None and isinstance(change_set.routing_meta, dict) else {} if change_set is not None: meta["scene_key"] = meta["scene_key"] or PMFlowChangeReviewService.AI_EXTRACTION_SCENE_KEY meta["prompt_key"] = meta["prompt_key"] or PMFlowChangeReviewService.AI_EXTRACTION_PROMPT_KEY meta["prompt_release_id"] = meta["prompt_release_id"] or self._int_or_none(routing_meta.get("prompt_release_id")) meta["runtime_profile_id"] = ( meta["runtime_profile_id"] or routing_meta.get("function_runtime_profile_version") or routing_meta.get("runtime_profile_id") or routing_meta.get("profile_id") ) meta["model_provider"] = meta["model_provider"] or routing_meta.get("provider") meta["model_name"] = meta["model_name"] or routing_meta.get("model") meta["ai_call_log_id"] = meta["ai_call_log_id"] or self._int_or_none(getattr(change_set, "llm_call_id", None)) if meta["ai_call_log_id"] is None and change_set is not None and change_set.llm_call_id: trace_result = await self.db.execute( select(AICallLog) .where( and_( AICallLog.tenant_id == self.tenant_id, AICallLog.trace_id == str(change_set.llm_call_id), ) ) .order_by(AICallLog.created_at.desc(), AICallLog.id.desc()) ) ai_call = trace_result.scalars().first() if ai_call is not None: meta["ai_call_log_id"] = int(ai_call.id) meta["prompt_key"] = meta["prompt_key"] or ai_call.prompt_key meta["prompt_release_id"] = meta["prompt_release_id"] or ai_call.prompt_release_id meta["model_provider"] = meta["model_provider"] or ai_call.provider meta["model_name"] = meta["model_name"] or ai_call.model routing = ai_call.routing_meta if isinstance(ai_call.routing_meta, dict) else {} meta["runtime_profile_id"] = ( meta["runtime_profile_id"] or routing.get("function_runtime_profile_version") or routing.get("runtime_profile_id") ) return meta async def _ensure_prompt_optimization_candidate(self, row: PMFlowAIFeedbackEvent) -> None: metadata = dict(row.feedback_metadata or {}) prompt_optimization = dict(metadata.get("promptOptimization") or {}) candidate_id = self._int_or_none(prompt_optimization.get("candidateId")) if row.rating != "negative": if candidate_id is not None: candidate = ( await self.db.execute( select(OptimizerCandidate).where( and_( OptimizerCandidate.tenant_id == self.tenant_id, OptimizerCandidate.id == int(candidate_id), ) ) ) ).scalar_one_or_none() if candidate is not None and candidate.status == "draft": candidate.status = "superseded" candidate.notes = "用户将 PMFlow OS AI 回答反馈改为赞同,自动挂起此前负反馈生成的优化候选。" prompt_optimization["status"] = "superseded_by_positive_feedback" metadata["promptOptimization"] = prompt_optimization row.feedback_metadata = metadata flag_modified(row, "feedback_metadata") return if not row.prompt_key: return judge_run_id = self._int_or_none(prompt_optimization.get("judgeRunId")) findings = self._feedback_findings(row) suggestion = self._feedback_prompt_suggestion(row, findings=findings) evidence = { "feedback_event_id": int(row.id), "target_type": row.target_type, "target_id": row.target_id, "answer_kind": row.answer_kind, "rating": row.rating, "reason_tags": list(row.reason_tags or []), "comment": row.comment, "scene_key": row.scene_key, "prompt_release_id": row.prompt_release_id, "runtime_profile_id": row.runtime_profile_id, "ai_call_log_id": row.ai_call_log_id, "findings": findings, } judge: Optional[JudgeRun] = None if judge_run_id is not None: judge = ( await self.db.execute( select(JudgeRun).where( and_( JudgeRun.tenant_id == self.tenant_id, JudgeRun.id == int(judge_run_id), ) ) ) ).scalar_one_or_none() if judge is None: judge = JudgeRun( tenant_id=self.tenant_id, project_id=int(row.project_id), prompt_key=row.prompt_key, workflow_type=row.scene_key or row.answer_kind, asked_via="pmflow_os", judge_status="feedback_review", bucket_key=f"pmflow_ai_feedback:{row.prompt_key}:{row.answer_kind}", rule_score=0.2, overall_score=0.2, findings=findings, metadata_json=evidence, ) self.db.add(judge) await self.db.flush() else: judge.findings = findings judge.metadata_json = evidence flag_modified(judge, "findings") flag_modified(judge, "metadata_json") candidate: Optional[OptimizerCandidate] = None if candidate_id is not None: candidate = ( await self.db.execute( select(OptimizerCandidate).where( and_( OptimizerCandidate.tenant_id == self.tenant_id, OptimizerCandidate.id == int(candidate_id), ) ) ) ).scalar_one_or_none() if candidate is None: candidate = OptimizerCandidate( tenant_id=self.tenant_id, project_id=int(row.project_id), judge_run_id=int(judge.id), candidate_type="prompt_change", target_type="prompt_key", target_ref=row.prompt_key, status="draft", suggestion=suggestion, evidence=evidence, notes="PMFlow OS AI feedback 自动生成的 prompt 优化候选,需平台 AI Ops 人工复核后再创建 release。", ) self.db.add(candidate) await self.db.flush() else: candidate.suggestion = suggestion candidate.evidence = evidence flag_modified(candidate, "suggestion") flag_modified(candidate, "evidence") metadata["promptOptimization"] = { "status": "draft_optimizer_candidate", "judgeRunId": int(judge.id), "candidateId": int(candidate.id), "targetPromptKey": row.prompt_key, } row.feedback_metadata = metadata flag_modified(row, "feedback_metadata") @staticmethod def _feedback_findings(row: PMFlowAIFeedbackEvent) -> list[dict[str, Any]]: tags = [str(tag).strip() for tag in (row.reason_tags or []) if str(tag).strip()] if not tags and row.comment: tags = ["用户补充说明"] return [ { "code": PMFlowAIFeedbackService._reason_code(tag), "label": tag, "severity": "high" if tag in {"不准确", "不该生成这个结果"} else "medium", "message": f"用户对 {row.answer_kind} 反馈为不赞同:{tag}", } for tag in tags ] or [ { "code": "negative_without_reason", "label": "未填写原因", "severity": "medium", "message": f"用户对 {row.answer_kind} 反馈为不赞同,但未选择原因标签。", } ] @staticmethod def _feedback_prompt_suggestion(row: PMFlowAIFeedbackEvent, *, findings: list[dict[str, Any]]) -> dict[str, Any]: labels = [str(item.get("label") or "").strip() for item in findings if isinstance(item, dict)] actions: list[str] = [] if "不准确" in labels: actions.append("强化事实约束、证据引用和不确定性表达,避免把候选对象说成已确认结论。") if "漏掉重点" in labels: actions.append("在抽取 prompt 中补充关键对象覆盖检查,优先覆盖用户最新输入里的需求、风险、决策和任务。") if "不够可执行" in labels: actions.append("要求输出包含可审查对象、可执行下一步、边界条件和验收信号。") if "不符合项目上下文" in labels: actions.append("提高项目目标、当前阶段、已有 ChangeSet 与 Evidence 的上下文权重。") if "表达不清" in labels: actions.append("约束回答结构,先给结论,再给依据和待确认动作。") if "不该生成这个结果" in labels: actions.append("增加生成前 gate:证据不足时显式待补充,不创建候选对象。") if not actions: actions.append("复核负反馈样本,补充场景化约束和回归 golden case。") return { "summary": f"基于 PMFlow OS 用户负反馈优化 {row.prompt_key}", "reason_tags": labels, "recommended_actions": actions, "target_answer_kind": row.answer_kind, "scene_key": row.scene_key, } @staticmethod def _reason_code(label: str) -> str: mapping = { "不准确": "inaccurate", "漏掉重点": "missed_key_point", "不够可执行": "not_actionable", "不符合项目上下文": "context_mismatch", "表达不清": "unclear_expression", "不该生成这个结果": "should_not_generate", "用户补充说明": "user_comment", } return mapping.get(label, "negative_feedback") @staticmethod def _int_or_none(value: Any) -> Optional[int]: if value is None: return None try: text = str(value).strip() if not text: return None return int(text) except (TypeError, ValueError): return None @staticmethod def serialize(row: PMFlowAIFeedbackEvent) -> PMFlowAIFeedbackResponse: return PMFlowAIFeedbackResponse( id=str(row.id), projectId=str(row.project_id), threadId=str(row.thread_id) if row.thread_id is not None else None, messageId=str(row.message_id) if row.message_id is not None else None, changeSetId=str(row.change_set_id) if row.change_set_id is not None else None, aiCallLogId=str(row.ai_call_log_id) if row.ai_call_log_id is not None else None, targetType=row.target_type, targetId=row.target_id, answerKind=row.answer_kind, rating=row.rating, reasonTags=list(row.reason_tags or []), comment=row.comment, sceneKey=row.scene_key, promptKey=row.prompt_key, promptReleaseId=row.prompt_release_id, runtimeProfileId=row.runtime_profile_id, modelProvider=row.model_provider, modelName=row.model_name, metadata=dict(row.feedback_metadata or {}), createdAt=row.created_at.isoformat() if row.created_at else "", updatedAt=row.updated_at.isoformat() if row.updated_at else "", )