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

297 lines
12 KiB
Python

"""统一反馈中心数据映射器。"""
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from .reason_catalog import build_reason_candidates
def _jsonable(value: Any) -> Any:
if value is None:
return None
if isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, Decimal):
return float(value)
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, dict):
return {str(key): _jsonable(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set)):
return [_jsonable(item) for item in value]
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
return _jsonable(model_dump(mode="json"))
if hasattr(value, "__dict__"):
raw = {key: item for key, item in vars(value).items() if not key.startswith("_")}
if raw:
return _jsonable(raw)
return str(value)
def _safe_int(value: Any) -> int | None:
try:
if value is None or value == "":
return None
return int(str(value).strip())
except (TypeError, ValueError):
return None
def _extract_task_breakdown_object(task: Any) -> dict[str, Any]:
payload = getattr(task, "ai_breakdown_payload", None)
if isinstance(payload, str):
try:
import json
payload = json.loads(payload)
except Exception:
payload = None
if isinstance(payload, dict):
if isinstance(payload.get("task"), dict):
return _jsonable(payload["task"])
tasks = payload.get("tasks")
if isinstance(tasks, list) and tasks:
current_title = str(getattr(task, "title", "") or "").strip().lower()
current_id = str(getattr(task, "id", "") or "").strip()
for item in tasks:
if not isinstance(item, dict):
continue
title = str(item.get("title") or "").strip().lower()
task_key = str(item.get("task_key") or "").strip()
if (current_title and title == current_title) or (current_id and task_key == current_id):
return _jsonable(item)
first = tasks[0]
if isinstance(first, dict):
return _jsonable(first)
return {
"task_key": str(getattr(task, "id", "") or "").strip() or str(getattr(task, "title", "") or "").strip(),
"title": getattr(task, "title", None),
"description": getattr(task, "description", None),
"type": "task",
"estimated_hours": _jsonable(getattr(task, "estimated_hours", None)),
"required_skill_names": [],
"required_skill_tag_ids": list(getattr(task, "required_skill_tag_ids", None) or []),
"depends_on_task_keys": [],
"risk_level": "medium",
"risk_notes": None,
"confidence": 0.0,
"evidence_refs": [],
"priority": getattr(task, "priority", "medium"),
}
def map_task_breakdown_to_record_payload(
*,
task: Any,
project_title: str | None = None,
source_context: dict[str, Any] | None = None,
) -> dict[str, Any]:
task_object = _extract_task_breakdown_object(task)
ai_snapshot = _jsonable(getattr(task, "ai_breakdown_payload", None))
reason_candidates = build_reason_candidates(
source_type="task_breakdown",
field_name="task_object",
before_value=None,
after_value=task_object,
)
return {
"tenant_id": int(getattr(task, "tenant_id", 1) or 1),
"project_id": int(getattr(task, "project_id", 0) or 0) or None,
"source_type": "task_breakdown",
"source_id": int(getattr(task, "id", 0) or 0),
"object_type": "task",
"object_id": int(getattr(task, "id", 0) or 0),
"field_name": "task_object",
"source_title": str(project_title or getattr(task, "title", "") or "")[:500] or None,
"object_title": str(getattr(task, "title", "") or "")[:500] or None,
"before_value": None,
"after_value": task_object,
"ai_snapshot": ai_snapshot,
"ai_confidence": float(task_object.get("confidence") or 0.0) if isinstance(task_object, dict) else None,
"reason_candidates": reason_candidates,
"reason_code": reason_candidates[0]["code"] if reason_candidates else None,
"reason_text": None,
"review_status": "pending",
"reviewed_by": None,
"reviewed_at": None,
"auto_assigned": bool(getattr(task, "assignee_id", None)),
}
def map_task_override_to_record_payloads(*, task: Any) -> list[dict[str, Any]]:
ai_object = _extract_task_breakdown_object(task)
raw_ai_payload = getattr(task, "ai_breakdown_payload", None)
if isinstance(raw_ai_payload, str):
try:
import json
raw_ai_payload = json.loads(raw_ai_payload)
except Exception:
raw_ai_payload = None
raw_ai_map = raw_ai_payload if isinstance(raw_ai_payload, dict) else {}
overrides = getattr(task, "manual_overrides", None) or {}
if isinstance(overrides, str):
try:
import json
overrides = json.loads(overrides)
except Exception:
overrides = {}
if not isinstance(overrides, dict):
overrides = dict(overrides or {})
payloads: list[dict[str, Any]] = []
for field_name, after_value in overrides.items():
if field_name == "manual_overrides":
continue
before_value = raw_ai_map.get(field_name)
if before_value is None:
before_value = ai_object.get(field_name) if isinstance(ai_object, dict) else getattr(task, field_name, None)
if _jsonable(before_value) == _jsonable(after_value):
continue
field_candidates = build_reason_candidates(
source_type="task_override",
field_name=str(field_name),
before_value=before_value,
after_value=after_value,
)
payloads.append(
{
"tenant_id": int(getattr(task, "tenant_id", 1) or 1),
"project_id": int(getattr(task, "project_id", 0) or 0) or None,
"source_type": "task_override",
"source_id": int(getattr(task, "id", 0) or 0),
"object_type": "task",
"object_id": int(getattr(task, "id", 0) or 0),
"field_name": str(field_name),
"source_title": str(getattr(task, "title", "") or "")[:500] or None,
"object_title": str(getattr(task, "title", "") or "")[:500] or None,
"before_value": _jsonable(before_value),
"after_value": _jsonable(after_value),
"ai_snapshot": {
"ai_breakdown_payload": _jsonable(getattr(task, "ai_breakdown_payload", None)),
"manual_overrides": _jsonable(overrides),
},
"ai_confidence": float(ai_object.get("confidence") or 0.0) if isinstance(ai_object, dict) else None,
"reason_candidates": field_candidates,
"reason_code": field_candidates[0]["code"] if field_candidates else None,
"reason_text": None,
"review_status": "accepted",
"reviewed_by": None,
"reviewed_at": None,
"auto_assigned": bool(getattr(task, "assignee_id", None)),
}
)
return payloads
def map_qa_feedback_to_record_payload(*, qa_feedback: Any, qa_record: Any) -> dict[str, Any]:
rating = str(getattr(qa_feedback, "rating", "") or "").strip().lower()
helpfulness = getattr(qa_feedback, "helpfulness", None)
error_types = list(getattr(qa_feedback, "error_types", None) or [])
correct_answer = getattr(qa_feedback, "correct_answer", None)
before_value = {
"question": getattr(qa_record, "question", None),
"answer": getattr(qa_record, "answer", None),
"sources": _jsonable(getattr(qa_record, "sources", None) or []),
}
after_value = {
"rating": rating,
"helpfulness": helpfulness,
"error_types": [str(item).strip() for item in error_types if str(item).strip()],
"correct_answer": correct_answer,
}
reason_candidates = build_reason_candidates(
source_type="qa_feedback",
field_name="qa_feedback",
before_value=before_value,
after_value=after_value,
)
return {
"tenant_id": int(getattr(qa_feedback, "tenant_id", getattr(qa_record, "tenant_id", 1)) or 1),
"project_id": int(getattr(qa_record, "project_id", 0) or 0) or None,
"source_type": "qa_feedback",
"source_id": int(getattr(qa_feedback, "id", 0) or 0),
"object_type": "qa_record",
"object_id": int(getattr(qa_feedback, "qa_id", 0) or 0),
"field_name": "qa_feedback",
"source_title": str(getattr(qa_record, "question", "") or "")[:500] or None,
"object_title": str(getattr(qa_record, "question", "") or "")[:500] or None,
"before_value": before_value,
"after_value": after_value,
"ai_snapshot": {
"question": getattr(qa_record, "question", None),
"answer": getattr(qa_record, "answer", None),
"sources": _jsonable(getattr(qa_record, "sources", None) or []),
"domains_used": _jsonable(getattr(qa_record, "domains_used", None) or []),
},
"ai_confidence": None,
"reason_candidates": reason_candidates,
"reason_code": reason_candidates[0]["code"] if reason_candidates else None,
"reason_text": correct_answer or None,
"review_status": "accepted",
"reviewed_by": int(getattr(qa_feedback, "submitted_by", 0) or 0) or None,
"reviewed_at": None,
"auto_assigned": False,
}
def map_signal_feedback_to_record_payload(*, signal_feedback: Any, signal: Any) -> dict[str, Any]:
predicted = bool(getattr(signal_feedback, "ai_predicted_strategic", False))
actual = bool(getattr(signal_feedback, "was_strategic", False))
before_value = {
"ai_predicted_strategic": predicted,
"ai_confidence": float(getattr(signal_feedback, "ai_confidence", 0.0) or 0.0),
}
after_value = {
"was_strategic": actual,
"reason": getattr(signal_feedback, "reason", None),
}
reason_candidates = build_reason_candidates(
source_type="signal_feedback",
field_name="strategic_classification",
before_value=before_value,
after_value=after_value,
)
return {
"tenant_id": int(getattr(signal_feedback, "tenant_id", getattr(signal, "tenant_id", 1)) or 1),
"project_id": int(getattr(signal, "matched_project_id", 0) or 0) or None,
"source_type": "signal_feedback",
"source_id": int(getattr(signal_feedback, "id", 0) or 0),
"object_type": "signal",
"object_id": int(getattr(signal_feedback, "signal_id", 0) or 0),
"field_name": "strategic_classification",
"source_title": str(getattr(signal, "title", "") or getattr(signal, "source", "") or "")[:500] or None,
"object_title": str(getattr(signal, "title", "") or getattr(signal, "source", "") or "")[:500] or None,
"before_value": before_value,
"after_value": after_value,
"ai_snapshot": {
"signal_id": getattr(signal_feedback, "signal_id", None),
"title": getattr(signal, "title", None),
"content": getattr(signal, "content", None),
"impact_score": getattr(signal, "impact_score", None),
"priority": getattr(signal, "priority", None),
"strategic_analysis": _jsonable(getattr(signal, "strategic_analysis", None)),
},
"ai_confidence": float(getattr(signal_feedback, "ai_confidence", 0.0) or 0.0),
"reason_candidates": reason_candidates,
"reason_code": reason_candidates[0]["code"] if reason_candidates else None,
"reason_text": getattr(signal_feedback, "reason", None),
"review_status": "accepted",
"reviewed_by": _safe_int(getattr(signal_feedback, "user_id", None)),
"reviewed_at": None,
"auto_assigned": False,
}
__all__ = [
"map_qa_feedback_to_record_payload",
"map_signal_feedback_to_record_payload",
"map_task_breakdown_to_record_payload",
"map_task_override_to_record_payloads",
]