""" 批量保存视频点位拓展判断结果。 """ 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}"