|
|
@@ -0,0 +1,1688 @@
|
|
|
+import asyncio
|
|
|
+import json
|
|
|
+import os
|
|
|
+import sys
|
|
|
+import argparse
|
|
|
+from datetime import datetime
|
|
|
+from typing import Literal
|
|
|
+
|
|
|
+from agents import Agent, Runner, ModelSettings
|
|
|
+from lib.my_trace import set_trace
|
|
|
+from pydantic import BaseModel, Field
|
|
|
+
|
|
|
+from lib.utils import read_file_as_string
|
|
|
+from lib.client import get_model
|
|
|
+MODEL_NAME = "google/gemini-2.5-flash"
|
|
|
+from script.search_recommendations.xiaohongshu_search_recommendations import XiaohongshuSearchRecommendations
|
|
|
+from script.search.xiaohongshu_search import XiaohongshuSearch
|
|
|
+
|
|
|
+
|
|
|
+# ============================================================================
|
|
|
+# 日志工具类
|
|
|
+# ============================================================================
|
|
|
+
|
|
|
+class TeeLogger:
|
|
|
+ """同时输出到控制台和日志文件的工具类"""
|
|
|
+ def __init__(self, stdout, log_file):
|
|
|
+ self.stdout = stdout
|
|
|
+ self.log_file = log_file
|
|
|
+
|
|
|
+ def write(self, message):
|
|
|
+ self.stdout.write(message)
|
|
|
+ self.log_file.write(message)
|
|
|
+ self.log_file.flush() # 实时写入,避免丢失日志
|
|
|
+
|
|
|
+ def flush(self):
|
|
|
+ self.stdout.flush()
|
|
|
+ self.log_file.flush()
|
|
|
+
|
|
|
+
|
|
|
+# ============================================================================
|
|
|
+# 数据模型
|
|
|
+# ============================================================================
|
|
|
+
|
|
|
+class Seg(BaseModel):
|
|
|
+ """分词"""
|
|
|
+ text: str
|
|
|
+ score_with_o: float = 0.0 # 与原始问题的评分
|
|
|
+ reason: str = "" # 评分理由
|
|
|
+ from_o: str = "" # 原始问题
|
|
|
+
|
|
|
+
|
|
|
+class Word(BaseModel):
|
|
|
+ """词"""
|
|
|
+ text: str
|
|
|
+ score_with_o: float = 0.0 # 与原始问题的评分
|
|
|
+ from_o: str = "" # 原始问题
|
|
|
+
|
|
|
+
|
|
|
+class QFromQ(BaseModel):
|
|
|
+ """Q来源信息(用于Sug中记录)"""
|
|
|
+ text: str
|
|
|
+ score_with_o: float = 0.0
|
|
|
+
|
|
|
+
|
|
|
+class Q(BaseModel):
|
|
|
+ """查询"""
|
|
|
+ text: str
|
|
|
+ score_with_o: float = 0.0 # 与原始问题的评分
|
|
|
+ reason: str = "" # 评分理由
|
|
|
+ from_source: str = "" # seg/sug/add(加词)
|
|
|
+
|
|
|
+
|
|
|
+class Sug(BaseModel):
|
|
|
+ """建议词"""
|
|
|
+ text: str
|
|
|
+ score_with_o: float = 0.0 # 与原始问题的评分
|
|
|
+ reason: str = "" # 评分理由
|
|
|
+ from_q: QFromQ | None = None # 来自的q
|
|
|
+
|
|
|
+
|
|
|
+class Seed(BaseModel):
|
|
|
+ """种子"""
|
|
|
+ text: str
|
|
|
+ added_words: list[str] = Field(default_factory=list) # 已经增加的words
|
|
|
+ from_type: str = "" # seg/sug/add
|
|
|
+ score_with_o: float = 0.0 # 与原始问题的评分
|
|
|
+
|
|
|
+
|
|
|
+class Post(BaseModel):
|
|
|
+ """帖子"""
|
|
|
+ title: str = ""
|
|
|
+ body_text: str = ""
|
|
|
+ type: str = "normal" # video/normal
|
|
|
+ images: list[str] = Field(default_factory=list) # 图片url列表,第一张为封面
|
|
|
+ video: str = "" # 视频url
|
|
|
+ interact_info: dict = Field(default_factory=dict) # 互动信息
|
|
|
+ note_id: str = ""
|
|
|
+ note_url: str = ""
|
|
|
+
|
|
|
+
|
|
|
+class Search(Sug):
|
|
|
+ """搜索结果(继承Sug)"""
|
|
|
+ post_list: list[Post] = Field(default_factory=list) # 搜索得到的帖子列表
|
|
|
+
|
|
|
+
|
|
|
+class RunContext(BaseModel):
|
|
|
+ """运行上下文"""
|
|
|
+ version: str
|
|
|
+ input_files: dict[str, str]
|
|
|
+ c: str # 原始需求
|
|
|
+ o: str # 原始问题
|
|
|
+ log_url: str
|
|
|
+ log_dir: str
|
|
|
+
|
|
|
+ # 每轮的数据
|
|
|
+ rounds: list[dict] = Field(default_factory=list) # 每轮的详细数据
|
|
|
+
|
|
|
+ # 最终结果
|
|
|
+ final_output: str | None = None
|
|
|
+
|
|
|
+ # 评估缓存:避免重复评估相同文本
|
|
|
+ evaluation_cache: dict[str, tuple[float, str]] = Field(default_factory=dict)
|
|
|
+ # key: 文本, value: (score, reason)
|
|
|
+
|
|
|
+
|
|
|
+# ============================================================================
|
|
|
+# Agent 定义
|
|
|
+# ============================================================================
|
|
|
+
|
|
|
+# Agent 1: 分词专家
|
|
|
+class WordSegmentation(BaseModel):
|
|
|
+ """分词结果"""
|
|
|
+ words: list[str] = Field(..., description="分词结果列表")
|
|
|
+ reasoning: str = Field(..., description="分词理由")
|
|
|
+
|
|
|
+word_segmentation_instructions = """
|
|
|
+你是分词专家。给定一个query,将其拆分成有意义的最小单元。
|
|
|
+
|
|
|
+## 分词原则
|
|
|
+1. 保留有搜索意义的词汇
|
|
|
+2. 拆分成独立的概念
|
|
|
+3. 保留专业术语的完整性
|
|
|
+4. 去除虚词(的、吗、呢等)
|
|
|
+如果是双标行为,单独分词 不拆分,如果有如何两个字 不要
|
|
|
+
|
|
|
+## 输出要求
|
|
|
+返回分词列表和分词理由。
|
|
|
+""".strip()
|
|
|
+
|
|
|
+word_segmenter = Agent[None](
|
|
|
+ name="分词专家",
|
|
|
+ instructions=word_segmentation_instructions,
|
|
|
+ model=get_model(MODEL_NAME),
|
|
|
+ output_type=WordSegmentation,
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+# Agent 2: 动机维度评估专家 + 品类维度评估专家(两阶段评估)
|
|
|
+
|
|
|
+# 动机评估的嵌套模型
|
|
|
+class CoreMotivationExtraction(BaseModel):
|
|
|
+ """核心动机提取"""
|
|
|
+ 简要说明核心动机: str = Field(..., description="核心动机说明")
|
|
|
+
|
|
|
+class MotivationEvaluation(BaseModel):
|
|
|
+ """动机维度评估"""
|
|
|
+ 原始问题核心动机提取: CoreMotivationExtraction = Field(..., description="原始问题核心动机提取")
|
|
|
+ 动机维度得分: float = Field(..., description="动机维度得分 -1~1")
|
|
|
+ 简要说明动机维度相关度理由: str = Field(..., description="动机维度相关度理由")
|
|
|
+
|
|
|
+class CategoryEvaluation(BaseModel):
|
|
|
+ """品类维度评估"""
|
|
|
+ 品类维度得分: float = Field(..., description="品类维度得分 -1~1")
|
|
|
+ 简要说明品类维度相关度理由: str = Field(..., description="品类维度相关度理由")
|
|
|
+
|
|
|
+# 动机评估 prompt - 第一轮版本(来自 sug_v6_1_2_115.py)
|
|
|
+motivation_evaluation_instructions_round1 = """
|
|
|
+#角色
|
|
|
+你是一个 **专业的语言专家和语义相关性评判专家**。你的任务是:判断我给你的 <平台sug词条> 与 <原始问题> 的需求动机匹配度,给出 **-1 到 1 之间** 的数值评分。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+# 核心概念与方法论
|
|
|
+
|
|
|
+## 评估维度
|
|
|
+本评估系统围绕 **动机维度** 进行:
|
|
|
+
|
|
|
+### 1. 动机维度
|
|
|
+**定义:** 用户"想要做什么",即原始问题的行为意图和目的
|
|
|
+- 核心是 **动词**:获取、学习、拍摄、制作、寻找等
|
|
|
+- 包括:核心动作 + 使用场景 + 最终目的
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 如何识别原始问题的核心动机
|
|
|
+
|
|
|
+**核心动机必须是动词**,识别方法如下:
|
|
|
+
|
|
|
+### 方法1: 显性动词直接提取
|
|
|
+
|
|
|
+当原始问题明确包含动词时,直接提取
|
|
|
+示例:
|
|
|
+"如何获取素材" → 核心动机 = "获取"
|
|
|
+"寻找拍摄技巧" → 核心动机 = "寻找"(或"学习")
|
|
|
+"制作视频教程" → 核心动机 = "制作"
|
|
|
+
|
|
|
+### 方法2: 隐性动词语义推理
|
|
|
+
|
|
|
+当原始问题没有显性动词时,需要结合上下文推理
|
|
|
+示例:
|
|
|
+例: "川西秋天风光摄影" → 隐含动作="拍摄"
|
|
|
+→ 需结合上下文判断
|
|
|
+
|
|
|
+如果原始问题是纯名词短语,无任何动作线索:
|
|
|
+→ 核心动机 = 无法识别
|
|
|
+→ 在此情况下,动机维度得分应为 0。
|
|
|
+示例:
|
|
|
+"摄影" → 无法识别动机,动机维度得分 = 0
|
|
|
+"川西风光" → 无法识别动机,动机维度得分 = 0
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+# 输入信息
|
|
|
+你将接收到以下输入:
|
|
|
+- **<原始问题>**:用户的初始查询问题,代表用户的真实需求意图。
|
|
|
+- **<平台sug词条>**:平台推荐的词条列表,每个词条需要单独评估。
|
|
|
+
|
|
|
+
|
|
|
+#判定流程
|
|
|
+#评估架构
|
|
|
+
|
|
|
+输入: <原始问题> + <平台sug词条>
|
|
|
+ ↓
|
|
|
+【动机维度相关性判定】
|
|
|
+ ├→ 步骤1: 评估<sug词条>与<原始问题>的需求动机匹配度
|
|
|
+ └→ 输出: -1到1之间的数值 + 判定依据
|
|
|
+
|
|
|
+
|
|
|
+相关度评估维度详解
|
|
|
+维度1: 动机维度评估
|
|
|
+评估对象: <平台sug词条> 与 <原始问题> 的需求动机匹配度
|
|
|
+说明: 核心动作是用户需求的第一优先级,决定了推荐的基本有效性
|
|
|
+
|
|
|
+
|
|
|
+评分标准:
|
|
|
+
|
|
|
+【正向匹配】
|
|
|
++0.95~1.0: 核心动作完全一致
|
|
|
+ - 例: 原始问题"如何获取素材" vs sug词"素材获取方法"
|
|
|
+ - 特殊规则: 如果sug词的核心动作是原始问题动作的**具体化子集**,也判定为完全一致
|
|
|
+ · 例: 原始问题"扣除猫咪主体的方法" vs sug词"扣除猫咪眼睛的方法"(子集但目的一致)
|
|
|
+
|
|
|
++0.75~0.95: 核心动作语义相近或为同义表达
|
|
|
+ - 例: 原始问题"如何获取素材" vs sug词"如何下载素材"
|
|
|
+ - 同义词对: 获取≈下载≈寻找, 技巧≈方法≈教程≈攻略
|
|
|
+
|
|
|
++0.5~0.75: 核心动作相关但非直接对应(相关实现路径)
|
|
|
+ - 例: 原始问题"如何获取素材" vs sug词"素材管理整理"
|
|
|
+
|
|
|
++0.2~0.45: 核心动作弱相关(同领域不同动作)
|
|
|
+ - 例: 原始问题"如何拍摄风光" vs sug词"风光摄影欣赏"
|
|
|
+
|
|
|
+【中性/无关】
|
|
|
+0: 没有明确目的,动作意图无明确关联
|
|
|
+ - 例: 原始问题"如何获取素材" vs sug词"摄影器材推荐"
|
|
|
+ - 例: 原始问题无法识别动机 且 sug词也无明确动作 → 0
|
|
|
+ - 如果原始问题无法识别动机,则动机维度得分为0。
|
|
|
+
|
|
|
+【负向偏离】
|
|
|
+-0.2~-0.05: 动作意图轻度冲突或误导
|
|
|
+ - 例: 原始问题"如何获取素材" vs sug词"素材版权保护须知"
|
|
|
+
|
|
|
+-0.5~-0.25: 动作意图明显对立
|
|
|
+ - 例: 原始问题"如何获取免费素材" vs sug词"如何售卖素材"
|
|
|
+
|
|
|
+-1.0~-0.55: 动作意图完全相反或产生严重负面引导
|
|
|
+ - 例: 原始问题"免费素材获取" vs sug词"付费素材强制推销"
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+# 输出要求
|
|
|
+
|
|
|
+输出结果必须为一个 **JSON 格式**,包含以下内容:
|
|
|
+```json
|
|
|
+{
|
|
|
+ "原始问题核心动机提取": {
|
|
|
+ "简要说明核心动机": ""
|
|
|
+ },
|
|
|
+ "动机维度得分": "-1到1之间的小数",
|
|
|
+ "简要说明动机维度相关度理由": "评估该sug词条与原始问题动机匹配程度的理由"
|
|
|
+}
|
|
|
+
|
|
|
+**输出约束(非常重要)**:
|
|
|
+1. **字符串长度限制**:\"简要说明动机维度相关度理由\"字段必须控制在**150字以内**
|
|
|
+2. **JSON格式规范**:必须生成完整的JSON格式,确保字符串用双引号包裹且正确闭合
|
|
|
+3. **引号使用**:字符串中如需表达引用,请使用《》或「」代替单引号或双引号
|
|
|
+
|
|
|
+#注意事项:
|
|
|
+始终围绕动机维度:所有评估都基于"动机"维度,不偏离
|
|
|
+核心动机必须是动词:在评估前,必须先提取原始问题的核心动机(动词),这是整个评估的基础
|
|
|
+严格标准一致性:对所有用例使用相同的评估标准,避免评分飘移
|
|
|
+负分使用原则:仅当sug词条对原始问题动机产生误导、冲突或有害引导时给予负分
|
|
|
+零分使用原则:当sug词条与原始问题动机无明确关联,既不相关也不冲突时给予零分,或原始问题无法识别动机时。
|
|
|
+""".strip()
|
|
|
+
|
|
|
+# 动机评估 prompt - 后续轮次版本(当前 116 版本)
|
|
|
+motivation_evaluation_instructions = """
|
|
|
+#角色
|
|
|
+你是一个 **专业的语言专家和语义相关性评判专家**。你的任务是:判断我给你的 <平台sug词条> 与 <原始问题> 的需求动机匹配度,给出 **-1 到 1 之间** 的数值评分。
|
|
|
+
|
|
|
+---
|
|
|
+# 动机评估核心原则(必读)
|
|
|
+
|
|
|
+### 动机 = 动作 + 对象 + 场景
|
|
|
+评估时必须同时考虑三要素,不能只看动词:
|
|
|
+- **动作**:制定、规划、获取、拍摄等
|
|
|
+- **对象**:旅行行程 vs 每日计划、风光照片 vs 证件照
|
|
|
+- **场景**:旅游 vs 日常、摄影 vs 办公
|
|
|
+
|
|
|
+### 关键判断:动词相同 ≠ 动机匹配
|
|
|
+
|
|
|
+错误:只看动词相同就给高分
|
|
|
+- "制定旅行行程" vs "制定每日计划" → 给0.95 错误
|
|
|
+- "拍摄风光" vs "拍摄证件照" → 给0.95 错误
|
|
|
+
|
|
|
+正确:检查对象和场景是否匹配
|
|
|
+- 对象不同领域 → 降至0.3左右
|
|
|
+- 场景不同 → 降至0.3左右
|
|
|
+
|
|
|
+
|
|
|
+# 核心概念与方法论
|
|
|
+
|
|
|
+## 评估维度
|
|
|
+本评估系统围绕 **动机维度** 进行:
|
|
|
+
|
|
|
+# 维度独立性警告
|
|
|
+【严格约束】本评估**只评估动机维度**:
|
|
|
+**禁止使用"主题相关"作为评分依据**:评分理由中不得出现"主题"、"内容"、"话题"等词
|
|
|
+
|
|
|
+### 1. 动机维度
|
|
|
+**定义:** 用户"想要做什么",即原始问题的行为意图和目的
|
|
|
+- 核心是 **动词**:获取、学习、拍摄、制作、寻找等
|
|
|
+- 包括:核心动作 + 使用场景 + 最终目的
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+如果原始问题是纯名词短语,无任何动作线索:
|
|
|
+→ 核心动机 = 无法识别
|
|
|
+→ 在此情况下,动机维度得分应为 0。
|
|
|
+示例:
|
|
|
+"摄影" → 无法识别动机,动机维度得分 = 0
|
|
|
+"川西风光" → 无法识别动机,动机维度得分 = 0
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+# 输入信息
|
|
|
+你将接收到以下输入:
|
|
|
+- **<原始问题>**:用户的初始查询问题,代表用户的真实需求意图。
|
|
|
+- **<平台sug词条>**:平台推荐的词条列表,每个词条需要单独评估。
|
|
|
+
|
|
|
+
|
|
|
+#判定流程
|
|
|
+#评估架构
|
|
|
+
|
|
|
+输入: <原始问题> + <平台sug词条>
|
|
|
+ ↓
|
|
|
+【动机维度相关性判定】
|
|
|
+ ├→ 步骤1: 评估<sug词条>与<原始问题>的需求动机匹配度
|
|
|
+ └→ 输出: -1到1之间的数值 + 判定依据
|
|
|
+
|
|
|
+
|
|
|
+相关度评估维度详解
|
|
|
+维度1: 动机维度评估
|
|
|
+评估对象: <平台sug词条> 与 <原始问题> 的需求动机匹配度
|
|
|
+说明: 核心动作是用户需求的第一优先级,决定了推荐的基本有效性
|
|
|
+
|
|
|
+
|
|
|
+评分标准:
|
|
|
+
|
|
|
+【正向匹配】
|
|
|
++0.95~1.0: 动作+对象+场景完全一致
|
|
|
+ - 要求:动词、对象、场景都必须匹配,不能只看动词
|
|
|
+ - "制定旅行行程" vs "制定每日计划"
|
|
|
+ 虽然动词相同,但对象和场景完全不同,不属于高分
|
|
|
+ - 特殊规则: 如果sug词的核心动作是原始问题动作在动作+对象+场景一致下的**具体化子集**,也判定为完全一致
|
|
|
+
|
|
|
+
|
|
|
++0.75~0.95: 核心动作语义相近或为同义表达
|
|
|
+ - 例: 原始问题"如何获取素材" vs sug词"如何下载素材"
|
|
|
+ - 同义词对: 获取≈下载≈寻找, 技巧≈方法≈教程≈攻略
|
|
|
+
|
|
|
++0.5~0.75: 核心动作相关但非直接对应(相关实现路径)
|
|
|
+ - 例: 原始问题"如何获取素材" vs sug词"素材管理整理"
|
|
|
+
|
|
|
++0.25~0.4: 动词相同但对象或场景明显不同(弱相关)
|
|
|
+ - 判断要点:动词一致,但对象不同领域或场景不同
|
|
|
+ - 关键:不要因为动词相同就给0.95,必须检查对象!
|
|
|
+
|
|
|
+
|
|
|
+【中性/无关】
|
|
|
+0: 没有明确目的,动作意图无明确关联
|
|
|
+ - 例: 原始问题"如何获取素材" vs sug词"摄影器材推荐"
|
|
|
+ - 例: 原始问题无法识别动机 且 sug词也无明确动作 → 0
|
|
|
+ - 如果原始问题无法识别动机,则动机维度得分为0
|
|
|
+
|
|
|
+ 特别注意 - 禁止的错误理由:
|
|
|
+ - 禁止: "虽然没有动作,但主题相关,所以给0.2"
|
|
|
+ - 禁止:"内容有参考价值,所以给0.15"
|
|
|
+ - 禁止: "都提到了XX(名词),所以不是完全无关"
|
|
|
+ - 正确理由:"sug词条无动作意图,与原始问题的'XX'动机完全无关"
|
|
|
+
|
|
|
+【负向偏离】
|
|
|
+-0.2~-0.05: 动作意图轻度冲突或误导
|
|
|
+ - 例: 原始问题"如何获取素材" vs sug词"素材版权保护须知"
|
|
|
+
|
|
|
+-0.5~-0.25: 动作意图明显对立
|
|
|
+ - 例: 原始问题"如何获取免费素材" vs sug词"如何售卖素材"
|
|
|
+
|
|
|
+-1.0~-0.55: 动作意图完全相反或产生严重负面引导
|
|
|
+ - 例: 原始问题"免费素材获取" vs sug词"付费素材强制推销"
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+# 输出要求
|
|
|
+
|
|
|
+输出结果必须为一个 **JSON 格式**,包含以下内容:
|
|
|
+```json
|
|
|
+{
|
|
|
+ "原始问题核心动机提取": {
|
|
|
+ "简要说明核心动机": ""
|
|
|
+ },
|
|
|
+ "动机维度得分": "-1到1之间的小数",
|
|
|
+ "简要说明动机维度相关度理由": "评估该sug词条与原始问题动机匹配程度的理由"
|
|
|
+}
|
|
|
+
|
|
|
+**输出约束(非常重要)**:
|
|
|
+1. **字符串长度限制**:\"简要说明动机维度相关度理由\"字段必须控制在**150字以内**
|
|
|
+2. **JSON格式规范**:必须生成完整的JSON格式,确保字符串用双引号包裹且正确闭合
|
|
|
+3. **引号使用**:字符串中如需表达引用,请使用《》或「」代替单引号或双引号
|
|
|
+
|
|
|
+#注意事项:
|
|
|
+始终围绕动机维度:所有评估都基于"动机"维度,不偏离
|
|
|
+核心动机必须是动词:在评估前,必须先提取原始问题的核心动机(动词),这是整个评估的基础
|
|
|
+严格标准一致性:对所有用例使用相同的评估标准,避免评分飘移
|
|
|
+负分使用原则:仅当sug词条对原始问题动机产生误导、冲突或有害引导时给予负分
|
|
|
+零分使用原则:当sug词条与原始问题动机无明确关联,既不相关也不冲突时给予零分,或原始问题无法识别动机时。
|
|
|
+""".strip()
|
|
|
+
|
|
|
+# 品类评估 prompt
|
|
|
+category_evaluation_instructions = """
|
|
|
+#角色
|
|
|
+你是一个 **专业的语言专家和语义相关性评判专家**。你的任务是:判断我给你的 <平台sug词条> 与 <原始问题> 的内容主体和限定词匹配度,给出 **-1 到 1 之间** 的数值评分。
|
|
|
+
|
|
|
+---
|
|
|
+# 核心概念与方法论
|
|
|
+
|
|
|
+## 评估维度
|
|
|
+本评估系统围绕 **品类维度** 进行:
|
|
|
+
|
|
|
+# 维度独立性警告
|
|
|
+【严格约束】本评估**只评估品类维度**,,必须遵守以下规则:
|
|
|
+1. **只看名词和限定词**:评估时只考虑主体、限定词的匹配度
|
|
|
+2. **完全忽略动词**:动作意图、目的等动机信息对本维度评分无影响
|
|
|
+
|
|
|
+### 品类维度
|
|
|
+**定义:** 用户"关于什么内容",即原始问题的主题对象和限定词
|
|
|
+- 核心是 **名词+限定词**:川西秋季风光摄影素材
|
|
|
+- 包括:核心主体 + 地域限定 + 时间限定 + 质量限定等
|
|
|
+
|
|
|
+## ⚠️ 品类评估核心原则(必读)
|
|
|
+
|
|
|
+### 原则1:只看词条表面,禁止联想推演
|
|
|
+- 只能基于sug词实际包含的词汇评分
|
|
|
+- 禁止推测"可能包含"、"可以理解为"
|
|
|
+
|
|
|
+**错误示例:**
|
|
|
+原始问题:"川西旅行行程" vs sug词:"每日计划"
|
|
|
+- 错误 "每日计划可以包含旅行规划,所以有关联" → 这是不允许的联想
|
|
|
+- 正确: "sug词只有'每日计划',无'旅行'字眼,品类不匹配" → 正确判断
|
|
|
+
|
|
|
+### 原则2:通用概念 ≠ 特定概念
|
|
|
+- **通用**:计划、方法、技巧、素材(无领域限定)
|
|
|
+- **特定**:旅行行程、摄影技巧、烘焙方法(有明确领域)
|
|
|
+
|
|
|
+IF sug词是通用 且 原始问题是特定:
|
|
|
+ → 品类不匹配 → 评分0.05~0.1
|
|
|
+关键:通用概念不等于特定概念,不能因为"抽象上都是规划"就给分
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+# 输入信息
|
|
|
+你将接收到以下输入:
|
|
|
+- **<原始问题>**:用户的初始查询问题,代表用户的真实需求意图。
|
|
|
+- **<平台sug词条>**:平台推荐的词条列表,每个词条需要单独评估。
|
|
|
+
|
|
|
+
|
|
|
+#判定流程
|
|
|
+#评估架构
|
|
|
+
|
|
|
+输入: <原始问题> + <平台sug词条>
|
|
|
+ ↓
|
|
|
+【品类维度相关性判定】
|
|
|
+ ├→ 步骤1: 评估<sug词条>与<原始问题>的内容主体和限定词匹配度
|
|
|
+ └→ 输出: -1到1之间的数值 + 判定依据
|
|
|
+
|
|
|
+
|
|
|
+相关度评估维度详解
|
|
|
+维度2: 品类维度评估
|
|
|
+评估对象: <平台sug词条> 与 <原始问题> 的内容主体和限定词匹配度
|
|
|
+
|
|
|
+评分标准:
|
|
|
+
|
|
|
+【正向匹配】
|
|
|
++0.95~1.0: 核心主体+所有关键限定词完全匹配
|
|
|
+ - 例: 原始问题"川西秋季风光摄影素材" vs sug词"川西秋季风光摄影作品"
|
|
|
+
|
|
|
++0.75~0.95: 核心主体匹配,存在限定词匹配
|
|
|
+ - 例: 原始问题"川西秋季风光摄影素材" vs sug词"川西风光摄影素材"(缺失"秋季")
|
|
|
+
|
|
|
++0.5~0.75: 核心主体匹配,无限定词匹配或合理泛化
|
|
|
+ - 例: 原始问题"川西秋季风光摄影素材" vs sug词"四川风光摄影"
|
|
|
+
|
|
|
++0.3~0.5: 核心主体匹配,但限定词缺失或存在语义错位
|
|
|
+ - 特别注意"语义身份"差异,主体词出现但上下文语义不同
|
|
|
+ - 例:
|
|
|
+ · "猫咪的XX行为"(猫咪是行为者)
|
|
|
+ · vs "用猫咪表达XX的梗图"(猫咪是媒介)
|
|
|
+ · 虽都含"猫咪+XX",但语义角色不同
|
|
|
+
|
|
|
++0.2~0.3: 主体词不匹配,限定词缺失或错位
|
|
|
+ - 例: 原始问题"川西秋季风光摄影素材" vs sug词"风光摄影入门"
|
|
|
+
|
|
|
++0.05~0.2: 主体词过度泛化或仅抽象相似
|
|
|
+ - 例: sug词是通用概念,原始问题是特定概念
|
|
|
+ sug词"每日计划"(通用)vs 原始问题 "川西旅行行程"(特定)
|
|
|
+ → 评分:0.08
|
|
|
+
|
|
|
+【中性/无关】
|
|
|
+0: 类别明显不同,没有明确目的,无明确关联
|
|
|
+ - 例: 原始问题"川西秋季风光摄影素材" vs sug词"人像摄影素材"
|
|
|
+ - 例: 原始问题无法识别动机 且 sug词也无明确动作 → 0
|
|
|
+
|
|
|
+【负向偏离】
|
|
|
+-0.2~-0.05: 主体词或限定词存在误导性
|
|
|
+ - 例: 原始问题"免费摄影素材" vs sug词"付费摄影素材库"
|
|
|
+
|
|
|
+-0.5~-0.25: 主体词明显错位或品类冲突
|
|
|
+ - 例: 原始问题"风光摄影素材" vs sug词"人像修图教程"
|
|
|
+
|
|
|
+-1.0~-0.55: 完全错误的品类或有害引导
|
|
|
+ - 例: 原始问题"正版素材获取" vs sug词"盗版素材下载"
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+# 输出要求
|
|
|
+
|
|
|
+输出结果必须为一个 **JSON 格式**,包含以下内容:
|
|
|
+```json
|
|
|
+{
|
|
|
+ "品类维度得分": "-1到1之间的小数",
|
|
|
+ "简要说明品类维度相关度理由": "评估该sug词条与原始问题品类匹配程度的理由"
|
|
|
+}
|
|
|
+---
|
|
|
+
|
|
|
+**输出约束(非常重要)**:
|
|
|
+1. **字符串长度限制**:\"简要说明品类维度相关度理由\"字段必须控制在**150字以内**
|
|
|
+2. **JSON格式规范**:必须生成完整的JSON格式,确保字符串用双引号包裹且正确闭合
|
|
|
+3. **引号使用**:字符串中如需表达引用,请使用《》或「」代替单引号或双引号
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+#注意事项:
|
|
|
+始终围绕品类维度:所有评估都基于"品类"维度,不偏离
|
|
|
+严格标准一致性:对所有用例使用相同的评估标准,避免评分飘移
|
|
|
+负分使用原则:仅当sug词条对原始问题品类产生误导、冲突或有害引导时给予负分
|
|
|
+零分使用原则:当sug词条与原始问题品类无明确关联,既不相关也不冲突时给予零分
|
|
|
+""".strip()
|
|
|
+
|
|
|
+# 创建评估 Agent - 第一轮使用
|
|
|
+motivation_evaluator_round1 = Agent[None](
|
|
|
+ name="动机维度评估专家(第一轮)",
|
|
|
+ instructions=motivation_evaluation_instructions_round1,
|
|
|
+ model=get_model(MODEL_NAME),
|
|
|
+ output_type=MotivationEvaluation)
|
|
|
+
|
|
|
+# 创建评估 Agent - 后续轮次使用
|
|
|
+motivation_evaluator = Agent[None](
|
|
|
+ name="动机维度评估专家(后续轮次)",
|
|
|
+ instructions=motivation_evaluation_instructions,
|
|
|
+ model=get_model(MODEL_NAME),
|
|
|
+ output_type=MotivationEvaluation)
|
|
|
+
|
|
|
+category_evaluator = Agent[None](
|
|
|
+ name="品类维度评估专家",
|
|
|
+ instructions=category_evaluation_instructions,
|
|
|
+ model=get_model(MODEL_NAME),
|
|
|
+ output_type=CategoryEvaluation
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+# Agent 3: 加词选择专家
|
|
|
+class WordCombination(BaseModel):
|
|
|
+ """单个词组合"""
|
|
|
+ selected_word: str = Field(..., description="选择的词")
|
|
|
+ combined_query: str = Field(..., description="组合后的新query")
|
|
|
+ reasoning: str = Field(..., description="选择理由")
|
|
|
+
|
|
|
+class WordSelectionTop5(BaseModel):
|
|
|
+ """加词选择结果(Top 5)"""
|
|
|
+ combinations: list[WordCombination] = Field(
|
|
|
+ ...,
|
|
|
+ description="选择的Top 5组合(不足5个则返回所有)",
|
|
|
+ min_items=1,
|
|
|
+ max_items=5
|
|
|
+ )
|
|
|
+ overall_reasoning: str = Field(..., description="整体选择思路")
|
|
|
+
|
|
|
+word_selection_instructions = """
|
|
|
+你是加词组合专家。
|
|
|
+
|
|
|
+## 任务
|
|
|
+从候选词列表中选择5个最合适的词,分别与当前seed组合成新的query。如果候选词不足5个,则返回所有。
|
|
|
+
|
|
|
+## 选择原则
|
|
|
+1. **相关性**:选择与当前seed最相关的词
|
|
|
+2. **语义通顺**:组合后的query要符合搜索习惯
|
|
|
+3. **扩展范围**:优先选择能扩展搜索范围的词
|
|
|
+4. **多样性**:5个词应该覆盖不同的方面(如:时间、地点、类型、用途等)
|
|
|
+
|
|
|
+## 组合约束(严格执行)
|
|
|
+**CRITICAL: 以下约束必须100%遵守,违反任何一条都是错误**
|
|
|
+
|
|
|
+1. **必须完整保留seed的所有文本内容**
|
|
|
+ - seed的每一个字都必须出现在组合结果中
|
|
|
+ - 禁止删除、省略、替换seed中的任何部分
|
|
|
+ - 即使某些字看起来不重要,也必须保留
|
|
|
+
|
|
|
+2. **必须完整保留word的所有文本内容**
|
|
|
+ - word的每一个字都必须出现在组合结果中
|
|
|
+ - 禁止删除、省略、替换word中的任何部分
|
|
|
+
|
|
|
+3. **禁止添加任何额外内容**
|
|
|
+ - 不能添加连接词(如"的"、"和"、"与"、"在"等)
|
|
|
+ - 不能添加任何其他词或字符
|
|
|
+
|
|
|
+4. **组合方式仅限以下三种**
|
|
|
+ - seed在前:seed的文本 + word的文本(如:制作梗图 + 猫咪 = 制作梗图猫咪)
|
|
|
+ - word在前:word的文本 + seed的文本(如:猫咪 + 制作梗图 = 猫咪制作梗图)
|
|
|
+ - word插入:将word插入seed中间合适位置(如:制作 + 猫咪 + 梗图 = 制作猫咪梗图)
|
|
|
+
|
|
|
+5. **验证检查清单**(在输出前必须自查)
|
|
|
+ ☑ 组合结果包含seed的所有字符?
|
|
|
+ ☑ 组合结果包含word的所有字符?
|
|
|
+ ☑ 组合结果没有额外的字符?
|
|
|
+ ☑ 只使用了三种组合方式之一?
|
|
|
+
|
|
|
+## 正确示例(必须参考)
|
|
|
+✓ seed="制作梗图" + word="猫咪" → "制作梗图猫咪"(seed在前)
|
|
|
+✓ seed="制作梗图" + word="猫咪" → "猫咪制作梗图"(word在前)
|
|
|
+✓ seed="制作梗图" + word="猫咪" → "制作猫咪梗图"(word插入中间)
|
|
|
+✓ seed="川西" + word="秋季" → "川西秋季"(seed在前)
|
|
|
+✓ seed="川西" + word="秋季" → "秋季川西"(word在前)
|
|
|
+✓ seed="摄影" + word="技巧" → "摄影技巧"(seed在前)
|
|
|
+✓ seed="摄影" + word="技巧" → "技巧摄影"(word在前)
|
|
|
+
|
|
|
+## 错误示例(严禁出现)
|
|
|
+✗ seed="制作梗图" + word="猫咪" → "猫咪梗图"(❌ 缺少"制作")
|
|
|
+✗ seed="制作梗图" + word="猫咪" → "梗图猫咪"(❌ 缺少"制作")
|
|
|
+✗ seed="制作梗图" + word="猫咪" → "制作猫咪表情包"(❌ 加了"表情包")
|
|
|
+✗ seed="川西" + word="秋季" → "川西的秋季"(❌ 加了"的")
|
|
|
+✗ seed="川西" + word="秋季" → "川西秋季风光"(❌ 加了"风光")
|
|
|
+✗ seed="摄影" + word="技巧" → "摄影拍摄技巧"(❌ 加了"拍摄")
|
|
|
+✗ seed="摄影" + word="技巧" → "影技巧"(❌ 缺少"摄")
|
|
|
+
|
|
|
+## 输出要求
|
|
|
+- 最多返回5个组合(如果候选词不足5个,返回所有)
|
|
|
+- 每个组合包含:
|
|
|
+ * selected_word: 选择的词(必须在候选词列表中)
|
|
|
+ * combined_query: 组合后的新query(只包含seed和word的原始文本,不多不少)
|
|
|
+ * reasoning: 选择理由(说明为什么选这个词)
|
|
|
+- overall_reasoning: 整体选择思路(说明这5个词的选择逻辑)
|
|
|
+
|
|
|
+## JSON输出规范
|
|
|
+1. **格式要求**:必须输出标准的、完整的JSON格式
|
|
|
+2. **字符限制**:不要在JSON中使用任何不可见的特殊字符或控制字符
|
|
|
+3. **引号规范**:字符串中如需表达引用或强调,使用书名号《》或单书名号「」,不要使用英文引号或中文引号""
|
|
|
+4. **编码规范**:所有文本使用UTF-8编码,不要包含二进制或转义序列
|
|
|
+5. **完整性**:确保JSON的开始和结束括号完整匹配,所有字段都正确闭合
|
|
|
+""".strip()
|
|
|
+
|
|
|
+word_selector = Agent[None](
|
|
|
+ name="加词组合专家",
|
|
|
+ instructions=word_selection_instructions,
|
|
|
+ model=get_model(MODEL_NAME),
|
|
|
+ output_type=WordSelectionTop5,
|
|
|
+ model_settings=ModelSettings(temperature=0.2),
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+# ============================================================================
|
|
|
+# 辅助函数
|
|
|
+# ============================================================================
|
|
|
+
|
|
|
+def calculate_final_score(motivation_score: float, category_score: float) -> float:
|
|
|
+ """
|
|
|
+ 应用依存性规则计算最终得分
|
|
|
+
|
|
|
+ 步骤1: 基础加权计算
|
|
|
+ base_score = motivation_score * 0.7 + category_score * 0.3
|
|
|
+
|
|
|
+ 步骤2: 极值保护规则
|
|
|
+
|
|
|
+ Args:
|
|
|
+ motivation_score: 动机维度得分 -1~1
|
|
|
+ category_score: 品类维度得分 -1~1
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ 最终得分 -1~1
|
|
|
+ """
|
|
|
+ # 基础加权得分
|
|
|
+ base_score = motivation_score * 0.7 + category_score * 0.3
|
|
|
+
|
|
|
+ # 规则C: 动机负向决定机制(最高优先级)
|
|
|
+ if motivation_score < 0:
|
|
|
+ return 0.0
|
|
|
+
|
|
|
+ # 规则A: 动机高分保护机制
|
|
|
+ if motivation_score >= 0.8:
|
|
|
+ # 当目的高度一致时,品类的泛化不应导致"弱相关"
|
|
|
+ return max(base_score, 0.7)
|
|
|
+
|
|
|
+ # 规则B: 动机低分限制机制
|
|
|
+ if motivation_score <= 0.2:
|
|
|
+ # 目的不符时,品类匹配的价值有限
|
|
|
+ return min(base_score, 0.5)
|
|
|
+
|
|
|
+ # 无规则调整,返回基础得分
|
|
|
+ return base_score
|
|
|
+
|
|
|
+
|
|
|
+def clean_json_string(text: str) -> str:
|
|
|
+ """清理JSON中的非法控制字符(保留 \t \n \r)"""
|
|
|
+ import re
|
|
|
+ # 移除除了 \t(09) \n(0A) \r(0D) 之外的所有控制字符
|
|
|
+ return re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F]', '', text)
|
|
|
+
|
|
|
+
|
|
|
+def process_note_data(note: dict) -> Post:
|
|
|
+ """处理搜索接口返回的帖子数据"""
|
|
|
+ note_card = note.get("note_card", {})
|
|
|
+ image_list = note_card.get("image_list", [])
|
|
|
+ interact_info = note_card.get("interact_info", {})
|
|
|
+ user_info = note_card.get("user", {})
|
|
|
+
|
|
|
+ # ========== 调试日志 START ==========
|
|
|
+ note_id = note.get("id", "")
|
|
|
+ raw_title = note_card.get("display_title") # 不提供默认值
|
|
|
+ raw_body = note_card.get("desc")
|
|
|
+ raw_type = note_card.get("type")
|
|
|
+
|
|
|
+ # 打印原始值类型和内容
|
|
|
+ print(f"\n[DEBUG] 处理帖子 {note_id}:")
|
|
|
+ print(f" raw_title 类型: {type(raw_title).__name__}, 值: {repr(raw_title)}")
|
|
|
+ print(f" raw_body 类型: {type(raw_body).__name__}, 值: {repr(raw_body)[:100] if raw_body else repr(raw_body)}")
|
|
|
+ print(f" raw_type 类型: {type(raw_type).__name__}, 值: {repr(raw_type)}")
|
|
|
+
|
|
|
+ # 检查是否为 None
|
|
|
+ if raw_title is None:
|
|
|
+ print(f" ⚠️ WARNING: display_title 是 None!")
|
|
|
+ if raw_body is None:
|
|
|
+ print(f" ⚠️ WARNING: desc 是 None!")
|
|
|
+ if raw_type is None:
|
|
|
+ print(f" ⚠️ WARNING: type 是 None!")
|
|
|
+ # ========== 调试日志 END ==========
|
|
|
+
|
|
|
+ # 提取图片URL - 使用新的字段名 image_url
|
|
|
+ images = []
|
|
|
+ for img in image_list:
|
|
|
+ if isinstance(img, dict):
|
|
|
+ # 尝试新字段名 image_url,如果不存在则尝试旧字段名 url_default
|
|
|
+ img_url = img.get("image_url") or img.get("url_default")
|
|
|
+ if img_url:
|
|
|
+ images.append(img_url)
|
|
|
+
|
|
|
+ # 判断类型
|
|
|
+ note_type = note_card.get("type", "normal")
|
|
|
+ video_url = ""
|
|
|
+ if note_type == "video":
|
|
|
+ video_info = note_card.get("video", {})
|
|
|
+ if isinstance(video_info, dict):
|
|
|
+ # 尝试获取视频URL
|
|
|
+ video_url = video_info.get("media", {}).get("stream", {}).get("h264", [{}])[0].get("master_url", "")
|
|
|
+
|
|
|
+ return Post(
|
|
|
+ note_id=note.get("id") or "",
|
|
|
+ title=note_card.get("display_title") or "",
|
|
|
+ body_text=note_card.get("desc") or "",
|
|
|
+ type=note_type,
|
|
|
+ images=images,
|
|
|
+ video=video_url,
|
|
|
+ interact_info={
|
|
|
+ "liked_count": interact_info.get("liked_count", 0),
|
|
|
+ "collected_count": interact_info.get("collected_count", 0),
|
|
|
+ "comment_count": interact_info.get("comment_count", 0),
|
|
|
+ "shared_count": interact_info.get("shared_count", 0)
|
|
|
+ },
|
|
|
+ note_url=f"https://www.xiaohongshu.com/explore/{note.get('id', '')}"
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+async def evaluate_with_o(text: str, o: str, cache: dict[str, tuple[float, str]] | None = None, round_num: int = 1) -> tuple[float, str]:
|
|
|
+ """评估文本与原始问题o的相关度
|
|
|
+
|
|
|
+ 采用两阶段评估 + 代码计算规则:
|
|
|
+ 1. 动机维度评估(权重70%)
|
|
|
+ 2. 品类维度评估(权重30%)
|
|
|
+ 3. 应用规则A/B/C调整得分
|
|
|
+
|
|
|
+ Args:
|
|
|
+ text: 待评估的文本
|
|
|
+ o: 原始问题
|
|
|
+ cache: 评估缓存(可选),用于避免重复评估
|
|
|
+ round_num: 轮次编号,第一轮使用 round1 prompt,后续使用标准 prompt
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ tuple[float, str]: (最终相关度分数, 综合评估理由)
|
|
|
+ """
|
|
|
+ # 检查缓存
|
|
|
+ if cache is not None and text in cache:
|
|
|
+ cached_score, cached_reason = cache[text]
|
|
|
+ print(f" ⚡ 缓存命中: {text} -> {cached_score:.2f}")
|
|
|
+ return cached_score, cached_reason
|
|
|
+
|
|
|
+ # 准备输入
|
|
|
+ eval_input = f"""
|
|
|
+<原始问题>
|
|
|
+{o}
|
|
|
+</原始问题>
|
|
|
+
|
|
|
+<平台sug词条>
|
|
|
+{text}
|
|
|
+</平台sug词条>
|
|
|
+
|
|
|
+请评估平台sug词条与原始问题的匹配度。
|
|
|
+"""
|
|
|
+
|
|
|
+ # 添加重试机制
|
|
|
+ max_retries = 2
|
|
|
+ last_error = None
|
|
|
+
|
|
|
+ for attempt in range(max_retries):
|
|
|
+ try:
|
|
|
+ # 根据轮次选择不同的 motivation evaluator
|
|
|
+ # 第一轮使用 round1 版本,后续使用标准版本
|
|
|
+ selected_motivation_evaluator = motivation_evaluator_round1 if round_num == 1 else motivation_evaluator
|
|
|
+
|
|
|
+ # 并发调用两个评估器
|
|
|
+ motivation_task = Runner.run(selected_motivation_evaluator, eval_input)
|
|
|
+ category_task = Runner.run(category_evaluator, eval_input)
|
|
|
+
|
|
|
+ motivation_result, category_result = await asyncio.gather(
|
|
|
+ motivation_task,
|
|
|
+ category_task
|
|
|
+ )
|
|
|
+
|
|
|
+ # 获取评估结果
|
|
|
+ motivation_eval: MotivationEvaluation = motivation_result.final_output
|
|
|
+ category_eval: CategoryEvaluation = category_result.final_output
|
|
|
+
|
|
|
+ # 提取得分
|
|
|
+ motivation_score = motivation_eval.动机维度得分
|
|
|
+ category_score = category_eval.品类维度得分
|
|
|
+
|
|
|
+ # 计算基础得分
|
|
|
+ base_score = motivation_score * 0.7 + category_score * 0.3
|
|
|
+
|
|
|
+ # 应用规则计算最终得分
|
|
|
+ final_score = calculate_final_score(motivation_score, category_score)
|
|
|
+
|
|
|
+ # 组合评估理由
|
|
|
+ core_motivation = motivation_eval.原始问题核心动机提取.简要说明核心动机
|
|
|
+ motivation_reason = motivation_eval.简要说明动机维度相关度理由
|
|
|
+ category_reason = category_eval.简要说明品类维度相关度理由
|
|
|
+
|
|
|
+ combined_reason = (
|
|
|
+ f"【核心动机】{core_motivation}\n"
|
|
|
+ f"【动机维度 {motivation_score:.2f}】{motivation_reason}\n"
|
|
|
+ f"【品类维度 {category_score:.2f}】{category_reason}\n"
|
|
|
+ f"【基础得分 {base_score:.2f}】= 动机({motivation_score:.2f})*0.7 + 品类({category_score:.2f})*0.3\n"
|
|
|
+ f"【最终得分 {final_score:.2f}】"
|
|
|
+ )
|
|
|
+
|
|
|
+ # 如果应用了规则,添加规则说明
|
|
|
+ if final_score != base_score:
|
|
|
+ if motivation_score < 0:
|
|
|
+ combined_reason += "(应用规则C:动机负向决定机制)"
|
|
|
+ elif motivation_score >= 0.8:
|
|
|
+ combined_reason += "(应用规则A:动机高分保护机制)"
|
|
|
+ elif motivation_score <= 0.2:
|
|
|
+ combined_reason += "(应用规则B:动机低分限制机制)"
|
|
|
+
|
|
|
+ # 存入缓存
|
|
|
+ if cache is not None:
|
|
|
+ cache[text] = (final_score, combined_reason)
|
|
|
+
|
|
|
+ return final_score, combined_reason
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ last_error = e
|
|
|
+ error_msg = str(e)
|
|
|
+
|
|
|
+ if attempt < max_retries - 1:
|
|
|
+ print(f" ⚠️ 评估失败 (尝试 {attempt+1}/{max_retries}): {error_msg[:150]}")
|
|
|
+ print(f" 正在重试...")
|
|
|
+ await asyncio.sleep(1) # 等待1秒后重试
|
|
|
+ else:
|
|
|
+ print(f" ❌ 评估失败 (已达最大重试次数): {error_msg[:150]}")
|
|
|
+
|
|
|
+ # 所有重试失败后,返回默认值
|
|
|
+ fallback_reason = f"评估失败(重试{max_retries}次): {str(last_error)[:200]}"
|
|
|
+ print(f" 使用默认值: score=0.0, reason={fallback_reason[:100]}...")
|
|
|
+ return 0.0, fallback_reason
|
|
|
+
|
|
|
+
|
|
|
+# ============================================================================
|
|
|
+# 核心流程函数
|
|
|
+# ============================================================================
|
|
|
+
|
|
|
+async def initialize(o: str, context: RunContext) -> tuple[list[Seg], list[Word], list[Q], list[Seed]]:
|
|
|
+ """
|
|
|
+ 初始化阶段
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ (seg_list, word_list_1, q_list_1, seed_list)
|
|
|
+ """
|
|
|
+ print(f"\n{'='*60}")
|
|
|
+ print(f"初始化阶段")
|
|
|
+ print(f"{'='*60}")
|
|
|
+
|
|
|
+ # 1. 分词:原始问题(o) ->分词-> seg_list
|
|
|
+ print(f"\n[步骤1] 分词...")
|
|
|
+ result = await Runner.run(word_segmenter, o)
|
|
|
+ segmentation: WordSegmentation = result.final_output
|
|
|
+
|
|
|
+ seg_list = []
|
|
|
+ for word in segmentation.words:
|
|
|
+ seg_list.append(Seg(text=word, from_o=o))
|
|
|
+
|
|
|
+ print(f"分词结果: {[s.text for s in seg_list]}")
|
|
|
+ print(f"分词理由: {segmentation.reasoning}")
|
|
|
+
|
|
|
+ # 2. 分词评估:seg_list -> 每个seg与o进行评分(使用信号量限制并发数)
|
|
|
+ print(f"\n[步骤2] 评估每个分词与原始问题的相关度...")
|
|
|
+
|
|
|
+ MAX_CONCURRENT_SEG_EVALUATIONS = 5
|
|
|
+ seg_semaphore = asyncio.Semaphore(MAX_CONCURRENT_SEG_EVALUATIONS)
|
|
|
+
|
|
|
+ async def evaluate_seg(seg: Seg) -> Seg:
|
|
|
+ async with seg_semaphore:
|
|
|
+ # 初始化阶段的分词评估使用第一轮 prompt (round_num=1)
|
|
|
+ seg.score_with_o, seg.reason = await evaluate_with_o(seg.text, o, context.evaluation_cache, round_num=1)
|
|
|
+ return seg
|
|
|
+
|
|
|
+ if seg_list:
|
|
|
+ print(f" 开始评估 {len(seg_list)} 个分词(并发限制: {MAX_CONCURRENT_SEG_EVALUATIONS})...")
|
|
|
+ eval_tasks = [evaluate_seg(seg) for seg in seg_list]
|
|
|
+ await asyncio.gather(*eval_tasks)
|
|
|
+
|
|
|
+ for seg in seg_list:
|
|
|
+ print(f" {seg.text}: {seg.score_with_o:.2f}")
|
|
|
+
|
|
|
+ # 3. 构建word_list_1: seg_list -> word_list_1(固定词库)
|
|
|
+ print(f"\n[步骤3] 构建word_list_1(固定词库)...")
|
|
|
+ word_list_1 = []
|
|
|
+ for seg in seg_list:
|
|
|
+ word_list_1.append(Word(
|
|
|
+ text=seg.text,
|
|
|
+ score_with_o=seg.score_with_o,
|
|
|
+ from_o=o
|
|
|
+ ))
|
|
|
+ print(f"word_list_1(固定): {[w.text for w in word_list_1]}")
|
|
|
+
|
|
|
+ # 4. 构建q_list_1:seg_list 作为 q_list_1
|
|
|
+ print(f"\n[步骤4] 构建q_list_1...")
|
|
|
+ q_list_1 = []
|
|
|
+ for seg in seg_list:
|
|
|
+ q_list_1.append(Q(
|
|
|
+ text=seg.text,
|
|
|
+ score_with_o=seg.score_with_o,
|
|
|
+ reason=seg.reason,
|
|
|
+ from_source="seg"
|
|
|
+ ))
|
|
|
+ print(f"q_list_1: {[q.text for q in q_list_1]}")
|
|
|
+
|
|
|
+ # 5. 构建seed_list: seg_list -> seed_list
|
|
|
+ print(f"\n[步骤5] 构建seed_list...")
|
|
|
+ seed_list = []
|
|
|
+ for seg in seg_list:
|
|
|
+ seed_list.append(Seed(
|
|
|
+ text=seg.text,
|
|
|
+ added_words=[],
|
|
|
+ from_type="seg",
|
|
|
+ score_with_o=seg.score_with_o
|
|
|
+ ))
|
|
|
+ print(f"seed_list: {[s.text for s in seed_list]}")
|
|
|
+
|
|
|
+ return seg_list, word_list_1, q_list_1, seed_list
|
|
|
+
|
|
|
+
|
|
|
+async def run_round(
|
|
|
+ round_num: int,
|
|
|
+ q_list: list[Q],
|
|
|
+ word_list_1: list[Word],
|
|
|
+ seed_list: list[Seed],
|
|
|
+ o: str,
|
|
|
+ context: RunContext,
|
|
|
+ xiaohongshu_api: XiaohongshuSearchRecommendations,
|
|
|
+ xiaohongshu_search: XiaohongshuSearch,
|
|
|
+ sug_threshold: float = 0.7
|
|
|
+) -> tuple[list[Q], list[Seed], list[Search]]:
|
|
|
+ """
|
|
|
+ 运行一轮
|
|
|
+
|
|
|
+ Args:
|
|
|
+ round_num: 轮次编号
|
|
|
+ q_list: 当前轮的q列表
|
|
|
+ word_list_1: 固定的词库(第0轮分词结果)
|
|
|
+ seed_list: 当前的seed列表
|
|
|
+ o: 原始问题
|
|
|
+ context: 运行上下文
|
|
|
+ xiaohongshu_api: 建议词API
|
|
|
+ xiaohongshu_search: 搜索API
|
|
|
+ sug_threshold: suggestion的阈值
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ (q_list_next, seed_list_next, search_list)
|
|
|
+ """
|
|
|
+ print(f"\n{'='*60}")
|
|
|
+ print(f"第{round_num}轮")
|
|
|
+ print(f"{'='*60}")
|
|
|
+
|
|
|
+ round_data = {
|
|
|
+ "round_num": round_num,
|
|
|
+ "input_q_list": [{"text": q.text, "score": q.score_with_o, "type": "query"} for q in q_list],
|
|
|
+ "input_word_list_1_size": len(word_list_1),
|
|
|
+ "input_seed_list_size": len(seed_list)
|
|
|
+ }
|
|
|
+
|
|
|
+ # 1. 请求sug:q_list -> 每个q请求sug接口 -> sug_list_list
|
|
|
+ print(f"\n[步骤1] 为每个q请求建议词...")
|
|
|
+ sug_list_list = [] # list of list
|
|
|
+ for q in q_list:
|
|
|
+ print(f"\n 处理q: {q.text}")
|
|
|
+ suggestions = xiaohongshu_api.get_recommendations(keyword=q.text)
|
|
|
+
|
|
|
+ q_sug_list = []
|
|
|
+ if suggestions:
|
|
|
+ print(f" 获取到 {len(suggestions)} 个建议词")
|
|
|
+ for sug_text in suggestions:
|
|
|
+ sug = Sug(
|
|
|
+ text=sug_text,
|
|
|
+ from_q=QFromQ(text=q.text, score_with_o=q.score_with_o)
|
|
|
+ )
|
|
|
+ q_sug_list.append(sug)
|
|
|
+ else:
|
|
|
+ print(f" 未获取到建议词")
|
|
|
+
|
|
|
+ sug_list_list.append(q_sug_list)
|
|
|
+
|
|
|
+ # 2. sug评估:sug_list_list -> 每个sug与o进行评分(并发)
|
|
|
+ print(f"\n[步骤2] 评估每个建议词与原始问题的相关度...")
|
|
|
+
|
|
|
+ # 2.1 收集所有需要评估的sug,并记录它们所属的q
|
|
|
+ all_sugs = []
|
|
|
+ sug_to_q_map = {} # 记录每个sug属于哪个q
|
|
|
+ for i, q_sug_list in enumerate(sug_list_list):
|
|
|
+ if q_sug_list:
|
|
|
+ q_text = q_list[i].text
|
|
|
+ for sug in q_sug_list:
|
|
|
+ all_sugs.append(sug)
|
|
|
+ sug_to_q_map[id(sug)] = q_text
|
|
|
+
|
|
|
+ # 2.2 并发评估所有sug(使用信号量限制并发数)
|
|
|
+ # 每个 evaluate_sug 内部会并发调用 2 个 LLM,所以这里限制为 5,实际并发 LLM 请求为 10
|
|
|
+ MAX_CONCURRENT_EVALUATIONS = 5
|
|
|
+ semaphore = asyncio.Semaphore(MAX_CONCURRENT_EVALUATIONS)
|
|
|
+
|
|
|
+ async def evaluate_sug(sug: Sug) -> Sug:
|
|
|
+ async with semaphore: # 限制并发数
|
|
|
+ # 根据轮次选择 prompt: 第一轮使用 round1 prompt,后续使用标准 prompt
|
|
|
+ sug.score_with_o, sug.reason = await evaluate_with_o(sug.text, o, context.evaluation_cache, round_num=round_num)
|
|
|
+ return sug
|
|
|
+
|
|
|
+ if all_sugs:
|
|
|
+ print(f" 开始评估 {len(all_sugs)} 个建议词(并发限制: {MAX_CONCURRENT_EVALUATIONS})...")
|
|
|
+ eval_tasks = [evaluate_sug(sug) for sug in all_sugs]
|
|
|
+ await asyncio.gather(*eval_tasks)
|
|
|
+
|
|
|
+ # 2.3 打印结果并组织到sug_details
|
|
|
+ sug_details = {} # 保存每个Q对应的sug列表
|
|
|
+ for i, q_sug_list in enumerate(sug_list_list):
|
|
|
+ if q_sug_list:
|
|
|
+ q_text = q_list[i].text
|
|
|
+ print(f"\n 来自q '{q_text}' 的建议词:")
|
|
|
+ sug_details[q_text] = []
|
|
|
+ for sug in q_sug_list:
|
|
|
+ print(f" {sug.text}: {sug.score_with_o:.2f}")
|
|
|
+ # 保存到sug_details
|
|
|
+ sug_details[q_text].append({
|
|
|
+ "text": sug.text,
|
|
|
+ "score": sug.score_with_o,
|
|
|
+ "reason": sug.reason,
|
|
|
+ "type": "sug"
|
|
|
+ })
|
|
|
+
|
|
|
+ # 2.4 剪枝判断(第2轮及以后生效)
|
|
|
+ pruned_query_texts = set()
|
|
|
+ if round_num >= 2:
|
|
|
+ print(f"\n[剪枝判断] 第{round_num}轮开始应用剪枝策略...")
|
|
|
+ for i, q in enumerate(q_list):
|
|
|
+ q_sug_list = sug_list_list[i]
|
|
|
+
|
|
|
+ if len(q_sug_list) == 0:
|
|
|
+ continue # 没有sug则不剪枝
|
|
|
+
|
|
|
+ # 剪枝条件1: 所有sug分数都低于query分数
|
|
|
+ all_lower_than_query = all(sug.score_with_o < q.score_with_o for sug in q_sug_list)
|
|
|
+ # 剪枝条件2: 所有sug分数都低于0.5
|
|
|
+ all_below_threshold = all(sug.score_with_o < 0.5 for sug in q_sug_list)
|
|
|
+
|
|
|
+ if all_lower_than_query and all_below_threshold:
|
|
|
+ pruned_query_texts.add(q.text)
|
|
|
+ max_sug_score = max(sug.score_with_o for sug in q_sug_list)
|
|
|
+ print(f" 🔪 剪枝: {q.text} (query分数:{q.score_with_o:.2f}, sug最高分:{max_sug_score:.2f}, 全部<0.5)")
|
|
|
+
|
|
|
+ if pruned_query_texts:
|
|
|
+ print(f" 本轮共剪枝 {len(pruned_query_texts)} 个query")
|
|
|
+ else:
|
|
|
+ print(f" 本轮无query被剪枝")
|
|
|
+ else:
|
|
|
+ print(f"\n[剪枝判断] 第{round_num}轮不应用剪枝策略")
|
|
|
+
|
|
|
+ # 3. search_list构建
|
|
|
+ print(f"\n[步骤3] 构建search_list(阈值>{sug_threshold})...")
|
|
|
+ search_list = []
|
|
|
+ high_score_sugs = [sug for sug in all_sugs if sug.score_with_o > sug_threshold]
|
|
|
+
|
|
|
+ if high_score_sugs:
|
|
|
+ print(f" 找到 {len(high_score_sugs)} 个高分建议词")
|
|
|
+
|
|
|
+ # 并发搜索
|
|
|
+ async def search_for_sug(sug: Sug) -> Search:
|
|
|
+ print(f" 搜索: {sug.text}")
|
|
|
+ try:
|
|
|
+ search_result = xiaohongshu_search.search(keyword=sug.text)
|
|
|
+ result_str = search_result.get("result", "{}")
|
|
|
+ if isinstance(result_str, str):
|
|
|
+ result_data = json.loads(result_str)
|
|
|
+ else:
|
|
|
+ result_data = result_str
|
|
|
+
|
|
|
+ notes = result_data.get("data", {}).get("data", [])
|
|
|
+ post_list = []
|
|
|
+ for note in notes[:10]: # 只取前10个
|
|
|
+ post = process_note_data(note)
|
|
|
+ post_list.append(post)
|
|
|
+
|
|
|
+ print(f" → 找到 {len(post_list)} 个帖子")
|
|
|
+
|
|
|
+ return Search(
|
|
|
+ text=sug.text,
|
|
|
+ score_with_o=sug.score_with_o,
|
|
|
+ from_q=sug.from_q,
|
|
|
+ post_list=post_list
|
|
|
+ )
|
|
|
+ except Exception as e:
|
|
|
+ print(f" ✗ 搜索失败: {e}")
|
|
|
+ return Search(
|
|
|
+ text=sug.text,
|
|
|
+ score_with_o=sug.score_with_o,
|
|
|
+ from_q=sug.from_q,
|
|
|
+ post_list=[]
|
|
|
+ )
|
|
|
+
|
|
|
+ search_tasks = [search_for_sug(sug) for sug in high_score_sugs]
|
|
|
+ search_list = await asyncio.gather(*search_tasks)
|
|
|
+ else:
|
|
|
+ print(f" 没有高分建议词,search_list为空")
|
|
|
+
|
|
|
+ # 4. 构建q_list_next
|
|
|
+ print(f"\n[步骤4] 构建q_list_next...")
|
|
|
+ q_list_next = []
|
|
|
+ existing_q_texts = set() # 用于去重
|
|
|
+ add_word_details = {} # 保存每个seed对应的组合词列表
|
|
|
+ all_seed_combinations = [] # 保存本轮所有seed的组合词(用于后续构建seed_list_next)
|
|
|
+
|
|
|
+ # 4.1 对于seed_list中的每个seed,从word_list_1中选词组合,产生Top 5
|
|
|
+ print(f"\n 4.1 为每个seed加词(产生Top 5组合)...")
|
|
|
+ for seed in seed_list:
|
|
|
+ print(f"\n 处理seed: {seed.text}")
|
|
|
+
|
|
|
+ # 剪枝检查:跳过被剪枝的seed
|
|
|
+ if seed.text in pruned_query_texts:
|
|
|
+ print(f" ⊗ 跳过被剪枝的seed: {seed.text}")
|
|
|
+ continue
|
|
|
+
|
|
|
+ # 从固定词库word_list_1筛选候选词
|
|
|
+ candidate_words = []
|
|
|
+ for word in word_list_1:
|
|
|
+ # 检查词是否已在seed中
|
|
|
+ if word.text in seed.text:
|
|
|
+ continue
|
|
|
+ # 检查词是否已被添加过
|
|
|
+ if word.text in seed.added_words:
|
|
|
+ continue
|
|
|
+ candidate_words.append(word)
|
|
|
+
|
|
|
+ if not candidate_words:
|
|
|
+ print(f" 没有可用的候选词")
|
|
|
+ continue
|
|
|
+
|
|
|
+ print(f" 候选词数量: {len(candidate_words)}")
|
|
|
+
|
|
|
+ # 调用Agent一次性选择并组合Top 5(添加重试机制)
|
|
|
+ candidate_words_text = ', '.join([w.text for w in candidate_words])
|
|
|
+ selection_input = f"""
|
|
|
+<原始问题>
|
|
|
+{o}
|
|
|
+</原始问题>
|
|
|
+
|
|
|
+<当前Seed>
|
|
|
+{seed.text}
|
|
|
+</当前Seed>
|
|
|
+
|
|
|
+<候选词列表>
|
|
|
+{candidate_words_text}
|
|
|
+</候选词列表>
|
|
|
+
|
|
|
+请从候选词列表中选择最多5个最合适的词,分别与当前seed组合成新的query。
|
|
|
+"""
|
|
|
+
|
|
|
+ # 重试机制
|
|
|
+ max_retries = 2
|
|
|
+ selection_result = None
|
|
|
+ for attempt in range(max_retries):
|
|
|
+ try:
|
|
|
+ result = await Runner.run(word_selector, selection_input)
|
|
|
+ selection_result = result.final_output
|
|
|
+ break # 成功则跳出
|
|
|
+ except Exception as e:
|
|
|
+ error_msg = str(e)
|
|
|
+ if attempt < max_retries - 1:
|
|
|
+ print(f" ⚠️ 选词失败 (尝试 {attempt+1}/{max_retries}): {error_msg[:100]}")
|
|
|
+ await asyncio.sleep(1)
|
|
|
+ else:
|
|
|
+ print(f" ❌ 选词失败,跳过该seed: {error_msg[:100]}")
|
|
|
+ break
|
|
|
+
|
|
|
+ if selection_result is None:
|
|
|
+ print(f" 跳过seed: {seed.text}")
|
|
|
+ continue
|
|
|
+
|
|
|
+ print(f" Agent选择了 {len(selection_result.combinations)} 个组合")
|
|
|
+ print(f" 整体选择思路: {selection_result.overall_reasoning}")
|
|
|
+
|
|
|
+ # 并发评估所有组合的相关度
|
|
|
+ async def evaluate_combination(comb: WordCombination) -> dict:
|
|
|
+ combined = comb.combined_query
|
|
|
+
|
|
|
+ # 验证:组合结果必须包含完整的seed和word
|
|
|
+ # 检查是否包含seed的所有字符
|
|
|
+ seed_chars_in_combined = all(char in combined for char in seed.text)
|
|
|
+ # 检查是否包含word的所有字符
|
|
|
+ word_chars_in_combined = all(char in combined for char in comb.selected_word)
|
|
|
+
|
|
|
+ if not seed_chars_in_combined or not word_chars_in_combined:
|
|
|
+ print(f" ⚠️ 警告:组合不完整")
|
|
|
+ print(f" Seed: {seed.text}")
|
|
|
+ print(f" Word: {comb.selected_word}")
|
|
|
+ print(f" 组合: {combined}")
|
|
|
+ print(f" 包含完整seed? {seed_chars_in_combined}")
|
|
|
+ print(f" 包含完整word? {word_chars_in_combined}")
|
|
|
+ # 返回极低分数,让这个组合不会被选中
|
|
|
+ return {
|
|
|
+ 'word': comb.selected_word,
|
|
|
+ 'query': combined,
|
|
|
+ 'score': -1.0, # 极低分数
|
|
|
+ 'reason': f"组合不完整:缺少seed或word的部分内容",
|
|
|
+ 'reasoning': comb.reasoning
|
|
|
+ }
|
|
|
+
|
|
|
+ # 正常评估,根据轮次选择 prompt
|
|
|
+ score, reason = await evaluate_with_o(combined, o, context.evaluation_cache, round_num=round_num)
|
|
|
+ return {
|
|
|
+ 'word': comb.selected_word,
|
|
|
+ 'query': combined,
|
|
|
+ 'score': score,
|
|
|
+ 'reason': reason,
|
|
|
+ 'reasoning': comb.reasoning
|
|
|
+ }
|
|
|
+
|
|
|
+ eval_tasks = [evaluate_combination(comb) for comb in selection_result.combinations]
|
|
|
+ top_5 = await asyncio.gather(*eval_tasks)
|
|
|
+
|
|
|
+ print(f" 评估完成,得到 {len(top_5)} 个组合")
|
|
|
+
|
|
|
+ # 将Top 5全部加入q_list_next(去重检查 + 得分过滤)
|
|
|
+ for comb in top_5:
|
|
|
+ # 得分过滤:只有得分大于种子得分的组合词才加入下一轮
|
|
|
+ if comb['score'] <= seed.score_with_o:
|
|
|
+ print(f" ⊗ 跳过低分: {comb['query']} (分数{comb['score']:.2f} ≤ 种子{seed.score_with_o:.2f})")
|
|
|
+ continue
|
|
|
+
|
|
|
+ # 去重检查
|
|
|
+ if comb['query'] in existing_q_texts:
|
|
|
+ print(f" ⊗ 跳过重复: {comb['query']}")
|
|
|
+ continue
|
|
|
+
|
|
|
+ print(f" ✓ {comb['query']} (分数: {comb['score']:.2f} > 种子: {seed.score_with_o:.2f})")
|
|
|
+
|
|
|
+ new_q = Q(
|
|
|
+ text=comb['query'],
|
|
|
+ score_with_o=comb['score'],
|
|
|
+ reason=comb['reason'],
|
|
|
+ from_source="add"
|
|
|
+ )
|
|
|
+ q_list_next.append(new_q)
|
|
|
+ existing_q_texts.add(comb['query']) # 记录到去重集合
|
|
|
+
|
|
|
+ # 记录已添加的词
|
|
|
+ seed.added_words.append(comb['word'])
|
|
|
+
|
|
|
+ # 保存到add_word_details
|
|
|
+ add_word_details[seed.text] = [
|
|
|
+ {
|
|
|
+ "text": comb['query'],
|
|
|
+ "score": comb['score'],
|
|
|
+ "reason": comb['reason'],
|
|
|
+ "selected_word": comb['word'],
|
|
|
+ "seed_score": seed.score_with_o, # 添加原始种子的得分
|
|
|
+ "type": "add"
|
|
|
+ }
|
|
|
+ for comb in top_5
|
|
|
+ ]
|
|
|
+
|
|
|
+ # 保存到all_seed_combinations(用于构建seed_list_next)
|
|
|
+ # 附加seed_score,用于后续过滤
|
|
|
+ for comb in top_5:
|
|
|
+ comb['seed_score'] = seed.score_with_o
|
|
|
+ all_seed_combinations.extend(top_5)
|
|
|
+
|
|
|
+ # 4.2 对于sug_list_list中,每个sug大于来自的query分数,加到q_list_next(去重检查)
|
|
|
+ print(f"\n 4.2 将高分sug加入q_list_next...")
|
|
|
+ for sug in all_sugs:
|
|
|
+ # 剪枝检查:跳过来自被剪枝query的sug
|
|
|
+ if sug.from_q and sug.from_q.text in pruned_query_texts:
|
|
|
+ print(f" ⊗ 跳过来自被剪枝query的sug: {sug.text} (来源: {sug.from_q.text})")
|
|
|
+ continue
|
|
|
+
|
|
|
+ if sug.from_q and sug.score_with_o > sug.from_q.score_with_o:
|
|
|
+ # 去重检查
|
|
|
+ if sug.text in existing_q_texts:
|
|
|
+ print(f" ⊗ 跳过重复: {sug.text}")
|
|
|
+ continue
|
|
|
+
|
|
|
+ new_q = Q(
|
|
|
+ text=sug.text,
|
|
|
+ score_with_o=sug.score_with_o,
|
|
|
+ reason=sug.reason,
|
|
|
+ from_source="sug"
|
|
|
+ )
|
|
|
+ q_list_next.append(new_q)
|
|
|
+ existing_q_texts.add(sug.text) # 记录到去重集合
|
|
|
+ print(f" ✓ {sug.text} (分数: {sug.score_with_o:.2f} > {sug.from_q.score_with_o:.2f})")
|
|
|
+
|
|
|
+ # 5. 构建seed_list_next(关键修改:不保留上一轮的seed)
|
|
|
+ print(f"\n[步骤5] 构建seed_list_next(不保留上轮seed)...")
|
|
|
+ seed_list_next = []
|
|
|
+ existing_seed_texts = set()
|
|
|
+
|
|
|
+ # 5.1 加入本轮所有组合词(只加入得分提升的)
|
|
|
+ print(f" 5.1 加入本轮所有组合词(得分过滤)...")
|
|
|
+ for comb in all_seed_combinations:
|
|
|
+ # 得分过滤:只有得分大于种子得分的组合词才作为下一轮种子
|
|
|
+ seed_score = comb.get('seed_score', 0)
|
|
|
+ if comb['score'] <= seed_score:
|
|
|
+ print(f" ⊗ 跳过低分: {comb['query']} (分数{comb['score']:.2f} ≤ 种子{seed_score:.2f})")
|
|
|
+ continue
|
|
|
+
|
|
|
+ if comb['query'] not in existing_seed_texts:
|
|
|
+ new_seed = Seed(
|
|
|
+ text=comb['query'],
|
|
|
+ added_words=[], # 新seed的added_words清空
|
|
|
+ from_type="add",
|
|
|
+ score_with_o=comb['score']
|
|
|
+ )
|
|
|
+ seed_list_next.append(new_seed)
|
|
|
+ existing_seed_texts.add(comb['query'])
|
|
|
+ print(f" ✓ {comb['query']} (分数: {comb['score']:.2f} > 种子: {seed_score:.2f})")
|
|
|
+
|
|
|
+ # 5.2 加入高分sug
|
|
|
+ print(f" 5.2 加入高分sug...")
|
|
|
+ for sug in all_sugs:
|
|
|
+ # 剪枝检查:跳过来自被剪枝query的sug
|
|
|
+ if sug.from_q and sug.from_q.text in pruned_query_texts:
|
|
|
+ continue
|
|
|
+
|
|
|
+ # sug分数 > 对应query分数
|
|
|
+ if sug.from_q and sug.score_with_o > sug.from_q.score_with_o and sug.text not in existing_seed_texts:
|
|
|
+ new_seed = Seed(
|
|
|
+ text=sug.text,
|
|
|
+ added_words=[],
|
|
|
+ from_type="sug",
|
|
|
+ score_with_o=sug.score_with_o
|
|
|
+ )
|
|
|
+ seed_list_next.append(new_seed)
|
|
|
+ existing_seed_texts.add(sug.text)
|
|
|
+ print(f" ✓ {sug.text} (分数: {sug.score_with_o:.2f} > 来源query: {sug.from_q.score_with_o:.2f})")
|
|
|
+
|
|
|
+ # 序列化搜索结果数据(包含帖子详情)
|
|
|
+ search_results_data = []
|
|
|
+ for search in search_list:
|
|
|
+ search_results_data.append({
|
|
|
+ "text": search.text,
|
|
|
+ "score_with_o": search.score_with_o,
|
|
|
+ "post_list": [
|
|
|
+ {
|
|
|
+ "note_id": post.note_id,
|
|
|
+ "note_url": post.note_url,
|
|
|
+ "title": post.title,
|
|
|
+ "body_text": post.body_text,
|
|
|
+ "images": post.images,
|
|
|
+ "interact_info": post.interact_info
|
|
|
+ }
|
|
|
+ for post in search.post_list
|
|
|
+ ]
|
|
|
+ })
|
|
|
+
|
|
|
+ # 记录本轮数据
|
|
|
+ round_data.update({
|
|
|
+ "sug_count": len(all_sugs),
|
|
|
+ "high_score_sug_count": len(high_score_sugs),
|
|
|
+ "search_count": len(search_list),
|
|
|
+ "total_posts": sum(len(s.post_list) for s in search_list),
|
|
|
+ "q_list_next_size": len(q_list_next),
|
|
|
+ "seed_list_next_size": len(seed_list_next),
|
|
|
+ "total_combinations": len(all_seed_combinations),
|
|
|
+ "pruned_query_count": len(pruned_query_texts),
|
|
|
+ "pruned_queries": list(pruned_query_texts),
|
|
|
+ "output_q_list": [{"text": q.text, "score": q.score_with_o, "reason": q.reason, "from": q.from_source, "type": "query"} for q in q_list_next],
|
|
|
+ "seed_list_next": [{"text": seed.text, "from": seed.from_type, "score": seed.score_with_o} for seed in seed_list_next],
|
|
|
+ "sug_details": sug_details,
|
|
|
+ "add_word_details": add_word_details,
|
|
|
+ "search_results": search_results_data
|
|
|
+ })
|
|
|
+ context.rounds.append(round_data)
|
|
|
+
|
|
|
+ print(f"\n本轮总结:")
|
|
|
+ print(f" 建议词数量: {len(all_sugs)}")
|
|
|
+ print(f" 高分建议词: {len(high_score_sugs)}")
|
|
|
+ print(f" 搜索数量: {len(search_list)}")
|
|
|
+ print(f" 帖子总数: {sum(len(s.post_list) for s in search_list)}")
|
|
|
+ print(f" 组合词数量: {len(all_seed_combinations)}")
|
|
|
+ print(f" 下轮q数量: {len(q_list_next)}")
|
|
|
+ print(f" 下轮seed数量: {len(seed_list_next)}")
|
|
|
+
|
|
|
+ return q_list_next, seed_list_next, search_list
|
|
|
+
|
|
|
+
|
|
|
+async def iterative_loop(
|
|
|
+ context: RunContext,
|
|
|
+ max_rounds: int = 2,
|
|
|
+ sug_threshold: float = 0.7
|
|
|
+):
|
|
|
+ """主迭代循环"""
|
|
|
+
|
|
|
+ print(f"\n{'='*60}")
|
|
|
+ print(f"开始迭代循环")
|
|
|
+ print(f"最大轮数: {max_rounds}")
|
|
|
+ print(f"sug阈值: {sug_threshold}")
|
|
|
+ print(f"{'='*60}")
|
|
|
+
|
|
|
+ # 初始化
|
|
|
+ seg_list, word_list_1, q_list, seed_list = await initialize(context.o, context)
|
|
|
+
|
|
|
+ # API实例
|
|
|
+ xiaohongshu_api = XiaohongshuSearchRecommendations()
|
|
|
+ xiaohongshu_search = XiaohongshuSearch()
|
|
|
+
|
|
|
+ # 保存初始化数据
|
|
|
+ context.rounds.append({
|
|
|
+ "round_num": 0,
|
|
|
+ "type": "initialization",
|
|
|
+ "seg_list": [{"text": s.text, "score": s.score_with_o, "reason": s.reason, "type": "seg"} for s in seg_list],
|
|
|
+ "word_list_1": [{"text": w.text, "score": w.score_with_o} for w in word_list_1],
|
|
|
+ "q_list_1": [{"text": q.text, "score": q.score_with_o, "reason": q.reason, "type": "query"} for q in q_list],
|
|
|
+ "seed_list": [{"text": s.text, "from_type": s.from_type, "score": s.score_with_o, "type": "seed"} for s in seed_list]
|
|
|
+ })
|
|
|
+
|
|
|
+ # 收集所有搜索结果
|
|
|
+ all_search_list = []
|
|
|
+
|
|
|
+ # 迭代
|
|
|
+ round_num = 1
|
|
|
+ while q_list and round_num <= max_rounds:
|
|
|
+ q_list, seed_list, search_list = await run_round(
|
|
|
+ round_num=round_num,
|
|
|
+ q_list=q_list,
|
|
|
+ word_list_1=word_list_1, # 传递固定词库
|
|
|
+ seed_list=seed_list,
|
|
|
+ o=context.o,
|
|
|
+ context=context,
|
|
|
+ xiaohongshu_api=xiaohongshu_api,
|
|
|
+ xiaohongshu_search=xiaohongshu_search,
|
|
|
+ sug_threshold=sug_threshold
|
|
|
+ )
|
|
|
+
|
|
|
+ all_search_list.extend(search_list)
|
|
|
+ round_num += 1
|
|
|
+
|
|
|
+ print(f"\n{'='*60}")
|
|
|
+ print(f"迭代完成")
|
|
|
+ print(f" 总轮数: {round_num - 1}")
|
|
|
+ print(f" 总搜索次数: {len(all_search_list)}")
|
|
|
+ print(f" 总帖子数: {sum(len(s.post_list) for s in all_search_list)}")
|
|
|
+ print(f"{'='*60}")
|
|
|
+
|
|
|
+ return all_search_list
|
|
|
+
|
|
|
+
|
|
|
+# ============================================================================
|
|
|
+# 主函数
|
|
|
+# ============================================================================
|
|
|
+
|
|
|
+async def main(input_dir: str, max_rounds: int = 2, sug_threshold: float = 0.7, visualize: bool = False):
|
|
|
+ """主函数"""
|
|
|
+ current_time, log_url = set_trace()
|
|
|
+
|
|
|
+ # 读取输入
|
|
|
+ input_context_file = os.path.join(input_dir, 'context.md')
|
|
|
+ input_q_file = os.path.join(input_dir, 'q.md')
|
|
|
+
|
|
|
+ c = read_file_as_string(input_context_file) # 原始需求
|
|
|
+ o = read_file_as_string(input_q_file) # 原始问题
|
|
|
+
|
|
|
+ # 版本信息
|
|
|
+ version = os.path.basename(__file__)
|
|
|
+ version_name = os.path.splitext(version)[0]
|
|
|
+
|
|
|
+ # 日志目录
|
|
|
+ log_dir = os.path.join(input_dir, "output", version_name, current_time)
|
|
|
+
|
|
|
+ # 创建运行上下文
|
|
|
+ run_context = RunContext(
|
|
|
+ version=version,
|
|
|
+ input_files={
|
|
|
+ "input_dir": input_dir,
|
|
|
+ "context_file": input_context_file,
|
|
|
+ "q_file": input_q_file,
|
|
|
+ },
|
|
|
+ c=c,
|
|
|
+ o=o,
|
|
|
+ log_dir=log_dir,
|
|
|
+ log_url=log_url,
|
|
|
+ )
|
|
|
+
|
|
|
+ # 创建日志目录
|
|
|
+ os.makedirs(run_context.log_dir, exist_ok=True)
|
|
|
+
|
|
|
+ # 配置日志文件
|
|
|
+ log_file_path = os.path.join(run_context.log_dir, "run.log")
|
|
|
+ log_file = open(log_file_path, 'w', encoding='utf-8')
|
|
|
+
|
|
|
+ # 重定向stdout到TeeLogger(同时输出到控制台和文件)
|
|
|
+ original_stdout = sys.stdout
|
|
|
+ sys.stdout = TeeLogger(original_stdout, log_file)
|
|
|
+
|
|
|
+ try:
|
|
|
+ print(f"📝 日志文件: {log_file_path}")
|
|
|
+ print(f"{'='*60}\n")
|
|
|
+
|
|
|
+ # 执行迭代
|
|
|
+ all_search_list = await iterative_loop(
|
|
|
+ run_context,
|
|
|
+ max_rounds=max_rounds,
|
|
|
+ sug_threshold=sug_threshold
|
|
|
+ )
|
|
|
+
|
|
|
+ # 格式化输出
|
|
|
+ output = f"原始需求:{run_context.c}\n"
|
|
|
+ output += f"原始问题:{run_context.o}\n"
|
|
|
+ output += f"总搜索次数:{len(all_search_list)}\n"
|
|
|
+ output += f"总帖子数:{sum(len(s.post_list) for s in all_search_list)}\n"
|
|
|
+ output += "\n" + "="*60 + "\n"
|
|
|
+
|
|
|
+ if all_search_list:
|
|
|
+ output += "【搜索结果】\n\n"
|
|
|
+ for idx, search in enumerate(all_search_list, 1):
|
|
|
+ output += f"{idx}. 搜索词: {search.text} (分数: {search.score_with_o:.2f})\n"
|
|
|
+ output += f" 帖子数: {len(search.post_list)}\n"
|
|
|
+ if search.post_list:
|
|
|
+ for post_idx, post in enumerate(search.post_list[:3], 1): # 只显示前3个
|
|
|
+ output += f" {post_idx}) {post.title}\n"
|
|
|
+ output += f" URL: {post.note_url}\n"
|
|
|
+ output += "\n"
|
|
|
+ else:
|
|
|
+ output += "未找到搜索结果\n"
|
|
|
+
|
|
|
+ run_context.final_output = output
|
|
|
+
|
|
|
+ print(f"\n{'='*60}")
|
|
|
+ print("最终结果")
|
|
|
+ print(f"{'='*60}")
|
|
|
+ print(output)
|
|
|
+
|
|
|
+ # 保存上下文文件
|
|
|
+ context_file_path = os.path.join(run_context.log_dir, "run_context.json")
|
|
|
+ context_dict = run_context.model_dump()
|
|
|
+ with open(context_file_path, "w", encoding="utf-8") as f:
|
|
|
+ json.dump(context_dict, f, ensure_ascii=False, indent=2)
|
|
|
+ print(f"\nRunContext saved to: {context_file_path}")
|
|
|
+
|
|
|
+ # 保存详细的搜索结果
|
|
|
+ search_results_path = os.path.join(run_context.log_dir, "search_results.json")
|
|
|
+ search_results_data = [s.model_dump() for s in all_search_list]
|
|
|
+ with open(search_results_path, "w", encoding="utf-8") as f:
|
|
|
+ json.dump(search_results_data, f, ensure_ascii=False, indent=2)
|
|
|
+ print(f"Search results saved to: {search_results_path}")
|
|
|
+
|
|
|
+ # 可视化
|
|
|
+ if visualize:
|
|
|
+ import subprocess
|
|
|
+ output_html = os.path.join(run_context.log_dir, "visualization.html")
|
|
|
+ print(f"\n🎨 生成可视化HTML...")
|
|
|
+
|
|
|
+ # 获取绝对路径
|
|
|
+ abs_context_file = os.path.abspath(context_file_path)
|
|
|
+ abs_output_html = os.path.abspath(output_html)
|
|
|
+
|
|
|
+ # 运行可视化脚本
|
|
|
+ result = subprocess.run([
|
|
|
+ "node",
|
|
|
+ "visualization/sug_v6_1_2_8/index.js",
|
|
|
+ abs_context_file,
|
|
|
+ abs_output_html
|
|
|
+ ])
|
|
|
+
|
|
|
+ if result.returncode == 0:
|
|
|
+ print(f"✅ 可视化已生成: {output_html}")
|
|
|
+ else:
|
|
|
+ print(f"❌ 可视化生成失败")
|
|
|
+
|
|
|
+ finally:
|
|
|
+ # 恢复stdout
|
|
|
+ sys.stdout = original_stdout
|
|
|
+ log_file.close()
|
|
|
+ print(f"\n📝 运行日志已保存: {log_file_path}")
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ parser = argparse.ArgumentParser(description="搜索query优化工具 - v6.1.2.115 广度遍历版")
|
|
|
+ parser.add_argument(
|
|
|
+ "--input-dir",
|
|
|
+ type=str,
|
|
|
+ default="input/旅游-逸趣玩旅行/如何获取能体现川西秋季特色的高质量风光摄影素材?",
|
|
|
+ help="输入目录路径,默认: input/旅游-逸趣玩旅行/如何获取能体现川西秋季特色的高质量风光摄影素材?"
|
|
|
+ )
|
|
|
+ parser.add_argument(
|
|
|
+ "--max-rounds",
|
|
|
+ type=int,
|
|
|
+ default=4,
|
|
|
+ help="最大轮数,默认: 4"
|
|
|
+ )
|
|
|
+ parser.add_argument(
|
|
|
+ "--sug-threshold",
|
|
|
+ type=float,
|
|
|
+ default=0.7,
|
|
|
+ help="suggestion阈值,默认: 0.7"
|
|
|
+ )
|
|
|
+ parser.add_argument(
|
|
|
+ "--visualize",
|
|
|
+ action="store_true",
|
|
|
+ default=True,
|
|
|
+ help="运行完成后自动生成可视化HTML"
|
|
|
+ )
|
|
|
+ args = parser.parse_args()
|
|
|
+
|
|
|
+ asyncio.run(main(args.input_dir, max_rounds=args.max_rounds, sug_threshold=args.sug_threshold, visualize=args.visualize))
|