307 lines
13 KiB
Python
307 lines
13 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 []),
|
|
"citations": _jsonable(getattr(qa_record, "citations", None) or []),
|
|
"sources_used": _jsonable(getattr(qa_record, "sources_used", 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 []),
|
|
"answer_mode": getattr(qa_record, "answer_mode", None),
|
|
"grounding_status": getattr(qa_record, "grounding_status", None),
|
|
"policy_version": getattr(qa_record, "policy_version", None),
|
|
"citations": _jsonable(getattr(qa_record, "citations", None) or []),
|
|
"sources_used": _jsonable(getattr(qa_record, "sources_used", None) or []),
|
|
"evidence_pack": _jsonable(getattr(qa_record, "evidence_pack", None) or {}),
|
|
"tool_trace": _jsonable(getattr(qa_record, "tool_trace", None) or []),
|
|
"retrieval_metrics": _jsonable(getattr(qa_record, "retrieval_metrics", 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",
|
|
]
|