884 lines
38 KiB
Python
884 lines
38 KiB
Python
"""统一反馈中心业务服务。"""
|
||
|
||
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,
|
||
"_breakdown_task_ids": set(),
|
||
"_override_task_ids": set(),
|
||
"_auto_assigned_task_ids": set(),
|
||
"_latencies": [],
|
||
"_reworked_record_ids": set(),
|
||
}
|
||
|
||
|
||
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", [])
|
||
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
|
||
reviewed_decisions = accepted_count + rejected_count
|
||
|
||
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["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
|
||
|
||
|
||
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.created_at and record.reviewed_at:
|
||
delta = (record.reviewed_at - record.created_at).total_seconds() / 60
|
||
if delta >= 0:
|
||
accumulator["_latencies"].append(delta)
|
||
|
||
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),
|
||
FeedbackCenterRecord.created_at.is_not(None),
|
||
FeedbackCenterRecord.created_at >= start_dt,
|
||
FeedbackCenterRecord.created_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:
|
||
created_at = getattr(record, "created_at", None)
|
||
if created_at is None:
|
||
continue
|
||
created_date = created_at.date()
|
||
revision_count = len(record.revisions or [])
|
||
if current_start <= created_date <= today:
|
||
bucket = current_daily.get(created_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 <= created_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,
|
||
"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"]
|