| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222 |
- """
- 批量保存视频点位拓展判断结果。
- """
- from __future__ import annotations
- import logging
- import re
- import uuid
- from typing import Any
- from supply_agent.tools import tool
- from supply_infra.db.models.multi_demand_video_point import POINT_TYPES
- from supply_infra.db.repositories.demand_video_expansion_repo import (
- DemandVideoExpansionRepository,
- )
- from supply_infra.db.repositories.multi_demand_video_point_repo import (
- MultiDemandVideoPointRepository,
- )
- from supply_infra.db.session import get_session
- logger = logging.getLogger(__name__)
- _VALID_GRADES = frozenset({"S", "A"})
- def _optional_str(value: Any) -> str | None:
- if value is None:
- return None
- text = str(value).strip()
- return text or None
- def _normalize_items(
- items: list[dict[str, Any]],
- *,
- default_run_id: str,
- biz_dt: str,
- source_demand_grade_id: int,
- source_demand_name: str,
- source_grade: str,
- ) -> tuple[list[dict[str, Any]], list[str]]:
- rows: list[dict[str, Any]] = []
- errors: list[str] = []
- seen_keys: set[tuple[str, str]] = set()
- for idx, item in enumerate(items):
- if not isinstance(item, dict):
- errors.append(f"第 {idx} 项不是对象")
- continue
- expanded_text = _optional_str(item.get("expanded_text"))
- point_type = _optional_str(item.get("point_type"))
- video_id = _optional_str(item.get("video_id"))
- reason = _optional_str(item.get("reason"))
- if not expanded_text:
- errors.append(f"第 {idx} 项缺少 expanded_text")
- continue
- if not point_type or point_type not in POINT_TYPES:
- allowed = " / ".join(POINT_TYPES)
- errors.append(f"第 {idx} 项 point_type 无效(只能是 {allowed}): {point_type!r}")
- continue
- if not video_id:
- errors.append(f"第 {idx} 项缺少 video_id")
- continue
- if not reason:
- errors.append(f"第 {idx} 项缺少 reason")
- continue
- dedupe_key = (expanded_text, video_id)
- if dedupe_key in seen_keys:
- errors.append(f"第 {idx} 项在本次请求中重复: {expanded_text!r} / {video_id!r}")
- continue
- seen_keys.add(dedupe_key)
- rows.append(
- {
- "biz_dt": biz_dt,
- "run_id": _optional_str(item.get("run_id")) or default_run_id,
- "source_demand_grade_id": source_demand_grade_id,
- "source_demand_name": source_demand_name,
- "source_grade": source_grade,
- "expanded_text": expanded_text,
- "point_type": point_type,
- "point_desc": _optional_str(item.get("point_desc")),
- "video_id": video_id,
- "reason": reason,
- "is_delete": 0,
- }
- )
- return rows, errors
- def _normalize_demand_name(name: str) -> str:
- text = re.sub(r"\s+", "", name.strip().lower())
- return re.sub(r"[,,。..!!??;;::""''\"'、/\\|·—_()()【】\[\]《》<>]", "", text)
- def _fill_missing_point_descs(rows: list[dict[str, Any]], session) -> None:
- """point_desc 为空时,从 multi_demand_video_point 按 video_id/point_type/point_data 补全。
- 查不到或源表 point_desc 也为空时,保持原值(仍为 None)。
- """
- missing = [row for row in rows if not row.get("point_desc")]
- if not missing:
- return
- video_ids = sorted({str(row["video_id"]) for row in missing})
- points_by_vid = MultiDemandVideoPointRepository(session).list_by_video_ids(video_ids)
- lookup: dict[tuple[str, str, str], str] = {}
- for vid, points in points_by_vid.items():
- for point in points:
- point_data = _optional_str(point.get("point_data"))
- point_type = _optional_str(point.get("point_type"))
- point_desc = _optional_str(point.get("point_desc"))
- if not point_data or not point_type or not point_desc:
- continue
- lookup[(vid, point_type, point_data)] = point_desc
- for row in missing:
- key = (str(row["video_id"]), str(row["point_type"]), str(row["expanded_text"]))
- desc = lookup.get(key)
- if desc:
- row["point_desc"] = desc
- # 未命中或源表无描述:不改动,保留空值
- @tool
- def batch_save_demand_expansions(
- items: list[dict[str, Any]],
- biz_dt: str,
- source_demand_grade_id: int,
- source_demand_name: str,
- source_grade: str,
- run_id: str | None = None,
- ) -> str:
- """
- 保存视频点位拓展判断结果到 demand_video_expansion 表。
- Args:
- items: 拓展候选列表。每项必填:
- - expanded_text: 拓展需求文本(来自 point_data)
- - point_type: inspiration / purpose / key
- - video_id: 来源视频 id
- - reason: 为何与原需求相近、可作为拓展
- 选填:
- - point_desc: 点位描述快照
- biz_dt: 业务日 YYYYMMDD。
- source_demand_grade_id: 来源 demand_grade.id(由用户消息提供,原样传入)。
- source_demand_name: 来源需求名。
- source_grade: 来源等级 S 或 A。
- run_id: 任务批次 id;省略则自动生成。
- Returns:
- 保存结果摘要。若无合适拓展,传 items=[] 即可。
- """
- biz_dt_text = _optional_str(biz_dt)
- if not biz_dt_text or len(biz_dt_text) != 8 or not biz_dt_text.isdigit():
- return f"biz_dt 格式无效,应为 YYYYMMDD: {biz_dt!r}"
- demand_name = _optional_str(source_demand_name)
- if not demand_name:
- return "source_demand_name 不能为空"
- grade = _optional_str(source_grade)
- if grade not in _VALID_GRADES:
- return f"source_grade 无效,只能是 S 或 A: {source_grade!r}"
- try:
- grade_id = int(source_demand_grade_id)
- except (TypeError, ValueError):
- return f"source_demand_grade_id 无效: {source_demand_grade_id!r}"
- if not items:
- return "无拓展候选,跳过落库"
- default_run_id = _optional_str(run_id) or uuid.uuid4().hex
- rows, errors = _normalize_items(
- items,
- default_run_id=default_run_id,
- biz_dt=biz_dt_text,
- source_demand_grade_id=grade_id,
- source_demand_name=demand_name,
- source_grade=grade,
- )
- normalized_demand = _normalize_demand_name(demand_name)
- filtered_rows: list[dict[str, Any]] = []
- skipped_same: list[str] = []
- for row in rows:
- if _normalize_demand_name(row["expanded_text"]) == normalized_demand:
- skipped_same.append(row["expanded_text"])
- continue
- filtered_rows.append(row)
- if not filtered_rows:
- detail = ";".join(errors) if errors else "无有效数据"
- same_note = ""
- if skipped_same:
- same_note = f";剔除与原需求相同 {len(skipped_same)} 条"
- return f"没有可保存的数据: {detail}{same_note}"
- try:
- with get_session() as session:
- _fill_missing_point_descs(filtered_rows, session)
- saved = DemandVideoExpansionRepository(session).bulk_upsert(filtered_rows)
- parts = [f"成功保存 {saved} 条拓展需求", f"biz_dt={biz_dt_text}", f"run_id={default_run_id}"]
- if skipped_same:
- parts.append(f"剔除与原需求相同 {len(skipped_same)} 条")
- if errors:
- parts.append(f"校验失败 {len(errors)} 条: " + ";".join(errors[:10]))
- message = "。".join(parts)
- logger.info("batch_save_demand_expansions completed: %s", message)
- return message
- except Exception as e:
- logger.error("batch_save_demand_expansions failed: %s", e, exc_info=True)
- return f"保存拓展需求失败: {e}"
|