PMFlow/backend/app/services/feedback_center/service.py

1211 lines
52 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.

"""统一反馈中心业务服务。"""
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"]