1211 lines
52 KiB
Python
1211 lines
52 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,
|
||
"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"]
|