demand_feedback.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. """Human feedback for demand-summary records."""
  2. from __future__ import annotations
  3. import json
  4. from typing import Any
  5. from pydantic import ValidationError
  6. from sqlalchemy import select
  7. from sqlalchemy.exc import IntegrityError
  8. from api.schemas.demand_feedback import CreateDemandFeedbackBody
  9. from api.services.video_discovery import _parse_video_ids
  10. from supply_infra.db.models.business_harness import (
  11. DailyDemandPackage,
  12. DailyDemandTask,
  13. PlatformDemandVersion,
  14. )
  15. from supply_infra.db.models.demand_feedback import DemandFeedback
  16. from supply_infra.db.repositories.demand_feedback_repo import DemandFeedbackRepository
  17. from supply_infra.db.repositories.demand_grade_repo import DemandGradeRepository
  18. from supply_infra.db.repositories.demand_video_expansion_repo import (
  19. DemandVideoExpansionRepository,
  20. )
  21. from supply_infra.db.repositories.multi_demand_video_detail_repo import (
  22. MultiDemandVideoDetailRepository,
  23. )
  24. from supply_infra.db.session import get_session
  25. _SOURCE_VIDEO_GRADES = frozenset({"B", "C", "D"})
  26. class FeedbackTargetNotFoundError(Exception):
  27. """Raised when the requested demand, video or hit content does not exist."""
  28. class FeedbackTargetConflictError(Exception):
  29. """Raised when target identifiers do not belong to the same record."""
  30. class FeedbackRequestConflictError(Exception):
  31. """Raised when an idempotency key is reused for a different request."""
  32. def _feedback_summary(count: int) -> dict[str, int]:
  33. return {"count": int(count)}
  34. def _serialize_feedback(row: DemandFeedback) -> dict[str, Any]:
  35. try:
  36. target_snapshot = json.loads(row.target_snapshot_json)
  37. except (TypeError, ValueError):
  38. target_snapshot = {}
  39. try:
  40. impact = json.loads(row.impact_json) if row.impact_json else None
  41. except (TypeError, ValueError):
  42. impact = None
  43. return {
  44. "id": int(row.id),
  45. "target_type": row.target_type,
  46. "biz_dt": row.biz_dt,
  47. "demand_grade_id": int(row.demand_grade_id),
  48. "video_id": row.video_id,
  49. "demand_video_expansion_id": (
  50. int(row.demand_video_expansion_id)
  51. if row.demand_video_expansion_id is not None
  52. else None
  53. ),
  54. "feedback_action": row.feedback_action,
  55. "reason_code": row.reason_code,
  56. "content": row.content,
  57. "target_snapshot": target_snapshot,
  58. "feedback_user": {
  59. "id": int(row.feedback_user_id),
  60. "username": row.feedback_username_snapshot,
  61. "display_name": row.feedback_user_name_snapshot,
  62. },
  63. "processing_status": row.processing_status,
  64. "platform_demand_id": row.platform_demand_id,
  65. "platform_demand_version_id": row.platform_demand_version_id,
  66. "processed_by": row.processed_by,
  67. "processed_at": (
  68. row.processed_at.isoformat() if row.processed_at is not None else None
  69. ),
  70. "resolution_reason": row.resolution_reason,
  71. "consumed_run_id": row.consumed_run_id,
  72. "impact": impact,
  73. "created_at": row.created_at.isoformat() if row.created_at else None,
  74. }
  75. def _same_request(
  76. row: DemandFeedback,
  77. body: CreateDemandFeedbackBody,
  78. feedback_user_id: int,
  79. ) -> bool:
  80. return (
  81. row.feedback_user_id == feedback_user_id
  82. and row.target_type == body.target_type
  83. and row.demand_grade_id == body.demand_grade_id
  84. and row.video_id == body.video_id
  85. and row.demand_video_expansion_id == body.demand_video_expansion_id
  86. and row.feedback_action == body.feedback_action
  87. and row.reason_code == body.reason_code
  88. and row.content == body.content
  89. )
  90. def _resolve_target(
  91. session: Any,
  92. body: CreateDemandFeedbackBody,
  93. ) -> tuple[Any, dict[str, Any]]:
  94. grade = DemandGradeRepository(session).get_by_id(body.demand_grade_id)
  95. if grade is None:
  96. raise FeedbackTargetNotFoundError("需求不存在")
  97. snapshot: dict[str, Any] = {
  98. "demand_name": grade.demand_name,
  99. "grade": grade.grade,
  100. }
  101. if body.target_type == "demand":
  102. snapshot["reason"] = grade.reason
  103. return grade, snapshot
  104. video_id = body.video_id or ""
  105. grade_code = str(grade.grade or "").upper()
  106. expansion_repo = DemandVideoExpansionRepository(session)
  107. if grade_code in _SOURCE_VIDEO_GRADES:
  108. video_exists = video_id in _parse_video_ids(grade.video_list)
  109. video_source = "pool"
  110. else:
  111. video_exists = expansion_repo.has_video(
  112. biz_dt=str(grade.biz_dt),
  113. source_demand_grade_id=int(grade.id),
  114. video_id=video_id,
  115. )
  116. video_source = "expansion"
  117. if not video_exists:
  118. raise FeedbackTargetConflictError("视频不属于当前需求")
  119. detail = MultiDemandVideoDetailRepository(session).list_by_vids([video_id]).get(
  120. video_id
  121. )
  122. snapshot.update(
  123. {
  124. "video_id": video_id,
  125. "video_title": detail.title if detail else None,
  126. "video_source": video_source,
  127. }
  128. )
  129. if body.target_type == "video":
  130. return grade, snapshot
  131. expansion = expansion_repo.get_active_by_id(body.demand_video_expansion_id or 0)
  132. if expansion is None:
  133. raise FeedbackTargetNotFoundError("命中内容不存在")
  134. if (
  135. int(expansion.source_demand_grade_id) != int(grade.id)
  136. or str(expansion.biz_dt) != str(grade.biz_dt)
  137. or str(expansion.video_id) != video_id
  138. ):
  139. raise FeedbackTargetConflictError("命中内容不属于当前需求和视频")
  140. snapshot.update(
  141. {
  142. "point_type": expansion.point_type,
  143. "expanded_text": expansion.expanded_text,
  144. "point_desc": expansion.point_desc,
  145. "reason": expansion.reason,
  146. }
  147. )
  148. return grade, snapshot
  149. def create_demand_feedback(
  150. body: CreateDemandFeedbackBody,
  151. current_user: dict[str, Any],
  152. ) -> dict[str, Any]:
  153. feedback_user_id = int(current_user["id"])
  154. feedback_username = str(current_user["username"])
  155. feedback_user_name = str(
  156. current_user.get("display_name") or current_user.get("username") or feedback_user_id
  157. )
  158. with get_session() as session:
  159. repo = DemandFeedbackRepository(session)
  160. existing = repo.get_by_client_request_id(body.client_request_id)
  161. if existing is not None:
  162. if not _same_request(existing, body, feedback_user_id):
  163. raise FeedbackRequestConflictError("请求标识已用于其他反馈")
  164. _, video_counts, expansion_counts = repo.count_for_demand(
  165. body.demand_grade_id
  166. )
  167. count = (
  168. repo.count_demands([body.demand_grade_id]).get(body.demand_grade_id, 0)
  169. if body.target_type == "demand"
  170. else video_counts.get(body.video_id or "", 0)
  171. if body.target_type == "video"
  172. else expansion_counts.get(body.demand_video_expansion_id or 0, 0)
  173. )
  174. return {
  175. "item": _serialize_feedback(existing),
  176. "feedback_summary": _feedback_summary(count),
  177. }
  178. grade, target_snapshot = _resolve_target(session, body)
  179. platform_version = session.scalar(
  180. select(PlatformDemandVersion)
  181. .join(
  182. DailyDemandTask,
  183. DailyDemandTask.platform_demand_version_id
  184. == PlatformDemandVersion.platform_demand_version_id,
  185. )
  186. .join(
  187. DailyDemandPackage,
  188. DailyDemandPackage.demand_package_id
  189. == DailyDemandTask.demand_package_id,
  190. )
  191. .where(
  192. PlatformDemandVersion.source_demand_grade_id == int(grade.id),
  193. DailyDemandPackage.biz_dt == str(grade.biz_dt),
  194. DailyDemandPackage.status == "published",
  195. )
  196. .order_by(DailyDemandPackage.package_version.desc())
  197. .limit(1)
  198. )
  199. feedback = DemandFeedback(
  200. client_request_id=body.client_request_id,
  201. target_type=body.target_type,
  202. biz_dt=str(grade.biz_dt),
  203. demand_grade_id=int(grade.id),
  204. video_id=body.video_id,
  205. demand_video_expansion_id=body.demand_video_expansion_id,
  206. feedback_action=body.feedback_action,
  207. reason_code=body.reason_code,
  208. content=body.content,
  209. target_snapshot_json=json.dumps(
  210. target_snapshot,
  211. ensure_ascii=False,
  212. separators=(",", ":"),
  213. ),
  214. feedback_user_id=feedback_user_id,
  215. feedback_user_name_snapshot=feedback_user_name[:128],
  216. feedback_username_snapshot=feedback_username[:64],
  217. processing_status="pending",
  218. platform_demand_id=(
  219. platform_version.platform_demand_id
  220. if platform_version is not None
  221. else None
  222. ),
  223. platform_demand_version_id=(
  224. platform_version.platform_demand_version_id
  225. if platform_version is not None
  226. else None
  227. ),
  228. )
  229. try:
  230. repo.add(feedback)
  231. session.refresh(feedback)
  232. except IntegrityError:
  233. session.rollback()
  234. existing = repo.get_by_client_request_id(body.client_request_id)
  235. if existing is None or not _same_request(existing, body, feedback_user_id):
  236. raise FeedbackRequestConflictError("请求标识已用于其他反馈") from None
  237. feedback = existing
  238. rows, total = repo.list_for_target(
  239. target_type=body.target_type,
  240. demand_grade_id=body.demand_grade_id,
  241. video_id=body.video_id,
  242. demand_video_expansion_id=body.demand_video_expansion_id,
  243. limit=1,
  244. offset=0,
  245. )
  246. del rows
  247. return {
  248. "item": _serialize_feedback(feedback),
  249. "feedback_summary": _feedback_summary(total),
  250. }
  251. def list_demand_feedback(
  252. *,
  253. target_type: str,
  254. demand_grade_id: int,
  255. video_id: str | None,
  256. demand_video_expansion_id: int | None,
  257. limit: int,
  258. offset: int,
  259. ) -> dict[str, Any]:
  260. try:
  261. body = CreateDemandFeedbackBody(
  262. client_request_id="history-query",
  263. target_type=target_type,
  264. demand_grade_id=demand_grade_id,
  265. video_id=video_id,
  266. demand_video_expansion_id=demand_video_expansion_id,
  267. feedback_action="support",
  268. )
  269. except ValidationError as exc:
  270. raise FeedbackTargetConflictError("反馈目标参数不完整") from exc
  271. with get_session() as session:
  272. repo = DemandFeedbackRepository(session)
  273. rows, total = repo.list_for_target(
  274. target_type=target_type,
  275. demand_grade_id=demand_grade_id,
  276. video_id=video_id,
  277. demand_video_expansion_id=demand_video_expansion_id,
  278. limit=limit,
  279. offset=offset,
  280. )
  281. if total == 0:
  282. _resolve_target(session, body)
  283. return {
  284. "items": [_serialize_feedback(row) for row in rows],
  285. "total": total,
  286. "limit": limit,
  287. "offset": offset,
  288. }