"""统一反馈中心业务服务。""" from __future__ import annotations from datetime import date, datetime, time, timedelta from typing import Any, Optional from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from ...models import FeedbackCenterRecord, FeedbackCenterRecordRevision from ...utils.timezone import now_cn_naive from .mappers import ( map_qa_feedback_to_record_payload, map_signal_feedback_to_record_payload, map_task_breakdown_to_record_payload, map_task_override_to_record_payloads, ) from .reason_catalog import build_reason_candidates, list_reason_catalog SOURCE_GROUP_VALUES = {"all", "task", "qa", "signal"} SOURCE_GROUP_TASK_TYPES = ("task_breakdown", "task_override") WINDOW_DAYS_VALUES = {7, 30} def _normalize_jsonable(value: Any) -> Any: if isinstance(value, dict): return {str(key): _normalize_jsonable(item) for key, item in value.items()} if isinstance(value, list): return [_normalize_jsonable(item) for item in value] if isinstance(value, tuple): return [_normalize_jsonable(item) for item in value] if hasattr(value, "model_dump"): return _normalize_jsonable(value.model_dump(mode="json")) return value def _normalize_source_group(value: Any) -> str: normalized = str(value or "all").strip().lower() if normalized not in SOURCE_GROUP_VALUES: raise ValueError(f"unsupported source_group: {normalized}") return normalized def _normalize_window_days(value: Any) -> int: if value is None: return 7 normalized = int(value) if normalized not in WINDOW_DAYS_VALUES: raise ValueError(f"unsupported window_days: {normalized}") return normalized def _source_group_clause(source_group: str): if source_group == "task": return FeedbackCenterRecord.source_type.in_(SOURCE_GROUP_TASK_TYPES) if source_group == "qa": return FeedbackCenterRecord.source_type == "qa_feedback" if source_group == "signal": return FeedbackCenterRecord.source_type == "signal_feedback" return None def _apply_source_group(stmt, source_group: str): clause = _source_group_clause(source_group) if clause is not None: stmt = stmt.where(clause) return stmt def _record_snapshot(record: FeedbackCenterRecord) -> dict[str, Any]: # 避免异步会话下属性过期触发隐式 IO(MissingGreenlet)。 created_at = record.__dict__.get("created_at") updated_at = record.__dict__.get("updated_at") return { "id": int(record.id), "tenant_id": int(record.tenant_id), "project_id": int(record.project_id) if record.project_id is not None else None, "source_type": record.source_type, "source_id": int(record.source_id), "object_type": record.object_type, "object_id": int(record.object_id), "field_name": record.field_name, "source_title": record.source_title, "object_title": record.object_title, "before_value": _normalize_jsonable(record.before_value), "after_value": _normalize_jsonable(record.after_value), "ai_snapshot": _normalize_jsonable(record.ai_snapshot), "ai_confidence": float(record.ai_confidence or 0.0) if record.ai_confidence is not None else None, "reason_candidates": _normalize_jsonable(record.reason_candidates or []), "reason_code": record.reason_code, "reason_text": record.reason_text, "review_status": record.review_status, "reviewed_by": int(record.reviewed_by) if record.reviewed_by is not None else None, "reviewed_at": record.reviewed_at.isoformat() if record.reviewed_at else None, "auto_assigned": bool(record.auto_assigned), "created_at": created_at.isoformat() if created_at else None, "updated_at": updated_at.isoformat() if updated_at else None, } def _revision_snapshot(revision: FeedbackCenterRecordRevision) -> dict[str, Any]: return { "id": int(revision.id), "record_id": int(revision.record_id), "before_snapshot": _normalize_jsonable(revision.before_snapshot), "after_snapshot": _normalize_jsonable(revision.after_snapshot), "reviewed_by": int(revision.reviewed_by) if revision.reviewed_by is not None else None, "reviewed_at": revision.reviewed_at.isoformat() if revision.reviewed_at else None, "created_at": revision.created_at.isoformat() if revision.created_at else None, "updated_at": revision.updated_at.isoformat() if revision.updated_at else None, } def _trend_accumulator(date_value: date | None = None) -> dict[str, Any]: return { "date": date_value, "total_records": 0, "accepted_count": 0, "rejected_count": 0, "reviewed_count": 0, "pending_count": 0, "task_breakdown_count": 0, "task_override_count": 0, "qa_feedback_count": 0, "signal_feedback_count": 0, "auto_assigned_count": 0, "override_count": 0, "rework_count": 0, "revision_count": 0, "avg_confidence": 0.0, "top_reasons": [], "_breakdown_task_ids": set(), "_override_task_ids": set(), "_auto_assigned_task_ids": set(), "_latencies": [], "_reworked_record_ids": set(), "_confidences": [], "_reason_counts": {}, } def _finalize_trend_accumulator(accumulator: dict[str, Any]) -> dict[str, Any]: breakdown_count = len(accumulator.pop("_breakdown_task_ids", set())) override_count = len(accumulator.pop("_override_task_ids", set())) auto_assigned_count = len(accumulator.pop("_auto_assigned_task_ids", set())) rework_count = len(accumulator.pop("_reworked_record_ids", set())) latencies = accumulator.pop("_latencies", []) confidences = accumulator.pop("_confidences", []) reason_counts = accumulator.pop("_reason_counts", {}) accepted_count = int(accumulator.get("accepted_count", 0)) rejected_count = int(accumulator.get("rejected_count", 0)) reviewed_count = int(accumulator.get("reviewed_count", 0)) pending_count = int(accumulator.get("pending_count", 0)) total_records = int(accumulator.get("total_records", 0)) avg_review_latency_minutes = sum(latencies) / len(latencies) if latencies else 0.0 avg_confidence = sum(confidences) / len(confidences) if confidences else 0.0 reviewed_decisions = accepted_count + rejected_count reason_catalog = {item["code"]: item["label"] for item in list_reason_catalog()} top_reasons = [ { "code": str(code), "label": reason_catalog.get(str(code), str(code)), "count": int(count or 0), } for code, count in sorted( reason_counts.items(), key=lambda item: (-int(item[1] or 0), str(item[0] or "")), )[:5] if str(code).strip() ] accumulator["task_breakdown_count"] = breakdown_count accumulator["task_override_count"] = override_count accumulator["qa_feedback_count"] = int(accumulator.get("qa_feedback_count", 0)) accumulator["signal_feedback_count"] = int(accumulator.get("signal_feedback_count", 0)) accumulator["auto_assigned_count"] = auto_assigned_count accumulator["override_count"] = override_count accumulator["rework_count"] = rework_count accumulator["revision_count"] = int(accumulator.get("revision_count", 0)) accumulator["accepted_rate_reviewed"] = accepted_count / reviewed_decisions if reviewed_decisions else 0.0 accumulator["manual_override_rate"] = override_count / breakdown_count if breakdown_count else 0.0 accumulator["rework_rate"] = rework_count / total_records if total_records else 0.0 accumulator["auto_assigned_rate"] = auto_assigned_count / breakdown_count if breakdown_count else 0.0 accumulator["avg_review_latency_minutes"] = avg_review_latency_minutes accumulator["avg_confidence"] = avg_confidence accumulator["top_reasons"] = top_reasons accumulator["reviewed_count"] = reviewed_count accumulator["pending_count"] = pending_count accumulator["accepted_count"] = accepted_count accumulator["rejected_count"] = rejected_count accumulator["total_records"] = total_records if accumulator.get("date") is not None: accumulator["date"] = accumulator["date"].isoformat() return accumulator def _scope_section_for(source_group: str) -> str: if source_group == "qa": return "qa" if source_group == "signal": return "signal" return "task" def _make_insight( *, code: str, title: str, description: str, severity: str, action_label: str, target_section: str, target_source_type: str | None = None, target_review_status: str | None = None, target_reason_code: str | None = None, ) -> dict[str, Any]: return { "code": code, "title": title, "description": description, "severity": severity, "action_label": action_label, "target_section": target_section, "target_source_type": target_source_type, "target_review_status": target_review_status, "target_reason_code": target_reason_code, } def _severity_rank(value: str) -> int: return {"critical": 3, "warning": 2, "info": 1}.get(str(value), 0) class FeedbackCenterService: """统一反馈中心服务。""" def __init__(self, db: AsyncSession): self.db = db @staticmethod def _base_stmt(*, tenant_id: int, project_id: Optional[int] = None): stmt = select(FeedbackCenterRecord).where(FeedbackCenterRecord.tenant_id == int(tenant_id)) if project_id is not None: stmt = stmt.where(FeedbackCenterRecord.project_id == int(project_id)) return stmt @staticmethod def _identity_filters(payload: dict[str, Any]): return ( FeedbackCenterRecord.tenant_id == int(payload["tenant_id"]), FeedbackCenterRecord.source_type == str(payload["source_type"]), FeedbackCenterRecord.source_id == int(payload["source_id"]), FeedbackCenterRecord.object_type == str(payload["object_type"]), FeedbackCenterRecord.object_id == int(payload["object_id"]), FeedbackCenterRecord.field_name == str(payload["field_name"]), ) async def _find_existing_record(self, payload: dict[str, Any]) -> FeedbackCenterRecord | None: stmt = select(FeedbackCenterRecord).where(*self._identity_filters(payload)) return (await self.db.execute(stmt)).scalar_one_or_none() def _merge_existing_record(self, record: FeedbackCenterRecord, payload: dict[str, Any]) -> bool: mutable_fields = ( "project_id", "source_title", "object_title", "before_value", "after_value", "ai_snapshot", "ai_confidence", "reason_candidates", "reason_code", "reason_text", "review_status", "reviewed_by", "reviewed_at", "auto_assigned", ) changed = False for field in mutable_fields: next_value = payload.get(field) current_value = getattr(record, field) if _normalize_jsonable(current_value) == _normalize_jsonable(next_value): continue setattr(record, field, next_value) changed = True if changed: record.updated_at = now_cn_naive() return changed @staticmethod def _apply_filters( stmt, *, source_type: Optional[str] = None, review_status: Optional[str] = None, field_name: Optional[str] = None, reason_code: Optional[str] = None, search: Optional[str] = None, ): if source_type: stmt = stmt.where(FeedbackCenterRecord.source_type == str(source_type)) if review_status: stmt = stmt.where(FeedbackCenterRecord.review_status == str(review_status)) if field_name: stmt = stmt.where(FeedbackCenterRecord.field_name == str(field_name)) if reason_code: stmt = stmt.where(FeedbackCenterRecord.reason_code == str(reason_code)) if search: like = f"%{str(search).strip()}%" stmt = stmt.where( or_( func.coalesce(FeedbackCenterRecord.object_title, "").ilike(like), func.coalesce(FeedbackCenterRecord.source_title, "").ilike(like), ) ) return stmt @staticmethod def _trend_add_record(accumulator: dict[str, Any], record: FeedbackCenterRecord, revision_count: int) -> None: accumulator["total_records"] += 1 status = str(record.review_status or "pending") if status == "accepted": accumulator["accepted_count"] += 1 elif status == "rejected": accumulator["rejected_count"] += 1 elif status == "reviewed": accumulator["reviewed_count"] += 1 else: accumulator["pending_count"] += 1 source_type = str(record.source_type or "") object_type = str(record.object_type or "") if source_type == "task_breakdown" and object_type == "task": accumulator["_breakdown_task_ids"].add(int(record.object_id)) if bool(record.auto_assigned): accumulator["_auto_assigned_task_ids"].add(int(record.object_id)) if source_type == "task_override" and object_type == "task": accumulator["_override_task_ids"].add(int(record.object_id)) if source_type == "qa_feedback": accumulator["qa_feedback_count"] += 1 if source_type == "signal_feedback": accumulator["signal_feedback_count"] += 1 if revision_count > 0: accumulator["revision_count"] += int(revision_count) if revision_count > 1: accumulator["_reworked_record_ids"].add(int(record.id)) if record.ai_confidence is not None: try: accumulator["_confidences"].append(float(record.ai_confidence)) except (TypeError, ValueError): pass reason_code = str(record.reason_code or "").strip() if reason_code: reason_counts = accumulator.setdefault("_reason_counts", {}) reason_counts[reason_code] = int(reason_counts.get(reason_code, 0) or 0) + 1 if record.created_at and record.reviewed_at: delta = (record.reviewed_at - record.created_at).total_seconds() / 60 if delta >= 0: accumulator["_latencies"].append(delta) def _build_insights(self, *, source_group: str, summary: dict[str, Any]) -> list[dict[str, Any]]: trend = summary.get("trend") or {} current = trend.get("current") or {} previous = trend.get("previous") or {} deltas = trend.get("deltas") or {} total = int(current.get("total_records") or 0) backlog_count = int(current.get("pending_count") or 0) + int(current.get("reviewed_count") or 0) accepted_count = int(current.get("accepted_count") or 0) rejected_count = int(current.get("rejected_count") or 0) pending_count = int(current.get("pending_count") or 0) top_reasons = current.get("top_reasons") or [] target_section = _scope_section_for(source_group) def _percent(value: float) -> str: return f"{max(0.0, float(value)):.0%}" def _minutes(value: float) -> str: minutes = max(0.0, float(value)) if minutes < 60: return f"{minutes:.0f}m" hours = int(minutes // 60) remainder = int(round(minutes % 60)) return f"{hours}h {remainder}m" def _append( items: list[dict[str, Any]], *, code: str, title: str, description: str, severity: str, action_label: str, target_section: str = target_section, target_source_type: str | None = None, target_review_status: str | None = None, target_reason_code: str | None = None, ) -> None: items.append( _make_insight( code=code, title=title, description=description, severity=severity, action_label=action_label, target_section=target_section, target_source_type=target_source_type, target_review_status=target_review_status, target_reason_code=target_reason_code, ) ) if total <= 0: return [ _make_insight( code="trend_stable", title="当前还没有样本", description="先让任务、QA 或信号积累一些反馈,趋势页就会开始给出决策建议。", severity="info", action_label="去看全部记录", target_section=target_section, ) ] insights: list[dict[str, Any]] = [] backlog_ratio = backlog_count / total if total else 0.0 accepted_rate_reviewed = float(current.get("accepted_rate_reviewed") or 0.0) manual_override_rate = float(current.get("manual_override_rate") or 0.0) rework_rate = float(current.get("rework_rate") or 0.0) auto_assigned_rate = float(current.get("auto_assigned_rate") or 0.0) avg_review_latency_minutes = float(current.get("avg_review_latency_minutes") or 0.0) avg_confidence = float(current.get("avg_confidence") or 0.0) if backlog_count >= max(8, int(total * 0.35)): severity = "critical" if backlog_count >= max(20, int(total * 0.5)) else "warning" _append( insights, code="backlog_pressure", title="待复核积压偏高", description=f"当前 backlog {backlog_count} 条,占总样本 {_percent(backlog_ratio)}。", severity=severity, action_label="查看待复核记录", target_review_status="pending", ) if source_group in {"all", "task"} and ( manual_override_rate >= 0.3 or float(deltas.get("manual_override_rate_delta") or 0.0) >= 0.05 ): severity = "critical" if manual_override_rate >= 0.45 else "warning" _append( insights, code="manual_override_pressure", title="人工覆盖偏高", description=( f"任务覆盖率 {_percent(manual_override_rate)}," f"上期 {_percent(float(previous.get('manual_override_rate') or 0.0))}。" ), severity=severity, action_label="查看任务覆盖样本", target_source_type="task_override", ) if source_group in {"all", "task"} and ( rework_rate >= 0.2 or float(deltas.get("rework_rate_delta") or 0.0) >= 0.05 ): severity = "critical" if rework_rate >= 0.35 else "warning" _append( insights, code="rework_pressure", title="返工率上升", description=( f"存在二次修订的样本占比 {_percent(rework_rate)}," f"上期 {_percent(float(previous.get('rework_rate') or 0.0))}。" ), severity=severity, action_label="查看返工样本", target_source_type="task_override", ) if source_group in {"all", "task"} and ( auto_assigned_rate <= 0.5 or float(deltas.get("auto_assigned_rate_delta") or 0.0) <= -0.05 ): severity = "critical" if auto_assigned_rate <= 0.3 else "warning" _append( insights, code="auto_assignment_gap", title="自动指派偏弱", description=( f"任务拆解自动落负责人比例 {_percent(auto_assigned_rate)}," f"上期 {_percent(float(previous.get('auto_assigned_rate') or 0.0))}。" ), severity=severity, action_label="查看任务拆解", target_source_type="task_breakdown", ) if accepted_count + rejected_count > 0 and ( accepted_rate_reviewed < 0.65 or float(deltas.get("accepted_rate_reviewed_delta") or 0.0) <= -0.05 ): severity = "critical" if accepted_rate_reviewed < 0.5 else "warning" _append( insights, code="accepted_rate_drop", title="采纳率走弱", description=( f"已决策样本中进入学习池的比例 {_percent(accepted_rate_reviewed)}," f"上期 {_percent(float(previous.get('accepted_rate_reviewed') or 0.0))}。" ), severity=severity, action_label="查看低采纳样本", target_review_status="rejected", ) if avg_review_latency_minutes >= 120 or float(deltas.get("avg_review_latency_minutes_delta") or 0.0) >= 30: severity = "critical" if avg_review_latency_minutes >= 240 else "warning" _append( insights, code="latency_pressure", title="复核耗时偏长", description=( f"样本从生成到复核平均 {_minutes(avg_review_latency_minutes)}," f"上期 {_minutes(float(previous.get('avg_review_latency_minutes') or 0.0))}。" ), severity=severity, action_label="查看待复核记录", target_review_status="pending", ) if source_group in {"all", "qa"} and accepted_rate_reviewed < 0.7 and pending_count > 0: _append( insights, code="qa_acceptance_pressure", title="QA 反馈转化率偏低", description=f"当前 QA 相关样本里,进入学习池的比例只有 {_percent(accepted_rate_reviewed)}。", severity="warning", action_label="查看 QA 反馈", target_section="qa", ) if source_group in {"all", "signal"} and avg_confidence < 0.7 and pending_count > 0: _append( insights, code="signal_confidence_pressure", title="信号判断置信度偏低", description=f"当前 scope 的平均置信度只有 {_percent(avg_confidence)},适合优先复核。", severity="warning", action_label="查看信号反馈", target_section="signal", ) top_reason = next((item for item in top_reasons if item.get("code")), None) if top_reason: top_reason_count = int(top_reason.get("count") or 0) reason_ratio = top_reason_count / total if total else 0.0 if reason_ratio >= 0.3: severity = "critical" if reason_ratio >= 0.5 else "warning" _append( insights, code="reason_concentration", title=f"高频原因:{top_reason.get('label') or top_reason.get('code')}", description=( f"最近 {top_reason_count} 条样本都在指向同一个原因," "适合直接看同类记录确认根因。" ), severity=severity, action_label="查看同类样本", target_reason_code=str(top_reason.get("code")), ) if not insights: insights.append( _make_insight( code="trend_stable", title="当前趋势稳定", description="最近的样本量和关键比率都没有明显异常,可以继续观察。", severity="info", action_label="查看当前范围样本", target_section=target_section, ) ) insights.sort( key=lambda item: ( -_severity_rank(str(item.get("severity", "info"))), str(item.get("code") or ""), ) ) return insights async def _build_trend( self, *, tenant_id: int, project_id: Optional[int] = None, window_days: int = 7, source_group: str = "all", ) -> dict[str, Any]: window_days = _normalize_window_days(window_days) source_group = _normalize_source_group(source_group) today = now_cn_naive().date() current_start = today - timedelta(days=window_days - 1) previous_start = current_start - timedelta(days=window_days) end_exclusive = today + timedelta(days=1) start_dt = datetime.combine(previous_start, time.min) end_dt = datetime.combine(end_exclusive, time.min) stmt = ( select(FeedbackCenterRecord) .options(selectinload(FeedbackCenterRecord.revisions)) .where( and_( FeedbackCenterRecord.tenant_id == int(tenant_id), or_( and_( FeedbackCenterRecord.created_at.is_not(None), FeedbackCenterRecord.created_at >= start_dt, FeedbackCenterRecord.created_at < end_dt, ), and_( FeedbackCenterRecord.reviewed_at.is_not(None), FeedbackCenterRecord.reviewed_at >= start_dt, FeedbackCenterRecord.reviewed_at < end_dt, ), ), ) ) ) if project_id is not None: stmt = stmt.where(FeedbackCenterRecord.project_id == int(project_id)) stmt = _apply_source_group(stmt, source_group) records = (await self.db.execute(stmt)).scalars().all() current_daily: dict[date, dict[str, Any]] = { current_start + timedelta(days=index): _trend_accumulator(current_start + timedelta(days=index)) for index in range(window_days) } current_window = _trend_accumulator() previous_window = _trend_accumulator() for record in records: reviewed_at = getattr(record, "reviewed_at", None) created_at = getattr(record, "created_at", None) activity_at = reviewed_at if reviewed_at and start_dt <= reviewed_at < end_dt else created_at if activity_at is None: continue activity_date = activity_at.date() revision_count = len(record.revisions or []) if current_start <= activity_date <= today: bucket = current_daily.get(activity_date) if bucket is not None: self._trend_add_record(bucket, record, revision_count) self._trend_add_record(current_window, record, revision_count) continue if previous_start <= activity_date < current_start: self._trend_add_record(previous_window, record, revision_count) daily = [_finalize_trend_accumulator(current_daily[current_start + timedelta(days=index)]) for index in range(window_days)] current = _finalize_trend_accumulator(current_window) previous = _finalize_trend_accumulator(previous_window) deltas = { "total_records_delta": int(current["total_records"] - previous["total_records"]), "accepted_rate_reviewed_delta": float(current["accepted_rate_reviewed"] - previous["accepted_rate_reviewed"]), "manual_override_rate_delta": float(current["manual_override_rate"] - previous["manual_override_rate"]), "rework_rate_delta": float(current["rework_rate"] - previous["rework_rate"]), "auto_assigned_rate_delta": float(current["auto_assigned_rate"] - previous["auto_assigned_rate"]), "avg_review_latency_minutes_delta": float( current["avg_review_latency_minutes"] - previous["avg_review_latency_minutes"] ), } return { "window_days": window_days, "current_window_start": current_start, "previous_window_start": previous_start, "daily": daily, "current": current, "previous": previous, "deltas": deltas, } async def summary( self, *, tenant_id: int, project_id: Optional[int] = None, window_days: int = 7, source_group: str = "all", ) -> dict[str, Any]: window_days = _normalize_window_days(window_days) source_group = _normalize_source_group(source_group) stmt = self._base_stmt(tenant_id=tenant_id, project_id=project_id) stmt = _apply_source_group(stmt, source_group) total_stmt = select(func.count()).select_from(stmt.subquery()) total = int((await self.db.execute(total_stmt)).scalar_one() or 0) status_stmt = select(FeedbackCenterRecord.review_status, func.count()).group_by( FeedbackCenterRecord.review_status ) status_stmt = status_stmt.where(FeedbackCenterRecord.tenant_id == int(tenant_id)) if project_id is not None: status_stmt = status_stmt.where(FeedbackCenterRecord.project_id == int(project_id)) status_stmt = _apply_source_group(status_stmt, source_group) status_rows = await self.db.execute(status_stmt) by_status = {str(status or "pending"): int(count or 0) for status, count in status_rows.all()} source_stmt = select(FeedbackCenterRecord.source_type, func.count()).group_by(FeedbackCenterRecord.source_type) source_stmt = source_stmt.where(FeedbackCenterRecord.tenant_id == int(tenant_id)) if project_id is not None: source_stmt = source_stmt.where(FeedbackCenterRecord.project_id == int(project_id)) source_stmt = _apply_source_group(source_stmt, source_group) source_rows = await self.db.execute(source_stmt) by_source_type = {str(source_type or "unknown"): int(count or 0) for source_type, count in source_rows.all()} confidence_stmt = select(func.avg(FeedbackCenterRecord.ai_confidence)) confidence_stmt = confidence_stmt.where(FeedbackCenterRecord.tenant_id == int(tenant_id)) if project_id is not None: confidence_stmt = confidence_stmt.where(FeedbackCenterRecord.project_id == int(project_id)) confidence_stmt = _apply_source_group(confidence_stmt, source_group) avg_confidence = float((await self.db.execute(confidence_stmt)).scalar_one() or 0.0) trend = await self._build_trend( tenant_id=tenant_id, project_id=project_id, window_days=window_days, source_group=source_group, ) accepted_count = int(by_status.get("accepted", 0)) reviewed_count = int(by_status.get("reviewed", 0)) pending_count = int(by_status.get("pending", 0)) rejected_count = int(by_status.get("rejected", 0)) backlog_count = pending_count + reviewed_count accepted_rate = (accepted_count / total) if total else 0.0 reason_stmt = select(FeedbackCenterRecord.reason_code, func.count()) reason_stmt = reason_stmt.where(FeedbackCenterRecord.tenant_id == int(tenant_id)) if project_id is not None: reason_stmt = reason_stmt.where(FeedbackCenterRecord.project_id == int(project_id)) reason_stmt = _apply_source_group(reason_stmt, source_group) reason_stmt = reason_stmt.where(FeedbackCenterRecord.reason_code.is_not(None)) reason_stmt = reason_stmt.group_by(FeedbackCenterRecord.reason_code).order_by(func.count().desc()) reason_rows = (await self.db.execute(reason_stmt)).all() top_reasons: list[dict[str, Any]] = [] for reason_code, count in reason_rows[:5]: if not reason_code: continue catalog = list_reason_catalog() label = next((item["label"] for item in catalog if item["code"] == reason_code), reason_code) top_reasons.append( { "code": str(reason_code), "label": label, "count": int(count or 0), } ) async def _distinct_task_count(*, source_type: str, auto_assigned: Optional[bool] = None) -> int: stmt = select(func.count(func.distinct(FeedbackCenterRecord.object_id))).where( and_( FeedbackCenterRecord.tenant_id == int(tenant_id), FeedbackCenterRecord.source_type == source_type, FeedbackCenterRecord.object_type == "task", ) ) if project_id is not None: stmt = stmt.where(FeedbackCenterRecord.project_id == int(project_id)) stmt = _apply_source_group(stmt, source_group) if auto_assigned is not None: stmt = stmt.where(FeedbackCenterRecord.auto_assigned.is_(auto_assigned)) return int((await self.db.execute(stmt)).scalar_one() or 0) task_breakdown_count = int(by_source_type.get("task_breakdown", 0)) task_override_count = int(by_source_type.get("task_override", 0)) qa_feedback_count = int(by_source_type.get("qa_feedback", 0)) signal_feedback_count = int(by_source_type.get("signal_feedback", 0)) breakdown_task_count = await _distinct_task_count(source_type="task_breakdown") override_task_count = await _distinct_task_count(source_type="task_override") auto_assigned_task_count = await _distinct_task_count(source_type="task_breakdown", auto_assigned=True) accepted_count_reviewed = accepted_count reviewed_decision_total = accepted_count + rejected_count accepted_rate_reviewed = accepted_count_reviewed / reviewed_decision_total if reviewed_decision_total else 0.0 manual_override_rate = override_task_count / breakdown_task_count if breakdown_task_count else 0.0 auto_assigned_rate = auto_assigned_task_count / breakdown_task_count if breakdown_task_count else 0.0 revision_scope = ( select(FeedbackCenterRecordRevision) .join( FeedbackCenterRecord, FeedbackCenterRecord.id == FeedbackCenterRecordRevision.record_id, ) .where( and_( FeedbackCenterRecordRevision.tenant_id == int(tenant_id), FeedbackCenterRecord.tenant_id == int(tenant_id), ) ) ) if project_id is not None: revision_scope = revision_scope.where(FeedbackCenterRecord.project_id == int(project_id)) revision_scope = _apply_source_group(revision_scope, source_group) revision_count_stmt = select(func.count()).select_from(revision_scope.subquery()) revision_count = int((await self.db.execute(revision_count_stmt)).scalar_one() or 0) rework_stmt = ( select(FeedbackCenterRecordRevision.record_id, func.count().label("cnt")) .join( FeedbackCenterRecord, FeedbackCenterRecord.id == FeedbackCenterRecordRevision.record_id, ) .where( and_( FeedbackCenterRecordRevision.tenant_id == int(tenant_id), FeedbackCenterRecord.tenant_id == int(tenant_id), ) ) .group_by(FeedbackCenterRecordRevision.record_id) .having(func.count() > 1) ) if project_id is not None: rework_stmt = rework_stmt.where(FeedbackCenterRecord.project_id == int(project_id)) rework_stmt = _apply_source_group(rework_stmt, source_group) rework_count_stmt = select(func.count()).select_from(rework_stmt.subquery()) rework_record_count = int((await self.db.execute(rework_count_stmt)).scalar_one() or 0) rework_rate = rework_record_count / total if total else 0.0 latency_stmt = select(FeedbackCenterRecord.created_at, FeedbackCenterRecord.reviewed_at).where( and_( FeedbackCenterRecord.tenant_id == int(tenant_id), FeedbackCenterRecord.reviewed_at.is_not(None), ) ) if project_id is not None: latency_stmt = latency_stmt.where(FeedbackCenterRecord.project_id == int(project_id)) latency_stmt = _apply_source_group(latency_stmt, source_group) latency_rows = (await self.db.execute(latency_stmt)).all() latencies: list[float] = [] for created_at, reviewed_at in latency_rows: if not created_at or not reviewed_at: continue delta = (reviewed_at - created_at).total_seconds() / 60 if delta >= 0: latencies.append(delta) avg_review_latency_minutes = sum(latencies) / len(latencies) if latencies else 0.0 return { "tenant_id": int(tenant_id), "project_id": int(project_id) if project_id is not None else None, "source_group": source_group, "total": total, "backlog_count": backlog_count, "pending_count": pending_count, "reviewed_count": reviewed_count, "accepted_count": accepted_count, "rejected_count": rejected_count, "accepted_rate": accepted_rate, "avg_confidence": avg_confidence, "by_status": by_status, "by_source_type": by_source_type, "top_reasons": top_reasons, "insights": self._build_insights(source_group=source_group, summary={ "total": total, "backlog_count": backlog_count, "accepted_count": accepted_count, "rejected_count": rejected_count, "by_status": by_status, "top_reasons": top_reasons, "metrics": { "accepted_rate_reviewed": accepted_rate_reviewed, "manual_override_rate": manual_override_rate, "rework_rate": rework_rate, "auto_assigned_rate": auto_assigned_rate, "avg_review_latency_minutes": avg_review_latency_minutes, }, "trend": trend, "avg_confidence": avg_confidence, }), "metrics": { "accepted_rate_reviewed": accepted_rate_reviewed, "manual_override_rate": manual_override_rate, "rework_rate": rework_rate, "auto_assigned_rate": auto_assigned_rate, "avg_review_latency_minutes": avg_review_latency_minutes, "total_task_breakdown_count": task_breakdown_count, "total_task_override_count": task_override_count, "total_qa_feedback_count": qa_feedback_count, "total_signal_feedback_count": signal_feedback_count, "total_revision_count": revision_count, }, "trend": trend, } async def list_records( self, *, tenant_id: int, project_id: Optional[int] = None, source_type: Optional[str] = None, review_status: Optional[str] = None, field_name: Optional[str] = None, reason_code: Optional[str] = None, search: Optional[str] = None, limit: int = 20, offset: int = 0, ) -> dict[str, Any]: limit = max(1, min(int(limit), 100)) offset = max(0, int(offset)) stmt = self._base_stmt(tenant_id=tenant_id, project_id=project_id) stmt = self._apply_filters( stmt, source_type=source_type, review_status=review_status, field_name=field_name, reason_code=reason_code, search=search, ) total_stmt = select(func.count()).select_from(stmt.subquery()) total = int((await self.db.execute(total_stmt)).scalar_one() or 0) stmt = stmt.options(selectinload(FeedbackCenterRecord.revisions)) stmt = stmt.order_by(FeedbackCenterRecord.created_at.desc().nullslast(), FeedbackCenterRecord.id.desc()) rows = (await self.db.execute(stmt.offset(offset).limit(limit))).scalars().all() items = [self._serialize_record(row, include_revisions=False) for row in rows] return { "items": items, "total": total, "limit": limit, "offset": offset, "has_more": offset + len(items) < total, } async def get_detail( self, *, tenant_id: int, record_id: int, project_id: Optional[int] = None, ) -> dict[str, Any] | None: stmt = ( select(FeedbackCenterRecord) .options(selectinload(FeedbackCenterRecord.revisions)) .where( and_( FeedbackCenterRecord.tenant_id == int(tenant_id), FeedbackCenterRecord.id == int(record_id), ) ) ) if project_id is not None: stmt = stmt.where(FeedbackCenterRecord.project_id == int(project_id)) record = (await self.db.execute(stmt)).scalar_one_or_none() if not record: return None return { "record": self._serialize_record(record, include_revisions=True), "revisions": [self._serialize_revision(revision) for revision in record.revisions or []], } async def review( self, *, tenant_id: int, record_id: int, reviewer_id: int, review_status: str, reason_code: str | None = None, reason_text: str | None = None, reason_candidates: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: stmt = ( select(FeedbackCenterRecord) .options(selectinload(FeedbackCenterRecord.revisions)) .where( and_( FeedbackCenterRecord.tenant_id == int(tenant_id), FeedbackCenterRecord.id == int(record_id), ) ) ) record = (await self.db.execute(stmt)).scalar_one_or_none() if not record: raise ValueError("feedback record not found") previous_snapshot = self._serialize_record(record, include_revisions=False) record.review_status = str(review_status) record.reason_code = str(reason_code).strip()[:64] if reason_code else record.reason_code record.reason_text = str(reason_text).strip()[:4000] if reason_text else record.reason_text if reason_candidates is not None: record.reason_candidates = reason_candidates elif not record.reason_candidates: record.reason_candidates = build_reason_candidates( source_type=record.source_type, field_name=record.field_name, before_value=record.before_value, after_value=record.after_value, ) record.reviewed_by = int(reviewer_id) record.reviewed_at = now_cn_naive() record.updated_at = record.reviewed_at await self.db.flush() current_snapshot = self._serialize_record(record, include_revisions=False) self.db.add( FeedbackCenterRecordRevision( tenant_id=int(tenant_id), record_id=int(record.id), before_snapshot=previous_snapshot, after_snapshot=current_snapshot, reviewed_by=int(reviewer_id), reviewed_at=record.reviewed_at, ) ) await self.db.commit() await self.db.refresh(record) refreshed = await self.get_detail(tenant_id=tenant_id, record_id=record.id) if refreshed is None: raise ValueError("feedback record not found after review") return refreshed["record"] async def ingest_payloads( self, *, payloads: list[dict[str, Any]], commit: bool = False, ) -> list[FeedbackCenterRecord]: records: list[FeedbackCenterRecord] = [] for payload in payloads: normalized = self._normalize_payload(payload) existing = await self._find_existing_record(normalized) if existing is None: record = FeedbackCenterRecord(**normalized) self.db.add(record) records.append(record) continue previous_snapshot = self._serialize_record(existing, include_revisions=False) changed = self._merge_existing_record(existing, normalized) if changed: current_snapshot = self._serialize_record(existing, include_revisions=False) self.db.add( FeedbackCenterRecordRevision( tenant_id=int(existing.tenant_id), record_id=int(existing.id), before_snapshot=previous_snapshot, after_snapshot=current_snapshot, reviewed_by=normalized.get("reviewed_by"), reviewed_at=normalized.get("reviewed_at") or now_cn_naive(), ) ) records.append(existing) if commit: await self.db.commit() for record in records: await self.db.refresh(record) else: await self.db.flush() return records async def ingest_task_breakdown( self, *, task: Any, project_title: str | None = None, source_context: dict[str, Any] | None = None, commit: bool = False, ) -> FeedbackCenterRecord | None: payload = map_task_breakdown_to_record_payload( task=task, project_title=project_title, source_context=source_context, ) records = await self.ingest_payloads(payloads=[payload], commit=commit) return records[0] if records else None async def ingest_task_overrides( self, *, task: Any, commit: bool = False, ) -> list[FeedbackCenterRecord]: payloads = map_task_override_to_record_payloads(task=task) if not payloads: return [] return await self.ingest_payloads(payloads=payloads, commit=commit) async def ingest_qa_feedback( self, *, qa_feedback: Any, qa_record: Any, commit: bool = False, ) -> FeedbackCenterRecord | None: payload = map_qa_feedback_to_record_payload(qa_feedback=qa_feedback, qa_record=qa_record) records = await self.ingest_payloads(payloads=[payload], commit=commit) return records[0] if records else None async def ingest_signal_feedback( self, *, signal_feedback: Any, signal: Any, commit: bool = False, ) -> FeedbackCenterRecord | None: payload = map_signal_feedback_to_record_payload(signal_feedback=signal_feedback, signal=signal) records = await self.ingest_payloads(payloads=[payload], commit=commit) return records[0] if records else None async def get_signal_feedback_stats(self, *, tenant_id: int) -> dict[str, Any]: stmt = select(FeedbackCenterRecord).where( and_( FeedbackCenterRecord.tenant_id == int(tenant_id), FeedbackCenterRecord.source_type == "signal_feedback", ) ) rows = (await self.db.execute(stmt)).scalars().all() total = len(rows) if total == 0: return { "total_feedbacks": 0, "accuracy": 0.0, "true_positives": 0, "false_positives": 0, "true_negatives": 0, "false_negatives": 0, } def _predicted_actual(row: FeedbackCenterRecord) -> tuple[bool, bool]: before = row.before_value if isinstance(row.before_value, dict) else {} after = row.after_value if isinstance(row.after_value, dict) else {} predicted = bool(before.get("ai_predicted_strategic") if isinstance(before, dict) else False) actual = bool(after.get("was_strategic") if isinstance(after, dict) else False) return predicted, actual correct = 0 tp = fp = tn = fn = 0 for row in rows: predicted, actual = _predicted_actual(row) if predicted == actual: correct += 1 if predicted and actual: tp += 1 elif predicted and not actual: fp += 1 elif not predicted and not actual: tn += 1 elif not predicted and actual: fn += 1 return { "total_feedbacks": total, "accuracy": correct / total if total else 0.0, "true_positives": tp, "false_positives": fp, "true_negatives": tn, "false_negatives": fn, } def _serialize_record(self, record: FeedbackCenterRecord, *, include_revisions: bool) -> dict[str, Any]: payload = _record_snapshot(record) if include_revisions: payload["revisions"] = [self._serialize_revision(revision) for revision in getattr(record, "revisions", [])] else: payload["revisions"] = [] return payload def _serialize_revision(self, revision: FeedbackCenterRecordRevision) -> dict[str, Any]: return _revision_snapshot(revision) def _normalize_payload(self, payload: dict[str, Any]) -> dict[str, Any]: normalized = dict(payload) normalized["tenant_id"] = int(normalized.get("tenant_id") or 1) normalized["project_id"] = int(normalized["project_id"]) if normalized.get("project_id") not in {None, ""} else None normalized["source_id"] = int(normalized.get("source_id") or 0) normalized["object_id"] = int(normalized.get("object_id") or 0) normalized["source_type"] = str(normalized.get("source_type") or "").strip()[:32] normalized["object_type"] = str(normalized.get("object_type") or "").strip()[:32] normalized["field_name"] = str(normalized.get("field_name") or "").strip()[:128] normalized["source_title"] = str(normalized.get("source_title") or "").strip()[:500] or None normalized["object_title"] = str(normalized.get("object_title") or "").strip()[:500] or None normalized["before_value"] = _normalize_jsonable(normalized.get("before_value")) normalized["after_value"] = _normalize_jsonable(normalized.get("after_value")) normalized["ai_snapshot"] = _normalize_jsonable(normalized.get("ai_snapshot")) ai_confidence = normalized.get("ai_confidence") normalized["ai_confidence"] = float(ai_confidence) if ai_confidence not in {None, ""} else None reason_candidates = normalized.get("reason_candidates") normalized["reason_candidates"] = _normalize_jsonable(reason_candidates or []) normalized["reason_code"] = str(normalized.get("reason_code") or "").strip()[:64] or None normalized["reason_text"] = str(normalized.get("reason_text") or "").strip()[:4000] or None normalized["review_status"] = str(normalized.get("review_status") or "pending").strip()[:16] reviewed_by = normalized.get("reviewed_by") normalized["reviewed_by"] = int(reviewed_by) if reviewed_by not in {None, ""} else None normalized["reviewed_at"] = normalized.get("reviewed_at") or None normalized["auto_assigned"] = bool(normalized.get("auto_assigned", False)) normalized.pop("provider", None) normalized.pop("model_name", None) normalized.pop("prompt_version", None) normalized.pop("schema_version", None) return normalized __all__ = ["FeedbackCenterService"]