Files
deerflow-code/offline-backend-20260512/backend/skill-packages/add-audience/scripts/add_audiences.py
T
董宏凯 5c75e1af16 feat(deep-research): 强化任务链路可靠性并接入 agentfx 报告助手种子
功能说明:\n- 重构 Deep Research 调度与执行链,补齐 dispatcher、executor、live hub 与依赖注入入口,提升任务下发、执行、回调的稳定性\n- 增强运行时持久化与恢复能力,接入业务映射、任务来源与任务事件日志模型,覆盖持久化与执行可靠性关键路径测试\n- 新增 agentfx 报告助手 seed 与任务报告导入脚本配置,支持离线环境快速初始化与任务流模板回填\n- 完善 deep-research 与 thread_runs 路由参数编排,兼容不同业务映射与运行入口的前后端联动\n- 补充前端 agentfx deep-link 解析,保证 deep-research 场景下的登录跳转与任务上下文一致
2026-09-04 23:18:52 +08:00

336 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
添加受众目标入库脚本(完整链路)。
链路(与前端 block-chat-audience.taskAddActionDesign / audience-content.taskActionDesignDel 一致):
1. POST {login} 登录换取 access_token(Authorization: Bearer)
2. GET {design_list}?filter=taskId&filter=actionId 查询当前行动下已有受众(查重)
3. 同名受众存在时执行删除链:
GET {pathway_relation_list}?filter=actionId 找出该受众的 pathwayId(关系表)
DELETE {pathway_account_delete}/{pathwayId} 删关系表 task-action-pathway-account
DELETE {delete_design_vulns}?designId={id} 删受众关联脆弱点
DELETE {design_delete}/{id} 删受众目标本身
4. POST {design_create} 创建受众目标(parentTargetAudience=大模型生成)
5. POST {design_account_create} ×N 逐个创建受众-账号关联
用法:
python add_audiences.py --json payload.json [--dry-run] [--config ../config/audience_api.json]
payload.json 结构:
{
"taskId": 101,
"actionId": 201,
"audienceList": [
{ "targetAudience": "群体名", // 也可用 groupName
"recReason": "推荐理由", // 会自动清洗 <n> 引用标记
"userList": [ {完整user对象} ] } // 取 id/name/platformType/dataSource/score,整个对象进 frofileInfo
]
}
输出:一段 JSON(stdout),包含每个接口的入参与调用结果(密码与 token 已脱敏),
以及分组结果与汇总,供模型如实转述给用户。
"""
import argparse
import copy
import json
import os
import re
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
DEFAULT_CONFIG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "config", "audience_api.json")
# 环境变量覆盖名
ENV_OVERRIDES = {
"base_url": "AUDIENCE_API_BASE_URL",
"username": "AUDIENCE_API_USERNAME",
"password": "AUDIENCE_API_PASSWORD",
"verify_ssl": "AUDIENCE_API_VERIFY_SSL",
}
def load_config(path: str) -> dict:
cfg = json.loads(open(path, encoding="utf-8").read())
for key, env in ENV_OVERRIDES.items():
if os.environ.get(env):
raw = os.environ[env]
if key == "verify_ssl":
cfg.setdefault("request", {})
cfg["request"]["verify_ssl"] = raw.lower() in ("1", "true", "yes")
else:
cfg[key] = raw
# 兼容 "$ENV_VAR" 占位值写法
def resolve(v):
if isinstance(v, str) and v.startswith("$") and re.fullmatch(r"\$[A-Z0-9_]+", v):
return os.environ.get(v[1:], "")
return v
def walk(o):
if isinstance(o, dict):
return {k: walk(v) for k, v in o.items()}
if isinstance(o, list):
return [walk(v) for v in o]
return resolve(o)
return walk(cfg)
def clean_reason(text) -> str:
"""复刻前端 recReason 清洗:<n> 引用标记、顿号、「引用」、空括号。"""
if not isinstance(text, str):
return ""
text = re.sub(r"<(\d+)>", "", text)
return text.replace("、", "").replace("引用", "").replace("()", "")
class ApiClient:
def __init__(self, cfg: dict, dry_run: bool = False):
self.base = cfg["base_url"].rstrip("/")
self.cfg = cfg
self.dry_run = dry_run
self.headers = {"Content-Type": "application/json"}
self.headers.update(cfg.get("request", {}).get("extra_headers") or {})
self.timeout = cfg.get("request", {}).get("timeout_seconds", 30)
verify = cfg.get("request", {}).get("verify_ssl", True)
self.ctx = ssl.create_default_context() if verify else ssl._create_unverified_context()
self.calls = []
def _log(self, step, method, url, request, status, response):
self.calls.append({
"step": step, "method": method, "url": url,
"request": request, "response": {"status": status, "body": response},
})
def request(self, step, method, path, query=None, body=None, auth=True):
"""执行一次 HTTP 请求并记录。返回 (status, 解析后的响应)。"""
url = self.base + path
if query:
url += "?" + urllib.parse.urlencode(query, doseq=True)
payload = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
display_body = self._redact(copy.deepcopy(body))
req_headers = dict(self.headers)
if auth:
req_headers[self.cfg["auth"]["header_name"]] = "***已附带鉴权头***"
if self.dry_run:
self._log(step, method, url, {"query": query, "body": display_body}, 0, "[dry-run] 未实际发送")
return 0, None
req = urllib.request.Request(url, data=payload, method=method)
for k, v in req_headers.items():
if v == "***已附带鉴权头***":
req.add_header(k, self._auth_header_value())
else:
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx) as resp:
status, text = resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
status, text = e.code, e.read().decode("utf-8", "replace")
except Exception as e: # 网络层错误
self._log(step, method, url, {"query": query, "body": display_body}, -1, str(e))
raise
try:
parsed = json.loads(text) if text else None
except ValueError:
parsed = text[:300]
self._log(step, method, url, {"query": query, "body": display_body}, status, parsed if not isinstance(parsed, str) else parsed)
return status, parsed
def _auth_header_value(self) -> str:
return self.cfg["auth"]["header_template"].format(token=getattr(self, "token", ""))
@staticmethod
def _redact(body):
if isinstance(body, dict):
for k in list(body.keys()):
if k in ("password",):
body[k] = "***"
return body
def ep(cfg, key) -> str:
return cfg["endpoints"][key]
def main():
parser = argparse.ArgumentParser(description="添加受众目标(完整链路:登录→查重→删同名及关系表→建受众→建账号关联)")
parser.add_argument("--json", required=True, help="载荷 JSON 文件路径")
parser.add_argument("--config", default=DEFAULT_CONFIG, help="接口配置文件路径")
parser.add_argument("--dry-run", action="store_true", help="只校验与打印调用计划,不实际发送")
args = parser.parse_args()
result = {
"success": False, "dry_run": args.dry_run,
"配置": {}, "入参摘要": {}, "登录": None, "groups": [], "summary": {}, "error": None,
}
try:
cfg = load_config(args.config)
except Exception as e:
result["error"] = f"配置加载失败:{e}"
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(1)
# ---- 载荷校验 ----
try:
payload = json.loads(open(args.json, encoding="utf-8").read())
except Exception as e:
result["error"] = f"载荷 JSON 读取失败:{e}"
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(1)
task_id = payload.get("taskId")
action_id = payload.get("actionId")
groups_in = payload.get("audienceList") or []
errors = []
if not isinstance(task_id, int) or not isinstance(action_id, int):
errors.append("taskId / actionId 必须为整数")
if not groups_in:
errors.append("audienceList 不能为空")
for i, g in enumerate(groups_in):
name = (g.get("targetAudience") or g.get("groupName") or "").strip()
if not name:
errors.append(f"audienceList[{i}] 缺少群体名(targetAudience/groupName)")
if not isinstance(g.get("userList"), list):
errors.append(f"audienceList[{i}] 缺少 userList 数组")
if errors:
result["error"] = "载荷校验失败:" + ";".join(errors)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(1)
result["配置"] = {
"base_url": cfg["base_url"], "username": cfg["username"],
"verify_ssl": cfg.get("request", {}).get("verify_ssl", True),
"timeout_seconds": cfg.get("request", {}).get("timeout_seconds", 30),
}
result["入参摘要"] = {
"taskId": task_id, "actionId": action_id,
"audienceList": [
{"targetAudience": (g.get("targetAudience") or g.get("groupName", "")).strip(),
"recReason清洗后": clean_reason(g.get("recReason", "")),
"账号数": len(g.get("userList") or [])}
for g in groups_in
],
}
client = ApiClient(cfg, dry_run=args.dry_run)
# ---- 1. 登录 ----
login_status, login_resp = client.request("登录", "POST", ep(cfg, "login"),
body={"username": cfg["username"], "password": cfg["password"]}, auth=False)
if login_status not in (200, 201):
result["登录"] = f"失败(HTTP {login_status})"
result["error"] = f"登录失败:HTTP {login_status},请检查 config/audience_api.json 的 base_url 与账号密码"
result["calls"] = client.calls
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(1)
token = (login_resp or {}).get(cfg["auth"]["token_field"]) if isinstance(login_resp, dict) else None
if not token:
result["登录"] = "失败(响应中无 " + cfg["auth"]["token_field"] + ")"
result["error"] = f"登录响应缺少 {cfg['auth']['token_field']} 字段:{login_resp}"
result["calls"] = client.calls
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(1)
client.token = token
result["登录"] = f"成功(已获取 {cfg['auth']['token_field']},后续请求头 {cfg['auth']['header_name']}: {cfg['auth']['header_template']})"
# ---- 2. 查询已有受众(查重) ----
q = {"filter": [f"taskId||$eq||{task_id}", f"actionId||$eq||{action_id}"], "limit": 99999}
status, designs_resp = client.request("查询已有受众", "GET", ep(cfg, "design_list"), query=q)
designs = (designs_resp or {}).get("data") or [] if isinstance(designs_resp, dict) else []
if status != 200:
result["error"] = f"查询已有受众失败:HTTP {status}"
result["calls"] = client.calls
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(1)
# 关系表(一次查出整个行动的 pathway 关联)
rel_status, rel_resp = client.request("查询受众-传播节点关系表", "GET", ep(cfg, "pathway_relation_list"),
query={"filter": [f"actionId||$eq||{action_id}"], "limit": 99999})
relations = (rel_resp or {}).get("data") or [] if isinstance(rel_resp, dict) else []
summary = {"新增受众": 0, "新增账号关联": 0, "删除同名旧受众": 0, "删除关系表记录": 0, "删除脆弱点关联": 0}
all_ok = True
for g in groups_in:
name = (g.get("targetAudience") or g.get("groupName")).strip()
group = {"targetAudience": name, "designId": None, "新增账号": 0, "deletedOld": [], "ok": False, "error": None}
# ---- 3. 同名删除链 ----
for old in [d for d in designs if str(d.get("targetAudience", "")).strip() == name]:
old_id = old.get("id")
pathway_ids = [r.get("pathwayId") for r in relations if r.get("designId") == old_id and r.get("pathwayId") is not None]
for pid in pathway_ids:
s, _ = client.request("删除关系表记录", "DELETE", f"{ep(cfg, 'pathway_account_delete')}/{pid}")
if s in (200, 204):
summary["删除关系表记录"] += 1
s, _ = client.request("删除受众关联脆弱点", "DELETE", ep(cfg, "delete_design_vulns"),
query={"designId": old_id})
if s in (200, 204):
summary["删除脆弱点关联"] += 1
s, _ = client.request("删除同名旧受众", "DELETE", f"{ep(cfg, 'design_delete')}/{old_id}")
if s in (200, 204):
summary["删除同名旧受众"] += 1
group["deletedOld"].append({"id": old_id, "删除关系表记录数": len(pathway_ids)})
# ---- 4. 创建受众目标 ----
design_body = {
"taskId": task_id, "actionId": action_id,
"parentTargetAudience": "大模型生成",
"targetAudience": name,
"recReason": clean_reason(g.get("recReason", "")),
}
s, created = client.request("创建受众目标", "POST", ep(cfg, "design_create"), body=design_body)
if s not in (200, 201) or not isinstance(created, dict) or "id" not in created:
group["error"] = f"创建受众目标失败:HTTP {s},响应:{created}"
result["groups"].append(group)
all_ok = False
continue
design_id = created["id"]
group["designId"] = design_id
summary["新增受众"] += 1
# ---- 5. 创建账号关联 ----
for user in g.get("userList") or []:
account_source = user.get("dataSource") if user.get("dataSource") is not None else user.get("score")
account_body = {
"taskId": task_id, "actionId": action_id,
"accountId": user.get("id"),
"accountName": user.get("name"),
"accountPlat": user.get("platformType"),
"accountSource": account_source,
"frofileInfo": json.dumps(user, ensure_ascii=False),
"designId": design_id,
}
s, resp = client.request("创建受众-账号关联", "POST", ep(cfg, "design_account_create"), body=account_body)
if s in (200, 201):
group["新增账号"] += 1
summary["新增账号关联"] += 1
else:
group.setdefault("账号失败", []).append({"accountName": user.get("name"), "status": s, "response": resp})
all_ok = False
group["ok"] = "账号失败" not in group
result["groups"].append(group)
result["summary"] = summary
result["success"] = all_ok
result["calls"] = client.calls
result["人读摘要"] = (
f"{'[演练]' if args.dry_run else ''}共处理 {len(groups_in)} 个受众组:"
f"新增受众 {summary['新增受众']} 个、账号关联 {summary['新增账号关联']} 条;"
f"删除同名旧受众 {summary['删除同名旧受众']} 个(连带关系表记录 {summary['删除关系表记录']} 条、脆弱点关联 {summary['删除脆弱点关联']} 处)。"
+ ("全部成功。" if all_ok else "存在失败项,详见 groups/error 字段。")
)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0 if all_ok else 2)
if __name__ == "__main__":
main()