From 6851c96a4562a415d46cf7258a1850eeed86dc3c Mon Sep 17 00:00:00 2001
From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com>
Date: Mon, 29 Jun 2026 16:59:23 +0800
Subject: [PATCH] =?UTF-8?q?=E6=8A=80=E8=83=BD=E6=8F=90=E4=BA=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../workspace/agents/agent-module-sidebar.tsx | 7 +
frontend-web/src/core/auth/yuser-login.ts | 15 +-
frontend-web/src/pages/LoginPage.tsx | 14 +-
frontend-web/src/shims/next-navigation.ts | 30 +-
skills-data/xdfx-full-flow-skill-guide.md | 80 +-
.../xdfx-single-agent-full-flow/SKILL.md | 65 +-
.../scripts/xdfx_auth.py | 149 +++
.../scripts/xdfx_flow.py | 972 ++++++++++++++----
.../scripts/xdfx_run_full_steps.py | 95 ++
skills-data/xdfx-split-controller/SKILL.md | 47 +-
.../scripts/xdfx_auth.py | 149 +++
.../scripts/xdfx_flow.py | 972 ++++++++++++++----
.../scripts/xdfx_run_full_steps.py | 95 ++
skills-data/xdfx-split-flow-skill-guide.md | 71 +-
skills-data/xdfx-step1-audience/SKILL.md | 23 +-
.../xdfx-step1-audience/scripts/xdfx_auth.py | 149 +++
.../xdfx-step1-audience/scripts/xdfx_flow.py | 972 ++++++++++++++----
skills-data/xdfx-step2-pathway/SKILL.md | 24 +-
.../xdfx-step2-pathway/scripts/xdfx_auth.py | 149 +++
.../xdfx-step2-pathway/scripts/xdfx_flow.py | 972 ++++++++++++++----
.../xdfx-step3-audience-vulns/SKILL.md | 25 +-
.../scripts/xdfx_auth.py | 149 +++
.../scripts/xdfx_flow.py | 972 ++++++++++++++----
skills-data/xdfx-step4-pathway-vulns/SKILL.md | 25 +-
.../scripts/xdfx_auth.py | 149 +++
.../scripts/xdfx_flow.py | 972 ++++++++++++++----
.../xdfx-step5-audience-strategy/SKILL.md | 26 +-
.../scripts/xdfx_auth.py | 149 +++
.../scripts/xdfx_flow.py | 972 ++++++++++++++----
.../xdfx-step6-pathway-strategy/SKILL.md | 26 +-
.../scripts/xdfx_auth.py | 149 +++
.../scripts/xdfx_flow.py | 972 ++++++++++++++----
xdfx-auth-index-check-state.json | 80 ++
xdfx-auth-index-check-summary.md | 18 +
xdfx-log-check-state.json | 88 ++
xdfx-log-check-summary.md | 18 +
xdfx-runner-dry-state.json | 88 ++
xdfx-runner-dry-summary.md | 18 +
38 files changed, 8043 insertions(+), 1903 deletions(-)
create mode 100644 skills-data/xdfx-single-agent-full-flow/scripts/xdfx_auth.py
create mode 100644 skills-data/xdfx-single-agent-full-flow/scripts/xdfx_run_full_steps.py
create mode 100644 skills-data/xdfx-split-controller/scripts/xdfx_auth.py
create mode 100644 skills-data/xdfx-split-controller/scripts/xdfx_run_full_steps.py
create mode 100644 skills-data/xdfx-step1-audience/scripts/xdfx_auth.py
create mode 100644 skills-data/xdfx-step2-pathway/scripts/xdfx_auth.py
create mode 100644 skills-data/xdfx-step3-audience-vulns/scripts/xdfx_auth.py
create mode 100644 skills-data/xdfx-step4-pathway-vulns/scripts/xdfx_auth.py
create mode 100644 skills-data/xdfx-step5-audience-strategy/scripts/xdfx_auth.py
create mode 100644 skills-data/xdfx-step6-pathway-strategy/scripts/xdfx_auth.py
create mode 100644 xdfx-auth-index-check-state.json
create mode 100644 xdfx-auth-index-check-summary.md
create mode 100644 xdfx-log-check-state.json
create mode 100644 xdfx-log-check-summary.md
create mode 100644 xdfx-runner-dry-state.json
create mode 100644 xdfx-runner-dry-summary.md
diff --git a/frontend-web/src/components/workspace/agents/agent-module-sidebar.tsx b/frontend-web/src/components/workspace/agents/agent-module-sidebar.tsx
index d0712ccb..b9e48c2a 100644
--- a/frontend-web/src/components/workspace/agents/agent-module-sidebar.tsx
+++ b/frontend-web/src/components/workspace/agents/agent-module-sidebar.tsx
@@ -211,6 +211,13 @@ export function AgentModuleSidebar({
{
+ try {
+ sessionStorage.setItem(`agent-module:${moduleKey}:last-agent`, agent.id);
+ } catch {
+ // Ignore storage failures; route navigation is the source of truth.
+ }
+ }}
className={cn(
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 transition-colors",
isActive
diff --git a/frontend-web/src/core/auth/yuser-login.ts b/frontend-web/src/core/auth/yuser-login.ts
index a43405bb..5787963d 100644
--- a/frontend-web/src/core/auth/yuser-login.ts
+++ b/frontend-web/src/core/auth/yuser-login.ts
@@ -84,6 +84,7 @@ function writeCachedYUserInfo(yUserId: string, info: Record): v
function buildExternalUserInfo(
yUserId: string,
result: Record,
+ access_token: string | null | undefined,
previous: Record,
cached: Record,
): Record {
@@ -101,6 +102,12 @@ function buildExternalUserInfo(
return next;
}
+ const queryAccessToken = normalizeExternalAccessToken(access_token);
+ if (queryAccessToken) {
+ next.accessToken = queryAccessToken;
+ return next;
+ }
+
const previousYUserId = readStoredYUserId(previous);
const previousAccessToken =
previousYUserId === yUserId ? normalizeExternalAccessToken(previous.accessToken) : "";
@@ -121,6 +128,7 @@ function buildExternalUserInfo(
export async function loginByYUserId(
yUserId: string,
token?: string | null,
+ access_token?: string | null,
): Promise {
if (token) {
authStorageSet(TOOLS_TOKEN_STORAGE_KEY, token);
@@ -134,12 +142,11 @@ export async function loginByYUserId(
// consumer 返回 { accessToken, gateDepartName, typeName, userName }。
// 一并存入路由参数 yUserId,使「应用管理」里 source=yUserId 的路由参数能取到(与
// source=userName/gateDepartName/typeName 直接取接口值统一走 userInfo[source])。
- // 注意:接口也会返回 accessToken,但 XK/PG 及 source=accessToken 要用路由透传的
- // access_token,故登录流程随后会用 persistAccessToken 覆盖 userInfo.accessToken。
+ // 注意:接口返回的 accessToken 优先;登录 URL 的 access_token 只在接口值为空时兜底。
const result = res.data ?? {};
const previous = readExternalUserInfo();
const cached = readCachedYUserInfo(yUserId);
- const next = buildExternalUserInfo(yUserId, result, previous, cached);
+ const next = buildExternalUserInfo(yUserId, result, access_token, previous, cached);
authStorageSet(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(next));
writeCachedYUserInfo(yUserId, next);
}
@@ -149,7 +156,7 @@ export function persistToolsToken(token: string): void {
}
/**
- * 把内网登录路由参数 access_token 合并写入 userInfo.accessToken(覆盖接口返回的同名值)。
+ * 把内网登录路由参数 access_token 合并写入 userInfo.accessToken。
* 据此拼参的有:XK/PG 中任务 iframe(XK 用 accessToken、PG 用 access_token,二者均读
* external-links.getAccessToken() = userInfo.accessToken);以及「应用管理」里
* source=accessToken 的轻应用路由参数(resolveRouteParamValue 取 userInfo.accessToken)。
diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx
index 7ca382fb..75dc8a44 100644
--- a/frontend-web/src/pages/LoginPage.tsx
+++ b/frontend-web/src/pages/LoginPage.tsx
@@ -75,7 +75,7 @@ export default function LoginPage() {
const yUserId = searchParams.get('yUserId')?.trim() ?? '';
const toolToken = searchParams.get('token')?.trim() ?? '';
// 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参
- const accessToken = searchParams.get('access_token')?.trim() ?? '';
+ const access_token = searchParams.get('access_token')?.trim() ?? '';
// 用户名直登口令门:开启后端开关时,口令从 ?password= 透传
const gatePassword = searchParams.get('password') ?? '';
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
@@ -182,7 +182,7 @@ export default function LoginPage() {
if (onlineTicket) {
deerflowUsername = await resolveUsernameByOnlineTicket(onlineTicket);
} else if (yUserId) {
- await loginByYUserId(yUserId, toolToken || null);
+ await loginByYUserId(yUserId, toolToken || null, access_token || null);
} else if (toolToken) {
persistToolsToken(toolToken);
}
@@ -212,9 +212,11 @@ export default function LoginPage() {
}
// 标准登录把用户名写入 userInfo,供顶栏用户区显示(已有则不覆盖)
setStoredUserName(loginUserName);
- // 内网透传的 access_token 合并进 userInfo(在 loginByYUserId 覆盖之后),
- // XK/PG 中任务 iframe 据此取 accessToken
- persistAccessToken(accessToken);
+ // 非 yUserId 登录仍可直接持久化 access_token;yUserId 登录已在 loginByYUserId 内按
+ // “接口 accessToken 优先,URL access_token 仅兜底”的规则处理。
+ if (!yUserId) {
+ persistAccessToken(access_token);
+ }
// 嵌入模式不写 sidebarEnabled(localStorage 的「简洁/全量」偏好会跨标签污染正常页);
// 去 chrome 由按标签页的嵌入会话(isEmbedActive)驱动,见 PageLayout。
if (!isEmbed) persistSidebarEnabled(sidebarEnabled);
@@ -273,7 +275,7 @@ export default function LoginPage() {
sidebarEnabled,
yUserId,
toolToken,
- accessToken,
+ access_token,
onlineTicket,
authToken,
gatePassword,
diff --git a/frontend-web/src/shims/next-navigation.ts b/frontend-web/src/shims/next-navigation.ts
index b3bf4941..7ab31dde 100644
--- a/frontend-web/src/shims/next-navigation.ts
+++ b/frontend-web/src/shims/next-navigation.ts
@@ -8,21 +8,21 @@ import {
import { RouteBaseContext } from "@/core/route-base";
-export function useRouter() {
- const navigate = useNavigate();
- const base = useContext(RouteBaseContext);
-
- return useMemo(
- () => ({
- push: (href: string) =>
- navigate(href.replace("/page/workspace", base)),
- replace: (href: string) =>
- navigate(href.replace("/page/workspace", base), { replace: true }),
- back: () => navigate(-1),
- forward: () => navigate(1),
- refresh: () => window.location.reload(),
- prefetch: async () => {},
- }),
+export function useRouter() {
+ const navigate = useNavigate();
+ const base = useContext(RouteBaseContext);
+ const resolveHref = (href: string) =>
+ href.startsWith(base) ? href : href.replace("/page/workspace", base);
+
+ return useMemo(
+ () => ({
+ push: (href: string) => navigate(resolveHref(href)),
+ replace: (href: string) => navigate(resolveHref(href), { replace: true }),
+ back: () => navigate(-1),
+ forward: () => navigate(1),
+ refresh: () => window.location.reload(),
+ prefetch: async () => {},
+ }),
[navigate, base],
);
}
diff --git a/skills-data/xdfx-full-flow-skill-guide.md b/skills-data/xdfx-full-flow-skill-guide.md
index 88dd1805..9621f0c3 100644
--- a/skills-data/xdfx-full-flow-skill-guide.md
+++ b/skills-data/xdfx-full-flow-skill-guide.md
@@ -6,10 +6,10 @@
1. 受众目标分析,选出最优 3 个受众并入库。
2. 关键传播节点分析,跨受众去重后选出最优 3 个节点并入库。
-3. 受众脆弱点分析并入库。
-4. 关键传播节点脆弱点分析并入库。
-5. 受众策略分析并入库。
-6. 关键传播节点策略分析并入库。
+3. 从已入库受众中默认选前 2 个继续,每个生成最多 3 个脆弱点并入库。
+4. 从已入库关键传播节点中默认选前 2 个继续,每个生成最多 3 个脆弱点并入库。
+5. 对上述选中的每个受众默认生成最多 3 个策略并入库。
+6. 对上述选中的每个关键传播节点默认生成最多 3 个策略并入库。
7. 同步前端步骤状态,保证原 `item-aiceh` 页面步骤条可继续识别。
## 技能位置
@@ -41,9 +41,9 @@ skills-data/xdfx-single-agent-full-flow/scripts/xdfx_flow.py
}
```
-默认会自动处理登录态:当命令没有传 `Authorization`,或某个接口返回 401 时,脚本会用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/v1/auth/login/username?password=ch%40user`,请求体为 `{"username":"admin"}`。拿到 `access_token` 后写入 `Authorization: Bearer ...` 并重试原接口一次。
+默认会自动处理登录态:脚本启动时先 `import scripts/xdfx_auth.py`,用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/v1/auth/login/username?password=ch%40user`,请求体为 `{"username":"admin"}`。拿到 `access_token` 后写入内部 `Authorization: Bearer ...`,再调用第一步业务接口;不要通过任何命令行参数传 token。
-默认关闭 HTTPS 证书校验,用于兼容 UAT/内网自签证书;如果部署环境证书链正常,可以追加 `--verify-ssl`。如果已经有可用 token,也可以继续追加 `--auth-token TOKEN` 或 `--header "Authorization: Bearer TOKEN"`,脚本会优先使用传入 token。
+默认关闭 HTTPS 证书校验,用于兼容 UAT/内网自签证书;如果部署环境证书链正常,可以追加 `--verify-ssl`。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次。
## 执行命令
@@ -60,30 +60,44 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
`--mode full` 只作为无人值守或排障兜底,不作为默认执行方式;外层工具通常会等长命令结束后才返回输出,无法保证真实逐步反馈。逐步命令共享同一个 `xdfx-flow-state.json`,不会影响 id 传递、断点续跑和最终入库。
+如果需要一条命令完成逐步编排,可以使用包装脚本。它内部仍按 step1-step6 调用 `xdfx_flow.py`,并逐行转发反馈:
+
+```bash
+python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
+```
+
每完成一步都会输出一行:
```text
-STEP_FEEDBACK {...}
+步骤反馈 {...}
```
-调用技能的智能体必须在每个 step 命令结束后读取 `STEP_FEEDBACK`,给用户一次反馈,再执行下一条 step 命令。
+调用技能的智能体必须在每个 step 命令结束后读取 `步骤反馈`,给用户一次反馈,再执行下一条 step 命令。
每次执行具体操作或调用接口前,脚本会输出:
```text
-OP_FEEDBACK {...}
+操作反馈 {...}
```
-调用技能的智能体必须把 `operation`、`method` 和 `url` 转述给用户,例如:
+调用技能的智能体必须把 `操作`、`请求方式` 和 `接口地址` 转述给用户,例如:
```text
正在保存受众目标,调用 POST https://.../api/task/task-action-design。
```
+每次保存接口成功拿到 `id` 后,脚本会输出:
+
+```text
+入库反馈 {...}
+```
+
+调用技能的智能体必须把 `入库对象类型`、`入库对象名称` 和 `返回ID` 转述给用户,例如“已入库受众目标:xxx,designId=123”或“已入库受众脆弱点:xxx,id=456”。
+
如果报错,脚本会立即输出:
```text
-ERROR_FEEDBACK {...}
+错误反馈 {...}
```
调用技能的智能体必须立刻告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
@@ -91,10 +105,10 @@ ERROR_FEEDBACK {...}
如果模型 JSON 经过自动提取和修复仍无法解析,脚本会输出:
```text
-JSON_PARSE_REVIEW {...}
+JSON解析复核 {...}
```
-调用技能的智能体必须查看 `responseExcerpt`,判断是否能识别真正 JSON;无法确认时重跑当前步骤或重新调用对应模型接口。
+调用技能的智能体必须查看 `响应片段`,判断是否能识别真正 JSON;无法确认时重跑当前步骤或重新调用对应模型接口。
只检查脚本与参数、不调用真实接口:
@@ -130,6 +144,8 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
- `task-action-design-strategy-relation`
- `task/task-action/{actionId}` 的 `step` 字段
+策略信息形式/模态会按前端 `modelType` 字典保存为 value 逗号串,例如模型输出“图片,视频”时,脚本先查 `sys/sys-dict-data` 再写入 `task-action-message.modelType`,不会直接保存中文文字。
+
## 输出文件
执行后查看:
@@ -139,27 +155,29 @@ xdfx-flow-state.json
xdfx-flow-summary.md
```
-`xdfx-flow-state.json` 保存断点状态、接口返回 id、模型解析结果与错误明细。
+`xdfx-flow-state.json` 保存断点状态、接口返回 ID、模型解析结果与错误明细。
`xdfx-flow-summary.md` 是给用户看的执行摘要。
## 每步反馈
-脚本会输出三类反馈行:
+脚本会输出五类反馈行:
-- `OP_FEEDBACK`:当前正在做什么、调用哪个接口。
-- `ERROR_FEEDBACK`:当前错误的阶段、接口、状态码、请求摘要和响应摘要。
-- `JSON_PARSE_REVIEW`:模型返回内容无法稳定解析为 JSON,需要执行智能体复核是否重试。
-- `STEP_FEEDBACK`:当前步骤最终状态和累计入库数量。
+- `操作反馈`:当前正在做什么、调用哪个接口。
+- `入库反馈`:当前已入库的对象类型、名称和返回 ID。
+- `错误反馈`:当前错误的阶段、接口、状态码、请求摘要和响应摘要。
+- `JSON解析复核`:模型返回内容无法稳定解析为 JSON,需要执行智能体复核是否重试。
+- `步骤反馈`:当前步骤最终状态和累计入库数量。
-`STEP_FEEDBACK` 中包含:
+`步骤反馈` 中包含:
-- `step`:步骤编号。
-- `label`:步骤名称。
-- `status`:`ok`、`partial` 或 `skipped`。
-- `counts`:当前累计入库数量。
-- `newErrors`:本步骤新增错误数。
-- `totalErrors`:累计错误数。
+- `步骤`:步骤编号。
+- `步骤名称`:步骤名称。
+- `状态`:`成功`、`部分完成` 或 `已跳过`。
+- `执行状态`:中文执行状态。
+- `累计入库数量`:当前累计入库数量。
+- `本步骤新增错误数`:本步骤新增错误数。
+- `累计错误数`:累计错误数。
智能体反馈用户时建议使用这种格式:
@@ -167,20 +185,23 @@ xdfx-flow-summary.md
Step1 受众目标分析完成:当前已入库受众 X 个,本步骤新增错误 Y 个。
```
-如果 `status=partial`,要补一句“该步骤部分完成”,并说明稍后最终汇总会列出错误明细。
+如果 `状态` 为 `部分完成`,要补一句“该步骤部分完成”,并说明稍后最终汇总会列出错误明细。
-如果 `status=skipped`,要说明跳过原因。
+如果 `状态` 为 `已跳过`,要说明跳过原因。
## 容错规则
- 单个受众、账号、传播节点、脆弱点或策略失败,不中断整个流程。
- 保存接口必须返回 `id`,否则不会进入下游步骤。
+- 受众目标默认入库 3 个,关键传播节点默认入库 3 个。
+- 下游脆弱点/策略默认只选择前 2 个受众和前 2 个传播节点继续,可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。
+- 每个被选中的对象默认最多生成 3 个脆弱点、3 个策略。
- 模型流式 `answer` 会自动拼接。
- 流式模型接口第一次返回空响应、`y模型没有响应`、`模型没有响应` 或超时类字符串时,会自动再调用一次;仍失败才写入 `errors`。
- 模型输出会自动去掉 ``,从前后混杂文本、多个 JSON 代码块、无换行流式文本、连续 JSON 对象、非工整 JSON 中提取多个候选,并按当前步骤所需字段选择最像的 JSON。
- 模型 JSON 会尝试修复中文引号、全角冒号/逗号、尾随逗号、未加引号的键、Python 风格 `True/False/None`。
- 模型 JSON 解析失败会自动重试 1 次。
-- 每一步完成都会写入 state/summary 并输出 `STEP_FEEDBACK`。
+- 每一步完成都会写入 state/summary 并输出 `步骤反馈`。
- 接口失败会写入 `errors`,包含阶段、URL、状态码、请求摘要、响应正文和异常类型。
## 回复用户规则
@@ -190,3 +211,4 @@ Step1 受众目标分析完成:当前已入库受众 X 个,本步骤新增
如果 `errors` 为空,可以说明“全流程完成”。
如果 `errors` 非空,只能说明“部分完成”,并列出失败阶段、接口 URL、状态码和响应摘要。
+
diff --git a/skills-data/xdfx-single-agent-full-flow/SKILL.md b/skills-data/xdfx-single-agent-full-flow/SKILL.md
index 3c8a2656..7d3676d9 100644
--- a/skills-data/xdfx-single-agent-full-flow/SKILL.md
+++ b/skills-data/xdfx-single-agent-full-flow/SKILL.md
@@ -26,20 +26,20 @@ scripts/xdfx_flow.py
}
```
-默认会在缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 调用:
+鉴权不能通过命令行传递 token。脚本会在启动阶段先 `import scripts/xdfx_auth.py`,用 `admin / ch@user` 获取访问令牌,并在第一步业务接口调用前写入请求头。
+
+默认调用:
```text
POST /api/v1/auth/login/username?password=ch%40user
body: {"username":"admin"}
```
-拿到 `access_token` 后会写入 `Authorization: Bearer ...` 并重试原接口一次。登录接口和 401 重试都会输出 `OP_FEEDBACK` / `ERROR_FEEDBACK`,但反馈中的 `password`、`token` 会脱敏。默认关闭 HTTPS 证书校验,适配 UAT/内网自签证书;如果环境证书链正常,可以追加 `--verify-ssl`。
-
-如果已经有可用登录态,也可以在命令里追加 `--auth-token TOKEN` 或 `--header "Authorization: Bearer TOKEN"`;此时脚本优先使用传入的 token。
+拿到 `access_token` 后会写入内部 `Authorization: Bearer ...`。不要通过任何命令行参数传 token;脚本会忽略外部 Authorization,统一由 `xdfx_auth.py` 获取和刷新。登录接口和 401 重试都会输出 `操作反馈` / `错误反馈`,但反馈中的 `password`、`token` 会脱敏。默认关闭 HTTPS 证书校验,适配 UAT/内网自签证书;如果环境证书链正常,可以追加 `--verify-ssl`。
## 执行命令
-从本 Skill 目录逐步执行。虽然这是“一个技能做完整流程”的版本,但为了给用户真实的逐步反馈,智能体必须按 step1-step6 分六个命令运行,每个命令结束后先把 `STEP_FEEDBACK` / 新增错误反馈给用户,再继续下一步:
+从本 Skill 目录逐步执行。虽然这是“一个技能做完整流程”的版本,但为了给用户真实的逐步反馈,智能体必须按 step1-step6 分六个命令运行,每个命令结束后先把 `步骤反馈` / 新增错误反馈给用户,再继续下一步:
```bash
python scripts/xdfx_flow.py --mode step1 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
@@ -52,13 +52,29 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
`--mode full` 仅作为无人值守或本地排障兜底,不作为默认执行方式;因为外层工具通常会等长命令结束才返回输出,无法保证用户看到实时反馈。逐步命令共享同一个 `xdfx-flow-state.json`,id 传递和断点续跑不受影响。
-每一步完成后都会输出一行 `STEP_FEEDBACK {...}`。智能体必须在每个 step 命令结束后给用户一次简短反馈,说明该步骤状态、已入库数量和新增错误数,然后再执行下一步。
+如果确实想用一条命令编排完整流程,优先使用轻量包装脚本,而不是 `--mode full`。它会在内部逐个启动 step 命令并转发每一行反馈:
-脚本每次调用接口前还会输出 `OP_FEEDBACK {...}`,包含当前操作、HTTP 方法和 URL。智能体必须用简短中文告诉用户“正在做什么、调用哪个接口”。
+```bash
+python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
+```
-脚本出现错误时会立即输出 `ERROR_FEEDBACK {...}`,包含阶段、接口 URL、状态码、请求摘要、响应摘要和异常类型。智能体必须立刻告诉用户错误细节,不能等到最后才说。
+每一步完成后都会输出一行 `步骤反馈 {...}`。智能体必须在每个 step 命令结束后给用户一次简短反馈,说明该步骤状态、已入库数量和新增错误数,然后再执行下一步。
-如果模型返回内容经过多候选 JSON 提取和修复后仍无法解析,脚本会输出 `JSON_PARSE_REVIEW {...}`。智能体必须查看其中的 `responseExcerpt`,判断是否能识别出可用 JSON;无法确认时,重跑当前步骤或重新调用对应接口。
+脚本每次调用接口前还会输出 `操作反馈 {...}`,包含当前操作、HTTP 方法和 URL。智能体必须用简短中文告诉用户“正在做什么、调用哪个接口”。
+
+每次保存接口成功拿到返回 ID 后会输出 `入库反馈 {...}`,包含 `入库对象类型`、`入库对象名称`、`返回ID` 和 `接口路径`。智能体必须把它转述成“已入库 xxx 受众 / xxx 脆弱点 / xxx 策略”等明确日志。
+
+脚本出现错误时会立即输出 `错误反馈 {...}`,包含阶段、接口 URL、状态码、请求摘要、响应摘要和异常类型。智能体必须立刻告诉用户错误细节,不能等到最后才说。
+
+如果模型返回内容经过多候选 JSON 提取和修复后仍无法解析,脚本会输出 `JSON解析复核 {...}`。智能体必须查看其中的 `响应片段`,判断是否能识别出可用 JSON;无法确认时,重跑当前步骤或重新调用对应接口。
+
+## 失败重试规则
+
+- 任意步骤出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前失败的同一条 `--mode stepN` 命令。
+- 重试前必须告诉用户失败阶段、接口 URL、状态码、响应摘要和“正在重试当前步骤”,不能静默重试。
+- 当前步骤未重试成功前,不允许继续执行下一步;不要因为某一步失败就跳过进入后续分析或入库。
+- 同一步重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
+- 只有用户明确要求忽略该失败,才可以继续后续步骤;回复中必须说明这是用户确认后的带风险继续。
只做参数和脚本检查、不调用接口:
@@ -68,36 +84,44 @@ python scripts/xdfx_flow.py --mode full --input xdfx-input.json --dry-run
## 脚本保证
-- 受众只取最优 3 个继续。
-- 关键传播节点全局去重后只取最优 3 个继续。
+- 受众目标默认入库最优 3 个,可用 `--max-audiences` 调整。
+- 关键传播节点全局去重后默认入库最优 3 个,可用 `--max-pathways` 调整。
+- 下游脆弱点/策略默认只从已入库结果中选择前 2 个受众、前 2 个传播节点继续,可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。
+- 每个被选中的受众/传播节点默认生成最多 3 个脆弱点,可用 `--max-vulns` 调整。
+- 每个被选中的受众/传播节点默认生成最多 3 个策略,可用 `--max-strategies` 调整。
- 每个保存接口必须拿到返回 `id` 才进入下游。
- 保存后会同步前端 `task/task-action.step` 步骤状态;如只想入库不更新步骤条,追加 `--no-step-status`。
- 策略保存会同步写入 `task-action-channel`、`base-narrate`、`task-action-message`、`task-action-design-strategy-relation`,兼容策略表格/编排页面读取。
+- 策略 `信息形式/模态` 入库前会读取 `sys/sys-dict-data` 的 `modelType` 字典,把模型返回的“图片,视频/文本,图片”等文字转成前端需要的 value 逗号串;字典读取失败时使用内置兜底映射。
+- Step1 的受众目标抽取结果里,`用户编号列表` / `编号id` / `id` 必须对应上一轮 `zhanghao` 账号数组的索引值;脚本会按这个索引依次找到账号并保存受众账号关系。
- 模型流式 `{"answer":"..."}` 会自动拼接。
- 流式模型接口第一次返回空响应、`y模型没有响应`、`模型没有响应` 或超时类字符串时,会自动再调用一次;仍失败才写入 `errors`。
- 模型输出会自动去掉 ``,从前后混杂文本、多个 JSON 代码块、无换行流式文本、连续 JSON 对象、非工整 JSON 中提取多个候选,并按当前步骤所需字段选择最像的 JSON。
- 模型 JSON 会尝试修复中文引号、全角冒号/逗号、尾随逗号、未加引号的键、Python 风格 `True/False/None`。
- 模型 JSON 解析失败会自动重试 1 次。
-- 单个受众、账号、节点或策略失败不会拖垮全流程。
-- 每完成一步都会输出 `STEP_FEEDBACK`,包含 `step`、`label`、`status`、累计入库计数和新增错误数。
-- 每次接口调用前都会输出 `OP_FEEDBACK`,说明当前操作和接口 URL。
-- 每个错误都会立即输出 `ERROR_FEEDBACK`,说明失败阶段、接口、状态码、请求摘要和响应摘要。
+- 单个受众、账号、节点或策略失败会被脚本记录;智能体仍必须按“失败重试规则”先重跑当前步骤,不能直接跳到下一步。
+- 每完成一步都会输出 `步骤反馈`,包含 `步骤`、`步骤名称`、`状态`、`累计入库数量` 和 `本步骤新增错误数`。
+- 每次接口调用前都会输出 `操作反馈`,说明当前操作和接口 URL。
+- 每次保存成功都会输出 `入库反馈`,说明已入库对象类型、名称和返回 ID。
+- 每个错误都会立即输出 `错误反馈`,说明失败阶段、接口、状态码、请求摘要和响应摘要。
- 所有接口错误都会写入 `xdfx-flow-state.json.errors`,包含阶段、URL、状态码、请求摘要、响应正文和异常类型。
## 回复用户
-执行过程中,每出现一行 `STEP_FEEDBACK`,都要立刻向用户反馈一次:
+执行过程中,每出现一行 `步骤反馈`,都要立刻向用户反馈一次:
- 当前完成的步骤名称。
-- 状态:`ok`、`partial` 或 `skipped`。
+- 状态:`成功`、`部分完成` 或 `已跳过`。
- 当前累计入库数量。
- 本步骤新增错误数。
-每出现一行 `OP_FEEDBACK`,都要简短告诉用户当前正在执行的操作和接口,例如“正在保存受众目标,调用 POST /api/task/task-action-design”。
+每出现一行 `操作反馈`,都要简短告诉用户当前正在执行的操作和接口,例如“正在保存受众目标,调用 POST /api/task/task-action-design”。
-每出现一行 `ERROR_FEEDBACK`,都要立刻告诉用户失败阶段、接口 URL、状态码和响应摘要。
+每出现一行 `入库反馈`,都要简短告诉用户当前已入库的对象,例如“已入库受众目标:xxx,designId=123”或“已入库受众脆弱点:xxx,id=456”。
-每出现一行 `JSON_PARSE_REVIEW`,都要查看 `responseExcerpt`。如果能明显识别出目标 JSON,说明需要人工复核;如果不能识别,重跑当前 step 或重新调用对应模型接口,并告诉用户“模型 JSON 不工整,正在复核并重试”。
+每出现一行 `错误反馈`,都要立刻告诉用户失败阶段、接口 URL、状态码和响应摘要。
+
+每出现一行 `JSON解析复核`,都要查看 `响应片段`。如果能明显识别出目标 JSON,说明需要人工复核;如果不能识别,重跑当前 step 或重新调用对应模型接口,并告诉用户“模型 JSON 不工整,正在复核并重试”。
全部执行结束后再读取 `xdfx-flow-summary.md` 和命令输出,向用户说明:
@@ -109,3 +133,4 @@ python scripts/xdfx_flow.py --mode full --input xdfx-input.json --dry-run
- 失败明细
只要 `errors` 不为空,就说“部分完成”,不要说“全部完成”。
+
diff --git a/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_auth.py b/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_auth.py
new file mode 100644
index 00000000..fe46f809
--- /dev/null
+++ b/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_auth.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""Authentication helper for xdfx skill scripts.
+
+The caller should not pass tokens through command-line arguments. This module
+owns the fixed admin login flow and returns a Bearer token before business
+interfaces are called.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import ssl
+import urllib.error
+import urllib.parse
+import urllib.request
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass
+class AuthResult:
+ token: str
+ url: str
+ status: int | None
+ raw: str
+
+
+class AuthError(RuntimeError):
+ def __init__(
+ self,
+ message: str,
+ *,
+ url: str,
+ status: int | None = None,
+ response: str = "",
+ exception: BaseException | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.url = url
+ self.status = status
+ self.response = response
+ self.exception = exception
+
+
+def auth_header_value(token: str) -> str:
+ token = token.strip()
+ return token if token.lower().startswith("bearer ") else f"Bearer {token}"
+
+
+def redact_url(url: str) -> str:
+ return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
+
+
+def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
+ if verify_ssl:
+ return None
+ return ssl._create_unverified_context()
+
+
+def build_login_url(api_base: str, path: str, password: str) -> str:
+ base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
+ if password:
+ separator = "&" if "?" in base else "?"
+ base = f"{base}{separator}password={urllib.parse.quote(password, safe='')}"
+ return base
+
+
+def extract_access_token(data: Any) -> str | None:
+ if isinstance(data, dict):
+ for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
+ value = data.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ for value in data.values():
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ elif isinstance(data, list):
+ for value in data:
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ return None
+
+
+def parse_json_loose(text: str) -> Any:
+ try:
+ return json.loads(text)
+ except Exception:
+ pass
+ match = re.search(r"[\[{]", text or "")
+ if not match:
+ return {}
+ start = match.start()
+ for end in range(len(text), start, -1):
+ try:
+ return json.loads(text[start:end])
+ except Exception:
+ continue
+ return {}
+
+
+def fetch_access_token(
+ *,
+ api_base: str,
+ username: str = "admin",
+ password: str = "ch@user",
+ login_path: str = "/api/v1/auth/login/username",
+ timeout: float = 90,
+ verify_ssl: bool = False,
+) -> AuthResult:
+ """Login once and return the extracted access token."""
+ url = build_login_url(api_base, login_path, password)
+ payload = {"username": username}
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ parsed = parse_json_loose(raw)
+ token = extract_access_token(parsed)
+ if not token:
+ raise AuthError(
+ "登录接口已响应但未找到 access_token",
+ url=redact_url(url),
+ status=getattr(resp, "status", None),
+ response=raw,
+ )
+ return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
+ raise AuthError(
+ "登录接口返回错误",
+ url=redact_url(url),
+ status=exc.code,
+ response=body,
+ exception=exc,
+ ) from exc
+ except AuthError:
+ raise
+ except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
+ raise AuthError(
+ "登录接口调用异常",
+ url=redact_url(url),
+ response=str(exc),
+ exception=exc,
+ ) from exc
diff --git a/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_flow.py b/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_flow.py
index 52880e82..5f405cc8 100644
--- a/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_flow.py
+++ b/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_flow.py
@@ -26,7 +26,10 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
+import xdfx_auth
+
+# Keep every backend path in one table so endpoint drift is easy to audit.
ENDPOINTS = {
"aiuser": "/gpt/chat/chat_aiuser",
"group": "/gpt/chat/chat_qianqian_group_chat",
@@ -44,10 +47,12 @@ ENDPOINTS = {
"save_narrate": "/api/base/base-narrate",
"save_message": "/api/task/task-action-message",
"save_strategy_relation": "/api/task/task-action-design-strategy-relation",
+ "dict_data": "/api/sys/sys-dict-data",
}
+# Human-readable names are used only in the Chinese feedback logs.
ENDPOINT_LABELS = {
- "login": "登录获取访问 token",
+ "login": "登录获取访问令牌",
"aiuser": "调用候选账号/受众分析模型接口",
"group": "调用受众目标结构化抽取接口",
"spread": "调用关键传播节点结构化抽取接口",
@@ -64,8 +69,86 @@ ENDPOINT_LABELS = {
"save_narrate": "保存策略信息主题",
"save_message": "保存策略消息配置",
"save_strategy_relation": "保存策略与受众/传播节点关系",
+ "dict_data": "读取前端字典数据",
}
+LOG_PREFIXES = {
+ "operation": "操作反馈",
+ "error": "错误反馈",
+ "json_review": "JSON解析复核",
+ "save": "入库反馈",
+ "step": "步骤反馈",
+ "done": "执行完成",
+}
+
+STATUS_LABELS = {
+ "ok": "成功",
+ "partial": "部分完成",
+ "skipped": "已跳过",
+}
+
+STEP_NUMBER_LABELS = {
+ "step1": "第一步",
+ "step2": "第二步",
+ "step3": "第三步",
+ "step4": "第四步",
+ "step5": "第五步",
+ "step6": "第六步",
+}
+
+HTTP_METHOD_LABELS = {
+ "GET": "读取(GET)",
+ "POST": "提交(POST)",
+ "PUT": "更新(PUT)",
+ "PATCH": "局部更新(PATCH)",
+ "DELETE": "删除(DELETE)",
+}
+
+STAGE_SEGMENT_LABELS = {
+ "analyze": "模型分析",
+ "extract": "结构化抽取",
+ "save": "保存数据",
+ "read": "读取数据",
+ "status": "同步状态",
+}
+
+REQUEST_FIELD_LABELS = {
+ "history": "历史消息",
+ "max_tokens": "最大生成令牌数",
+ "model_name": "模型名称",
+ "otherinfo": "其他信息",
+ "prompt_name": "提示词名称",
+ "stream": "是否流式",
+ "temperature": "温度",
+ "usermessage": "用户消息",
+ "user_message": "用户消息",
+ "mubiao": "行动目标",
+ "zhanghao": "账号列表",
+ "taskId": "任务ID",
+ "actionId": "行动ID",
+ "taskActionId": "行动ID",
+ "designId": "受众ID",
+ "pathwayId": "传播节点ID",
+ "strategyId": "策略ID",
+ "accountId": "账号ID",
+ "accountName": "账号名称",
+ "accountType": "账号类型",
+ "targetAudience": "受众目标",
+ "vulnerabilities": "脆弱点",
+ "topic": "信息标题",
+ "narrate": "叙事主题",
+ "platform": "平台",
+ "persona": "发布人设",
+ "modelType": "信息形式",
+ "quantity": "数量",
+ "publishTime": "发布时间段",
+ "reason": "理由",
+ "ids": "ID列表",
+ "id": "ID",
+ "step": "步骤状态",
+}
+
+# Platform values mirror the frontend dictionaries used by xdfx strategy pages.
PLATFORM_MAP = {
"facebook": 1,
"fb": 1,
@@ -78,6 +161,8 @@ PLATFORM_MAP = {
"yt": 4,
}
+# The model service sometimes returns a successful HTTP response with these
+# strings instead of useful content. Treat them like transient failures.
MODEL_RETRY_MARKERS = (
"y模型没有响应",
"模型没有响应",
@@ -118,6 +203,12 @@ class FlowError(RuntimeError):
@dataclass
class FlowContext:
+ """Runtime options shared by every step.
+
+ Keep this object boring and serializable-ish: it is passed through every
+ helper, while durable progress belongs in the state JSON file.
+ """
+
api_base: str
headers: dict[str, str]
timeout: float
@@ -129,6 +220,8 @@ class FlowContext:
max_pathways: int = 3
max_vulns: int = 3
max_strategies: int = 3
+ downstream_audiences: int = 2
+ downstream_pathways: int = 2
sync_step_status: bool = True
model_retries: int = 1
verify_ssl: bool = False
@@ -153,7 +246,7 @@ def compact(value: Any, limit: int = 1400) -> str:
text = repr(value)
if len(text) <= limit:
return text
- return text[:limit] + f"... "
+ return text[:limit] + f"... <已截断 {len(text) - limit} 个字符>"
def stable_json(value: Any) -> str:
@@ -223,6 +316,11 @@ def save_json_file(path: str | Path, value: Any) -> None:
def default_state() -> dict[str, Any]:
+ """Create a resumable state container.
+
+ The script can be run as six independent commands. IDs returned by one step
+ are persisted here so later steps can resume without redoing earlier saves.
+ """
return {
"api_base": "",
"taskId": "",
@@ -247,6 +345,7 @@ def default_state() -> dict[str, Any]:
def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> None:
+ # Accept both frontend-style camelCase and CLI-friendly snake_case inputs.
aliases = {
"task_id": "taskId",
"action_id": "actionId",
@@ -260,16 +359,12 @@ def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> No
continue
if dest in {"taskId", "actionId"}:
state[dest] = str(value)
- elif dest in state or dest in {
- "api_base",
- "headers",
- "auth_token",
- "authorization",
- }:
+ elif dest in state or dest in {"api_base", "headers"}:
state[dest] = value
def record_event(state: dict[str, Any], stage: str, message: str, data: Any | None = None) -> None:
+ """Append a compact diagnostic event to the state file."""
state.setdefault("events", []).append(
{
"time": utc_now(),
@@ -286,9 +381,13 @@ def feedback_step(stage: str) -> str:
return match.group(1) if match else ""
+def feedback_step_label(stage: str) -> str:
+ return STEP_NUMBER_LABELS.get(feedback_step(stage), "")
+
+
def request_keys(payload: Any) -> list[str]:
if isinstance(payload, dict):
- return sorted(str(key) for key in payload.keys())
+ return sorted(REQUEST_FIELD_LABELS.get(str(key), str(key)) for key in payload.keys())
return []
@@ -306,6 +405,77 @@ def endpoint_label(endpoint_key: str | None, method: str, url: str) -> str:
return f"调用接口 {method}"
+def method_label(method: str) -> str:
+ return HTTP_METHOD_LABELS.get(str(method).upper(), str(method))
+
+
+def yes_no(value: bool) -> str:
+ return "是" if value else "否"
+
+
+def stage_label(stage: Any) -> str:
+ text = str(stage or "")
+ if not text:
+ return ""
+ if text in STEP_LABELS:
+ return STEP_LABELS[text]
+ if text == "runner":
+ return "流程执行器"
+ parts = text.split("/")
+ base = parts[0]
+ match = re.search(r"(step[1-6])", base)
+ labels: list[str] = []
+ if match:
+ labels.append(STEP_LABELS.get(match.group(1), match.group(1)))
+ elif base == "runner":
+ labels.append("流程执行器")
+ else:
+ labels.append(base)
+ for segment in parts[1:]:
+ normalized = segment.replace("-", "_")
+ attempt = re.fullmatch(r"model-call-attempt-(\d+)", segment)
+ json_attempt = re.fullmatch(r"model-json-attempt-(\d+)", segment)
+ if attempt:
+ labels.append(f"模型接口第 {attempt.group(1)} 次尝试")
+ elif json_attempt:
+ labels.append(f"JSON解析第 {json_attempt.group(1)} 次尝试")
+ elif segment == "auto-login":
+ labels.append("自动登录")
+ elif segment in STAGE_SEGMENT_LABELS:
+ labels.append(STAGE_SEGMENT_LABELS[segment])
+ elif normalized in ENDPOINT_LABELS:
+ labels.append(ENDPOINT_LABELS[normalized])
+ else:
+ labels.append(segment)
+ return " / ".join(labels)
+
+
+def status_label(status: Any) -> str:
+ if status is None:
+ return ""
+ return STATUS_LABELS.get(str(status), str(status))
+
+
+def mode_label(mode: str) -> str:
+ if mode == "full":
+ return "全流程"
+ return STEP_LABELS.get(mode, mode)
+
+
+def exception_type_label(exception_type: Any) -> str:
+ name = str(exception_type or "")
+ labels = {
+ "HTTPError": "HTTP 调用异常",
+ "URLError": "网络地址异常",
+ "TimeoutError": "请求超时异常",
+ "FlowError": "流程异常",
+ "ValueError": "数据格式异常",
+ "KeyError": "字段缺失异常",
+ "RuntimeError": "运行异常",
+ }
+ return labels.get(name, "程序异常" if name else "")
+
+
def emit_operation_feedback(
state: dict[str, Any],
stage: str,
@@ -317,39 +487,41 @@ def emit_operation_feedback(
attempt: int = 1,
dry_run: bool = False,
) -> None:
+ # Feedback lines are a mini protocol consumed by the calling agent. They
+ # must stay single-line JSON so weak models/tools can relay them reliably.
payload_obj = {
- "type": "OP_FEEDBACK",
- "step": feedback_step(stage),
- "stage": stage,
- "operation": endpoint_label(endpoint_key, method, url),
- "method": method,
- "url": url,
- "endpoint": endpoint_key or "",
- "attempt": attempt,
- "dryRun": dry_run,
- "requestKeys": request_keys(payload),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "操作反馈",
+ "步骤": feedback_step_label(stage),
+ "阶段": stage_label(stage),
+ "操作": endpoint_label(endpoint_key, method, url),
+ "请求方式": method_label(method),
+ "接口地址": url,
+ "接口名称": endpoint_label(endpoint_key, method, url),
+ "第几次尝试": attempt,
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("OP_FEEDBACK " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["operation"] + " " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
def emit_error_feedback(error: dict[str, Any]) -> None:
payload = {
- "type": "ERROR_FEEDBACK",
- "stage": error.get("stage"),
- "step": feedback_step(str(error.get("stage") or "")),
- "message": error.get("message"),
- "url": error.get("url"),
- "status": error.get("status"),
- "request": error.get("request"),
- "response": error.get("response"),
- "exceptionType": error.get("exceptionType"),
- "exception": error.get("exception"),
- "time": error.get("time"),
+ "日志类型": "错误反馈",
+ "阶段": stage_label(error.get("stage")),
+ "步骤": feedback_step_label(str(error.get("stage") or "")),
+ "错误信息": error.get("message"),
+ "接口地址": error.get("url"),
+ "状态码": error.get("status"),
+ "请求摘要": error.get("request"),
+ "响应摘要": error.get("response"),
+ "异常类型": exception_type_label(error.get("exceptionType")),
+ "异常信息": error.get("exception"),
+ "时间": error.get("time"),
}
- print("ERROR_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["error"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def emit_json_parse_review(
@@ -360,18 +532,89 @@ def emit_json_parse_review(
candidate_count: int,
) -> None:
payload = {
- "type": "JSON_PARSE_REVIEW",
- "stage": stage,
- "step": feedback_step(stage),
- "endpoint": endpoint_key,
- "message": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
- "candidateCount": candidate_count,
- "responseExcerpt": compact(text, 3000),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "JSON解析复核",
+ "阶段": stage_label(stage),
+ "步骤": feedback_step_label(stage),
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, endpoint_key),
+ "提示": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
+ "候选数量": candidate_count,
+ "响应片段": compact(text, 3000),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("JSON_PARSE_REVIEW " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["json_review"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
+
+
+SAVE_ENTITY_LABELS = {
+ "save_design": "受众目标",
+ "save_design_account": "受众目标账号",
+ "save_pathway": "关键传播节点",
+ "save_relation": "受众-传播节点关系",
+ "save_design_vuln": "受众脆弱点",
+ "save_pathway_vuln": "传播节点脆弱点",
+ "save_strategy": "策略",
+ "save_channel": "策略平台与人设",
+ "save_narrate": "策略信息主题",
+ "save_message": "策略消息配置",
+ "save_strategy_relation": "策略关联关系",
+}
+
+
+def saved_entity_name(endpoint_key: str, payload: dict[str, Any]) -> str:
+ """Return the most user-readable name for an inserted row."""
+ candidates = [
+ payload.get("targetAudience"),
+ payload.get("accountName"),
+ payload.get("vulnerabilities"),
+ payload.get("topic"),
+ payload.get("narrate"),
+ payload.get("platform"),
+ payload.get("strategyId"),
+ payload.get("designId"),
+ payload.get("pathwayId"),
+ ]
+ for value in candidates:
+ if value is not None and str(value).strip():
+ return str(value).strip()
+ return endpoint_key
+
+
+def emit_save_feedback(
+ state: dict[str, Any],
+ stage: str,
+ endpoint_key: str,
+ payload: dict[str, Any],
+ row_id: int,
+ *,
+ dry_run: bool,
+) -> None:
+ # Emit only after an id is known. Downstream steps use these ids and should
+ # fail loudly rather than inventing relations against missing rows.
+ label = SAVE_ENTITY_LABELS.get(endpoint_key, endpoint_key)
+ name = saved_entity_name(endpoint_key, payload)
+ feedback = {
+ "日志类型": "入库反馈",
+ "步骤": feedback_step(stage),
+ "阶段": stage,
+ "入库对象类型": label,
+ "入库对象名称": name,
+ "返回ID": row_id,
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, label),
+ "接口路径": ENDPOINTS.get(endpoint_key, ""),
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
+ }
+ print(LOG_PREFIXES["save"] + " " + json.dumps(feedback, ensure_ascii=False), flush=True)
+ record_event(
+ state,
+ stage,
+ f"入库{label}成功:{name}",
+ {"id": row_id, "endpoint": endpoint_key, "dryRun": dry_run},
+ )
def record_error(
@@ -385,6 +628,8 @@ def record_error(
response: Any | None = None,
exception: BaseException | None = None,
) -> None:
+ # Persist full-enough error context for post-run diagnosis, then immediately
+ # emit a short line the agent can show to the user.
item = {
"time": utc_now(),
"stage": stage,
@@ -430,65 +675,25 @@ def url_for_path(ctx: FlowContext, path: str) -> str:
return ctx.api_base.rstrip("/") + "/" + path.lstrip("/")
-def parse_headers(header_values: list[str], auth_token: str | None, authorization: str | None) -> dict[str, str]:
+def parse_headers(header_values: list[str]) -> dict[str, str]:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
- if auth_token:
- headers["Authorization"] = auth_token if auth_token.lower().startswith("bearer ") else f"Bearer {auth_token}"
- if authorization:
- headers["Authorization"] = authorization
for raw in header_values:
if ":" not in raw:
raise FlowError(f"header 格式错误,应为 Key: Value,实际:{raw}")
key, value = raw.split(":", 1)
- headers[key.strip()] = value.strip()
+ normalized = key.strip()
+ if normalized.lower() == "authorization":
+ continue
+ headers[normalized] = value.strip()
return headers
-def has_authorization(headers: dict[str, str]) -> bool:
- return any(key.lower() == "authorization" and str(value).strip() for key, value in headers.items())
-
-
def ssl_context(ctx: FlowContext) -> ssl.SSLContext | None:
if ctx.verify_ssl:
return None
return ssl._create_unverified_context()
-def auth_header_value(token: str) -> str:
- token = token.strip()
- return token if token.lower().startswith("bearer ") else f"Bearer {token}"
-
-
-def extract_access_token(data: Any) -> str | None:
- if isinstance(data, dict):
- for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
- value = data.get(key)
- if isinstance(value, str) and value.strip():
- return value.strip()
- for value in data.values():
- nested = extract_access_token(value)
- if nested:
- return nested
- elif isinstance(data, list):
- for value in data:
- nested = extract_access_token(value)
- if nested:
- return nested
- return None
-
-
-def redact_url(url: str) -> str:
- return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
-
-
-def build_login_url(ctx: FlowContext, path: str) -> str:
- base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else url_for_path(ctx, path)
- if ctx.login_password:
- separator = "&" if "?" in base else "?"
- base = f"{base}{separator}password={urllib.parse.quote(ctx.login_password, safe='')}"
- return base
-
-
def login_candidate_paths(ctx: FlowContext) -> list[str]:
paths = [
ctx.login_path or "/api/v1/auth/login/username",
@@ -506,6 +711,7 @@ def login_candidate_paths(ctx: FlowContext) -> list[str]:
def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force: bool = False) -> bool:
+ """Fetch a fresh token through the local auth package before business calls."""
if not ctx.auto_login:
return False
if ctx.dry_run:
@@ -515,7 +721,7 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
record_error(state, stage, "自动登录失败:缺少 login_username")
return False
if not force and ctx.login_attempt_count > 0:
- return has_authorization(ctx.headers)
+ return any(key.lower() == "authorization" and str(value).strip() for key, value in ctx.headers.items())
if force and ctx.login_attempt_count >= 2:
return False
@@ -525,8 +731,7 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
last_response = ""
last_exc: BaseException | None = None
for attempt, path in enumerate(login_candidate_paths(ctx), start=1):
- url = build_login_url(ctx, path)
- display_url = redact_url(url)
+ display_url = xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, path, ctx.login_password))
emit_operation_feedback(
state,
stage,
@@ -537,63 +742,39 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
attempt=attempt,
)
try:
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- headers = {
- "Content-Type": "application/json",
- "Accept": "application/json",
- }
- req = urllib.request.Request(url, data=data, headers=headers, method="POST")
- with urllib.request.urlopen(req, timeout=ctx.timeout, context=ssl_context(ctx)) as resp:
- raw = resp.read().decode("utf-8", errors="replace")
- parsed = parse_json_lenient(raw)
- token = extract_access_token(parsed)
- if not token:
- last_status = getattr(resp, "status", None)
- last_response = raw
- record_event(
- state,
- stage,
- "登录接口已响应但未找到 access_token",
- {"url": display_url, "status": last_status, "response": compact(raw, 1000)},
- )
- continue
- ctx.headers["Authorization"] = auth_header_value(token)
- record_event(
- state,
- stage,
- "自动登录成功,已写入 Authorization Bearer token",
- {
- "url": display_url,
- "status": getattr(resp, "status", None),
- "username": ctx.login_username,
- },
- )
- return True
- except urllib.error.HTTPError as exc:
- last_status = exc.code
- last_response = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
- last_exc = exc
- record_event(
- state,
- stage,
- "登录接口返回错误,准备尝试下一个候选登录路径",
- {"url": display_url, "status": exc.code, "response": compact(last_response, 1000)},
+ result = xdfx_auth.fetch_access_token(
+ api_base=ctx.api_base,
+ username=ctx.login_username,
+ password=ctx.login_password,
+ login_path=path,
+ timeout=ctx.timeout,
+ verify_ssl=ctx.verify_ssl,
)
- except Exception as exc: # noqa: BLE001 - auto-login must report every failure.
- last_exc = exc
- last_response = traceback.format_exc()
+ ctx.headers["Authorization"] = xdfx_auth.auth_header_value(result.token)
+ state["_tokenSource"] = "xdfx_auth"
record_event(
state,
stage,
- "登录接口调用异常,准备尝试下一个候选登录路径",
- {"url": display_url, "exceptionType": type(exc).__name__, "exception": str(exc)},
+ "启动阶段已通过 xdfx_auth 包获取 token,并写入 Authorization",
+ {"url": result.url, "status": result.status, "username": ctx.login_username},
+ )
+ return True
+ except xdfx_auth.AuthError as exc:
+ last_status = exc.status
+ last_response = exc.response
+ last_exc = exc
+ record_event(
+ state,
+ stage,
+ "xdfx_auth 获取 token 失败,准备尝试下一个候选登录路径",
+ {"url": exc.url, "status": exc.status, "response": compact(exc.response, 1000), "exception": str(exc)},
)
record_error(
state,
stage,
- "自动登录失败,未能获取 access_token",
- url=redact_url(build_login_url(ctx, ctx.login_path or "/api/v1/auth/login/username")),
+ "自动登录失败,未能通过 xdfx_auth 获取 access_token",
+ url=xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, ctx.login_path or "/api/v1/auth/login/username", ctx.login_password)),
status=last_status,
request=payload,
response=last_response,
@@ -612,6 +793,7 @@ def http_request_json_url(
endpoint_key: str | None = None,
allow_auto_login: bool = True,
) -> tuple[Any | None, str]:
+ """Call an HTTP JSON endpoint with logging, retries, and 401 recovery."""
method = method.upper()
if ctx.dry_run:
emit_operation_feedback(state, stage, method, url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
@@ -640,6 +822,7 @@ def http_request_json_url(
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
if exc.code == 401 and allow_auto_login and ctx.auto_login:
+ # Refresh once on 401, then replay the original request exactly.
refreshed = try_auto_login(ctx, state, f"{stage}/auto-login", force=True)
if refreshed:
record_event(
@@ -709,6 +892,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
emit_operation_feedback(state, stage, "POST", url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
record_event(state, stage, f"DRY RUN POST {url}", {"payload": payload})
if endpoint_key.startswith("save_"):
+ # Synthetic ids let downstream dry-run code exercise relation wiring.
synthetic_id = abs(hash(stage + stable_json(payload))) % 100000000
return {"id": synthetic_id, "dryRun": True}, json.dumps({"id": synthetic_id, "dryRun": True})
return {"dryRun": True}, json.dumps({"dryRun": True})
@@ -716,6 +900,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
def parse_json_lenient(text: str) -> Any | None:
+ """Parse normal JSON plus adjacent/SSE-like JSON objects."""
stripped = text.strip()
if not stripped:
return None
@@ -730,6 +915,7 @@ def parse_json_lenient(text: str) -> Any | None:
def parse_concatenated_json_objects(text: str) -> list[Any]:
+ """Read streams such as {"answer": "..."}{"answer": "..."} or data: lines."""
decoder = json.JSONDecoder()
objects: list[Any] = []
i = 0
@@ -766,6 +952,7 @@ def parse_concatenated_json_objects(text: str) -> list[Any]:
def model_text_from_response(parsed: Any, raw: str) -> tuple[str, list[Any]]:
+ """Flatten streamed model chunks into one text buffer."""
objects: list[Any]
if isinstance(parsed, list):
objects = parsed
@@ -824,6 +1011,7 @@ def extract_json_from_text(text: str) -> Any | None:
def extract_json_candidates_from_text(text: str) -> list[Any]:
+ """Collect every plausible JSON object/array from messy model text."""
cleaned = strip_think(text)
candidates: list[Any] = []
seen: set[str] = set()
@@ -860,6 +1048,7 @@ def extract_json_candidates_from_text(text: str) -> list[Any]:
def select_json_candidate(candidates: list[Any], endpoint_key: str, stage: str) -> Any | None:
+ """Pick the candidate whose keys best match the current step."""
if not candidates:
return None
scored = [(json_candidate_score(candidate, endpoint_key, stage), idx, candidate) for idx, candidate in enumerate(candidates)]
@@ -1010,6 +1199,7 @@ def extract_first_balanced(text: str, open_ch: str, close_ch: str) -> str | None
def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[str, list[Any], Any | None]:
+ """Call a model endpoint and retry only known empty/timeout/no-response cases."""
max_attempts = max(ctx.model_retries, 0) + 1
last_text = ""
last_objects: list[Any] = []
@@ -1048,6 +1238,11 @@ def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str,
def call_model_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[dict[str, Any], str, list[Any]]:
+ """Return the best structured JSON for a model step.
+
+ If the first parse fails, the second attempt includes an explicit repair
+ instruction so weak models can re-emit only the required JSON.
+ """
last_text = ""
last_objects: list[Any] = []
last_candidate_count = 0
@@ -1202,23 +1397,85 @@ def profile_info(account: dict[str, Any]) -> str:
def parse_index_list(value: Any) -> list[str]:
- if value is None:
- return []
- if isinstance(value, list):
- return [str(v).strip() for v in value if str(v).strip()]
- text = str(value)
- return [part.strip().strip("<>").strip() for part in re.split(r"[,,;;、\s]+", text) if part.strip()]
+ refs: list[str] = []
+
+ def add(raw: Any) -> None:
+ if raw is None:
+ return
+ if isinstance(raw, dict):
+ keys = (
+ "编号id",
+ "编号ID",
+ "用户编号id",
+ "用户编号ID",
+ "用户编号",
+ "账号编号",
+ "引用序号",
+ "索引",
+ "index",
+ "id",
+ "ids",
+ "accountIndex",
+ "accountIndexes",
+ "userIndex",
+ "userIndexes",
+ )
+ for key in keys:
+ if key in raw:
+ add(raw.get(key))
+ return
+ if isinstance(raw, (list, tuple, set)):
+ for item in raw:
+ add(item)
+ return
+ text = str(raw).strip()
+ if not text:
+ return
+ angle_refs = re.findall(r"<<\s*([^<>]+?)\s*>>", text)
+ if angle_refs:
+ for token in angle_refs:
+ add(token)
+ return
+ for part in re.split(r"[,,;;、\s]+", text):
+ token = part.strip().strip("<>").strip()
+ if token:
+ refs.append(token)
+
+ add(value)
+ deduped: list[str] = []
+ seen: set[str] = set()
+ for ref in refs:
+ if ref in seen:
+ continue
+ seen.add(ref)
+ deduped.append(ref)
+ return deduped
+
+
+def account_ref_numbers(account: dict[str, Any]) -> set[int]:
+ numbers: set[int] = set()
+ meta = account.get("metadata") if isinstance(account.get("metadata"), dict) else {}
+ for key in ("编号id", "编号ID", "用户编号id", "用户编号ID", "用户编号", "账号编号", "引用序号", "索引", "index", "accountIndex"):
+ for container in (account, meta):
+ number = as_int(container.get(key)) if isinstance(container, dict) else None
+ if number is not None:
+ numbers.add(number)
+ return numbers
def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, Any] | None:
+ """Resolve model references like <<3>>, index numbers, names, or account ids."""
if not accounts:
return None
text = str(ref).strip().strip("<>").strip()
number = as_int(text)
if number is not None:
for account in accounts:
- if any(as_int(account.get(key)) == number for key in ("引用序号", "索引", "index")):
+ if number in account_ref_numbers(account):
return account
+ # Step1 group extraction returns 编号/id values that refer to the
+ # previous zhanghao array index. Prefer exact index fields above, then
+ # fall back to zero-based and one-based array positions.
if 0 <= number < len(accounts):
return accounts[number]
if 1 <= number <= len(accounts):
@@ -1232,11 +1489,25 @@ def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, A
def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Normalize audience JSON whether the model used numbered fields or items[]."""
items: list[dict[str, Any]] = []
for i in range(1, 21):
group = first_present(data.get(f"群体{i}"), data.get(f"受众{i}"), data.get(f"targetAudience{i}"))
reason = first_present(data.get(f"理由{i}"), data.get(f"推荐理由{i}"), data.get(f"recReason{i}"))
- users = first_present(data.get(f"用户编号列表{i}"), data.get(f"用户列表{i}"), data.get(f"账号编号列表{i}"))
+ users = first_present(
+ data.get(f"用户编号列表{i}"),
+ data.get(f"编号id列表{i}"),
+ data.get(f"编号ID列表{i}"),
+ data.get(f"用户编号id列表{i}"),
+ data.get(f"用户编号ID列表{i}"),
+ data.get(f"用户列表{i}"),
+ data.get(f"账号编号列表{i}"),
+ data.get(f"索引列表{i}"),
+ data.get(f"编号{i}"),
+ data.get(f"id{i}"),
+ data.get(f"ids{i}"),
+ data.get(f"索引{i}"),
+ )
if group:
items.append(
{
@@ -1255,7 +1526,27 @@ def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
{
"targetAudience": str(group).strip(),
"recReason": str(first_present(item.get("理由"), item.get("recReason"), item.get("reason")) or "").strip(),
- "userIndexes": parse_index_list(first_present(item.get("用户编号列表"), item.get("users"), item.get("accounts"))),
+ "userIndexes": parse_index_list(
+ first_present(
+ item.get("用户编号列表"),
+ item.get("编号id列表"),
+ item.get("编号ID列表"),
+ item.get("用户编号id列表"),
+ item.get("用户编号ID列表"),
+ item.get("编号id"),
+ item.get("编号ID"),
+ item.get("用户编号id"),
+ item.get("用户编号ID"),
+ item.get("编号"),
+ item.get("索引"),
+ item.get("id"),
+ item.get("ids"),
+ item.get("users"),
+ item.get("accounts"),
+ item.get("userIndexes"),
+ item.get("accountIndexes"),
+ )
+ ),
"rank": i,
}
)
@@ -1272,6 +1563,7 @@ def audience_score(item: dict[str, Any]) -> tuple[int, int]:
def select_top_audiences(items: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
+ """Deduplicate audience names and keep the strongest candidates."""
unique: dict[str, dict[str, Any]] = {}
for item in items:
key = item.get("targetAudience", "")
@@ -1301,6 +1593,7 @@ def pathway_score(item: dict[str, Any]) -> tuple[int, int, int]:
def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Merge the same account found from different audiences before saving top 3."""
merged: dict[str, dict[str, Any]] = {}
for item in candidates:
account = item.get("account") or {}
@@ -1325,6 +1618,7 @@ def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str,
def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
+ """Normalize weak-point model output into a capped list."""
vulns: list[dict[str, str]] = []
for i in range(1, 21):
vuln = first_present(data.get(f"脆弱点{i}"), data.get(f"vulnerability{i}"), data.get(f"vuln{i}"))
@@ -1339,6 +1633,7 @@ def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
def extract_strategies(data: dict[str, Any], limit: int) -> list[dict[str, Any]]:
+ """Normalize strategy output and preserve the modal text for later mapping."""
strategies: list[dict[str, Any]] = []
for i in range(1, 21):
title = first_present(data.get(f"信息标题{i}"), data.get(f"主题{i}"), data.get(f"topic{i}"))
@@ -1395,11 +1690,29 @@ def response_id(response: Any) -> int | None:
return None
+def response_rows(response: Any) -> list[Any]:
+ """Extract list rows from common CRUD response shapes."""
+ if isinstance(response, list):
+ return response
+ if not isinstance(response, dict):
+ return []
+ for key in ("data", "rows", "items", "records", "list"):
+ value = response.get(key)
+ if isinstance(value, list):
+ return value
+ rows = response_rows(value)
+ if rows:
+ return rows
+ return []
+
+
def save_with_id(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[int | None, Any | None]:
response, _ = http_post_json(ctx, state, endpoint_key, payload, stage)
rid = response_id(response)
if rid is None:
- record_error(state, stage, "保存接口未返回 id,视为失败", request=payload, response=response)
+ record_error(state, stage, "保存接口未返回 ID,视为失败", request=payload, response=response)
+ else:
+ emit_save_feedback(state, stage, endpoint_key, payload, rid, dry_run=ctx.dry_run)
return rid, response
@@ -1429,6 +1742,7 @@ def parse_step_flags(raw: Any) -> dict[str, Any]:
def mark_step_status(ctx: FlowContext, state: dict[str, Any], links: list[str], stage: str) -> None:
+ """Update the frontend step bar without overwriting unrelated step flags."""
if not ctx.sync_step_status:
record_event(state, stage, "跳过 task-action.step 状态同步")
return
@@ -1477,12 +1791,126 @@ def strategy_platform(value: Any) -> Any:
return platform_to_number(value) or 1
-def strategy_model_type(value: Any) -> str:
- # Frontend defaults to text model type "1" when no modal type is selected.
- raw = first_present(value, "1")
- if isinstance(raw, list):
- return ",".join(str(v) for v in raw if str(v).strip()) or "1"
- return str(raw or "1")
+def choice_tokens(value: Any) -> list[Any]:
+ """Split frontend choice values from arrays, JSON strings, and mixed delimiters."""
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return choice_tokens(
+ first_present(value.get("value"), value.get("dictValue"), value.get("label"), value.get("dictLabel"))
+ )
+ if isinstance(value, (list, tuple, set)):
+ tokens: list[Any] = []
+ for item in value:
+ tokens.extend(choice_tokens(item))
+ return tokens
+ text = str(value).strip()
+ if not text:
+ return []
+ if text[0] in "[{":
+ try:
+ parsed = json.loads(text)
+ if parsed != value:
+ return choice_tokens(parsed)
+ except Exception:
+ pass
+ return [part.strip() for part in re.split(r"[,,、;/|]+", text) if part.strip()]
+
+
+def dict_choices(
+ ctx: FlowContext,
+ state: dict[str, Any],
+ dict_type: str,
+ stage: str,
+) -> list[dict[str, Any]]:
+ """Fetch and cache frontend dictionary choices such as modelType."""
+ cache = state.setdefault("_dictChoiceCache", {})
+ if dict_type in cache:
+ cached = cache[dict_type]
+ return cached if isinstance(cached, list) else []
+ if ctx.dry_run:
+ cache[dict_type] = []
+ return []
+
+ query = urllib.parse.urlencode({"filter": f"dictType||$eq||{dict_type}", "limit": 999, "page": 1})
+ url = f"{url_for(ctx, 'dict_data')}?{query}"
+ response, _ = http_request_json_url(ctx, state, "GET", url, None, stage, endpoint_key="dict_data")
+ choices: list[dict[str, Any]] = []
+ for row in response_rows(response):
+ if not isinstance(row, dict):
+ continue
+ label = str(first_present(row.get("dictLabel"), row.get("label"), "") or "").strip()
+ raw_value = first_present(row.get("dictValue"), row.get("value"))
+ if not label or raw_value is None:
+ continue
+ number = as_int(raw_value)
+ choices.append({"label": label, "value": number if number is not None else str(raw_value).strip()})
+ for alias in choice_tokens(row.get("alias")):
+ alias_label = str(alias).strip()
+ if alias_label:
+ choices.append({"label": alias_label, "value": number if number is not None else str(raw_value).strip()})
+ cache[dict_type] = choices
+ return choices
+
+
+def choice_value_by_label(choices: list[dict[str, Any]] | None, label: str) -> Any | None:
+ needle = re.sub(r"\s+", "", str(label or "").lower())
+ if not needle:
+ return None
+ for item in choices or []:
+ item_label = re.sub(r"\s+", "", str(item.get("label") or "").lower())
+ if item_label and (needle == item_label or needle in item_label or item_label in needle):
+ return item.get("value")
+ return None
+
+
+def model_type_token_value(token: Any, choices: list[dict[str, Any]] | None = None) -> Any:
+ """Convert modal labels like 图片/视频 into frontend dictionary values."""
+ number = as_int(token)
+ if number is not None:
+ return number
+
+ text = str(token or "").strip()
+ lower = text.lower()
+ exact = choice_value_by_label(choices, text)
+ if exact is not None:
+ return exact
+
+ label = ""
+ fallback_value = 2
+ if "视" in text or "video" in lower:
+ label, fallback_value = "视频", 3
+ elif "图" in text or "image" in lower or "picture" in lower or "photo" in lower:
+ label, fallback_value = "图片", 2
+ elif "音" in text or "audio" in lower or "sound" in lower:
+ label, fallback_value = "音频", 4
+ elif "发文" in text or "文本" in text or "文字" in text or "text" in lower:
+ label, fallback_value = "文本", 1
+
+ if label:
+ matched = choice_value_by_label(choices, label)
+ if matched is not None:
+ return matched
+ return fallback_value
+
+ # Mirrors the frontend helper's unknown-token default.
+ return 2
+
+
+def strategy_model_type(value: Any, choices: list[dict[str, Any]] | None = None) -> str:
+ """Return the comma-separated value string expected by task-action-message."""
+ tokens = choice_tokens(value)
+ if not tokens:
+ # Frontend save paths fall back to "1" when no modal type is selected.
+ return "1"
+ values: list[str] = []
+ seen: set[str] = set()
+ for token in tokens:
+ normalized = str(model_type_token_value(token, choices)).strip()
+ if normalized and normalized not in seen:
+ values.append(normalized)
+ seen.add(normalized)
+ return ",".join(values) or "1"
def strategy_language(value: Any) -> int:
@@ -1501,11 +1929,28 @@ def save_strategy_details(
*,
audience: bool,
) -> None:
- platform = strategy_platform(first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})))
+ """Save all frontend-readable strategy child rows.
+
+ The strategy table and arrangement page do not read only the main strategy
+ row; they also expect channel, narrate, message, and relation rows.
+ """
+ platform = strategy_platform(
+ first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
+ )
+ account_type = str(
+ first_present(
+ strategy.get("accountType"),
+ item.get("accountType"),
+ item.get("type"),
+ "目标受众账号" if audience else "关键传播节点账号",
+ )
+ or ""
+ ).strip()
persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
title = str(strategy.get("title") or "").strip()
+ model_type_choices = dict_choices(ctx, state, "modelType", stage + "/dict-modelType")
channel_id, _ = save_with_id(
ctx,
@@ -1516,6 +1961,7 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"platform": platform,
+ "accountType": account_type,
"personaJson": json.dumps([{"persona": persona, "size": persona_size}], ensure_ascii=False),
},
stage + "/save-channel",
@@ -1531,7 +1977,10 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"channelId": channel_id,
- "modelType": strategy_model_type(first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType"))),
+ "modelType": strategy_model_type(
+ first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType")),
+ model_type_choices,
+ ),
"language": strategy_language(first_present(strategy.get("language"), strategy.get("语种"))),
"respondingCount": responding_count,
"messageCount": responding_count,
@@ -1564,7 +2013,39 @@ def object_for_audience(audience: dict[str, Any]) -> dict[str, Any]:
}
+def downstream_limit(ctx: FlowContext, *, audience: bool) -> int:
+ """Limit expensive downstream analysis while keeping 3 upstream rows stored."""
+ raw = ctx.downstream_audiences if audience else ctx.downstream_pathways
+ return max(1, raw)
+
+
+def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, audience: bool) -> list[dict[str, Any]]:
+ """Select the 1-2 best stored rows for vulnerability/strategy generation."""
+ source_key = "topAudiences" if audience else "topPathways"
+ id_key = "designId" if audience else "pathwayAccountId"
+ selected_key = "selectedDownstreamAudiences" if audience else "selectedDownstreamPathways"
+ items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)]
+ limit = downstream_limit(ctx, audience=audience)
+ selected = items[:limit]
+ state[selected_key] = [
+ {
+ "id": item.get(id_key),
+ "name": item.get("targetAudience") if audience else item.get("accountName"),
+ }
+ for item in selected
+ ]
+ if items:
+ record_event(
+ state,
+ stage,
+ f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}",
+ {"limit": limit, "selected": state[selected_key]},
+ )
+ return selected
+
+
def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze audiences, save top 3, and attach any referenced accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step1-audience"
if state.get("topAudiences") and not ctx.dry_run:
@@ -1582,12 +2063,15 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
state["audienceRawText"] = text
+ # The first model call may return raw text plus embedded account objects.
accounts = accounts_from_response(parsed, objects, text)
state["audienceAccounts"] = accounts
+ # The second model call converts the long audience analysis into numbered
+ # groups that can be saved as task-action-design rows.
group_payload = {
"mubiao": state["mubiao"],
- "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
"zhanghao": accounts,
"history": [],
"stream": True,
@@ -1602,6 +2086,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
top = select_top_audiences(candidates, ctx.max_audiences)
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the audience first; account rows and downstream analyses depend
+ # on the returned designId.
design_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
@@ -1614,6 +2100,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
continue
item = {**item, "designId": design_id, "saveResponse": resp, "accounts": [], "savedAccountIds": []}
for ref in item.get("userIndexes") or []:
+ # Model references are intentionally fuzzy, so resolve them against
+ # both explicit indexes and account names/ids.
account = resolve_account_ref(ref, accounts)
if account is None:
record_error(state, stage + "/save-design-account", f"用户编号 {ref} 未匹配到候选账号")
@@ -1645,6 +2133,7 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze key pathway accounts for saved audiences and store top 3."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step2-pathway"
audiences = [a for a in state.get("topAudiences") or [] if a.get("designId")]
@@ -1656,6 +2145,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
return
all_candidates: list[dict[str, Any]] = []
for audience in audiences:
+ # Analyze pathway candidates in the context of each saved audience.
payload = {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。",
"otherinfo": {
@@ -1673,6 +2163,8 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
accounts = accounts_from_response(parsed, objects, text)
+ # Ask the model to point back to candidate accounts, then resolve those
+ # references to concrete account objects.
spread_payload = {
"mubiao": state["mubiao"],
"user_message": "分析其中关键信息。请从候选账号中抽取核心传播节点,字段必须是:账号1、理由1、账号2、理由2 ...,最多输出5个。账号字段优先返回候选数组的引用编号或索引,只返回JSON。",
@@ -1707,6 +2199,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways]
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the account row before writing the design-pathway relation.
account = item["account"]
aid = account_id(account)
name = account_name(account)
@@ -1757,12 +2250,14 @@ def run_step4(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate and save weak points for selected audiences or pathway accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step3-audience-vulns" if audience else "step4-pathway-vulns"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
output_key = "audienceVulns" if audience else "pathwayVulns"
state.setdefault(output_key, {})
for item in items:
+ # owner_id is designId for audiences and pathwayAccountId for accounts.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
if not owner_id:
continue
@@ -1789,6 +2284,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) ->
vulns = extract_vulns(data, ctx.max_vulns)
saved: list[dict[str, Any]] = []
for vuln in vulns:
+ # The two weak-point tables have nearly identical payloads but
+ # different foreign-key names.
if audience:
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
@@ -1835,14 +2332,16 @@ def run_step6(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate strategies from saved weak points and persist frontend child rows."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step5-audience-strategy" if audience else "step6-pathway-strategy"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {}
output_key = "audienceStrategies" if audience else "pathwayStrategies"
state.setdefault(output_key, [])
existing_topics = {s.get("topic") for s in state[output_key] if isinstance(s, dict)}
for item in items:
+ # Strategies are only meaningful after the matching weak points exist.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
vulns = vulns_by_owner.get(str(owner_id)) or []
vuln_texts = [v.get("vulnerabilities") for v in vulns if v.get("vulnerabilities")]
@@ -1876,16 +2375,20 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
continue
if topic in existing_topics:
continue
+ # Save the main row first; channel/narrate/message/relation rows all
+ # require the returned strategy id.
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
"targetAudience": target,
"accountType": account_type,
"topic": topic,
- "strategyTitle": "",
+ "strategyTitle": str(strategy.get("title") or "").strip(),
}
strategy_id, resp = save_with_id(ctx, state, "save_strategy", save_payload, stage + "/save")
if strategy_id is not None:
+ # Frontend strategy pages read these child tables, so a strategy
+ # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience)
existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp})
@@ -1914,13 +2417,14 @@ STEP_LABELS = {
def state_counts(state: dict[str, Any]) -> dict[str, int]:
+ """Summarize stored objects for step feedback."""
return {
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceVulns": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
- "pathwayVulns": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "受众脆弱点数量": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
+ "传播节点脆弱点数量": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
}
@@ -1932,23 +2436,26 @@ def emit_step_feedback(
status: str | None = None,
note: str = "",
) -> None:
+ # One feedback line per step keeps the single-skill and split-skill versions
+ # equally transparent to the user.
total_errors = len(state.get("errors") or [])
new_errors = max(0, total_errors - before_error_count)
final_status = status or ("partial" if new_errors else "ok")
payload = {
- "type": "STEP_FEEDBACK",
- "step": step,
- "label": STEP_LABELS.get(step, step),
- "status": final_status,
- "note": note,
- "newErrors": new_errors,
- "totalErrors": total_errors,
- "counts": state_counts(state),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "updatedAt": state.get("updatedAt"),
+ "日志类型": "步骤反馈",
+ "步骤": STEP_NUMBER_LABELS.get(step, step),
+ "步骤名称": STEP_LABELS.get(step, step),
+ "状态": STATUS_LABELS.get(final_status, final_status),
+ "执行状态": status_label(final_status),
+ "备注": note,
+ "本步骤新增错误数": new_errors,
+ "累计错误数": total_errors,
+ "累计入库数量": state_counts(state),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "更新时间": state.get("updatedAt"),
}
- print("STEP_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["step"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def finish_step(
@@ -1960,6 +2467,8 @@ def finish_step(
status: str | None = None,
note: str = "",
) -> None:
+ # Always persist before emitting step feedback so the user can inspect files
+ # immediately after a reported step completes.
state["updatedAt"] = utc_now()
save_json_file(ctx.state_path, state)
if ctx.summary_path:
@@ -1980,6 +2489,11 @@ def skip_step_with_feedback(ctx: FlowContext, state: dict[str, Any], step: str,
def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Legacy full runner.
+
+ The wrapper script is preferred for real-time logs, but this remains useful
+ for dry-runs and unattended local troubleshooting.
+ """
run_step_with_feedback(ctx, state, "step1")
run_step_with_feedback(ctx, state, "step2")
run_step_with_feedback(ctx, state, "step3")
@@ -1995,6 +2509,7 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
def write_summary(path: Path, state: dict[str, Any]) -> None:
+ """Write a human-readable checkpoint beside the machine state JSON."""
audience_vuln_count = sum(len(v) for v in (state.get("audienceVulns") or {}).values())
pathway_vuln_count = sum(len(v) for v in (state.get("pathwayVulns") or {}).values())
lines = [
@@ -2005,6 +2520,8 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
f"- actionId:{state.get('actionId')}",
f"- 受众入库:{len(state.get('topAudiences') or [])}",
f"- 传播节点入库:{len(state.get('topPathways') or [])}",
+ f"- 下游受众选择:{len(state.get('selectedDownstreamAudiences') or [])}",
+ f"- 下游传播节点选择:{len(state.get('selectedDownstreamPathways') or [])}",
f"- 受众脆弱点入库:{audience_vuln_count}",
f"- 传播节点脆弱点入库:{pathway_vuln_count}",
f"- 受众策略入库:{len(state.get('audienceStrategies') or [])}",
@@ -2021,9 +2538,9 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
if state.get("errors"):
lines += ["", "## 失败明细"]
for err in state["errors"]:
- lines.append(f"- [{err.get('stage')}] {err.get('message')} status={err.get('status')} url={err.get('url')}")
+ lines.append(f"- [{stage_label(err.get('stage'))}] {err.get('message')} 状态码={err.get('status')} 接口={err.get('url')}")
if err.get("response"):
- lines.append(f" - response: {err.get('response')}")
+ lines.append(f" - 响应摘要:{err.get('response')}")
text = "\n".join(lines) + "\n"
try:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -2034,34 +2551,34 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
def build_arg_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(description="Run xdfx analysis/storage flow.")
+ parser = argparse.ArgumentParser(description="运行 xdfx 分析与入库流程。")
parser.add_argument("--mode", choices=["full", *STEP_FUNCS.keys()], default="full")
- parser.add_argument("--input", help="JSON config file. Keys: api_base, taskId, actionId, mubiao, direction, platform, headers.")
- parser.add_argument("--state", default="xdfx-flow-state.json", help="State JSON path for resume and step handoff.")
- parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown summary output path.")
+ parser.add_argument("--input", help="JSON 配置文件。常用字段:api_base、taskId、actionId、mubiao、direction、platform、headers。")
+ parser.add_argument("--state", default="xdfx-flow-state.json", help="用于断点续跑和步骤交接的状态 JSON 文件路径。")
+ parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown 执行摘要输出路径。")
parser.add_argument("--api-base")
parser.add_argument("--task-id")
parser.add_argument("--action-id")
parser.add_argument("--mubiao")
parser.add_argument("--direction", default=None)
parser.add_argument("--platform", default=None)
- parser.add_argument("--auth-token", default=os.getenv("XDFX_AUTH_TOKEN"))
- parser.add_argument("--authorization", default=os.getenv("AUTHORIZATION"))
- parser.add_argument("--header", action="append", default=[], help="Extra HTTP header, format 'Key: Value'.")
- parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="Username for automatic login when Authorization is missing or stale.")
- parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="Password query parameter for automatic username login.")
- parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="Login endpoint path or absolute URL.")
- parser.add_argument("--no-auto-login", action="store_true", help="Disable automatic username login and 401 token refresh.")
- parser.add_argument("--verify-ssl", action="store_true", help="Verify HTTPS certificates. Default is disabled for internal/UAT certificates.")
+ parser.add_argument("--header", action="append", default=[], help="附加非鉴权 HTTP 请求头,格式为 'Key: Value';Authorization 会被忽略。")
+ parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="xdfx_auth 启动阶段自动登录使用的用户名。")
+ parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="用户名登录接口的 password 查询参数。")
+ parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="登录接口路径或完整 URL。")
+ parser.add_argument("--no-auto-login", action="store_true", help="关闭 xdfx_auth 自动登录和 401 刷新。")
+ parser.add_argument("--verify-ssl", action="store_true", help="校验 HTTPS 证书。默认不校验,以兼容内网/UAT 自签证书。")
parser.add_argument("--timeout", type=float, default=90)
parser.add_argument("--retries", type=int, default=2)
parser.add_argument("--dry-run", action="store_true")
- parser.add_argument("--no-step-status", action="store_true", help="Do not update task/task-action.step after completed steps.")
- parser.add_argument("--model-retries", type=int, default=1, help="Retry model/stream endpoints when the first response is empty, timeout-like, or says the model did not respond.")
+ parser.add_argument("--no-step-status", action="store_true", help="步骤完成后不更新 task/task-action.step。")
+ parser.add_argument("--model-retries", type=int, default=1, help="模型/流式接口首次空响应、超时或提示模型无响应时的重试次数。")
parser.add_argument("--max-audiences", type=int, default=3)
parser.add_argument("--max-pathways", type=int, default=3)
parser.add_argument("--max-vulns", type=int, default=3)
parser.add_argument("--max-strategies", type=int, default=3)
+ parser.add_argument("--downstream-audiences", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库受众数量,默认 2。")
+ parser.add_argument("--downstream-pathways", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库传播节点数量,默认 2。")
return parser
@@ -2070,6 +2587,8 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
state_path = Path(args.state)
state = default_state()
+ # Load previous state first, then overlay input JSON and finally CLI flags.
+ # That ordering makes resume safe while still allowing explicit overrides.
if state_path.exists():
loaded = load_json_file(state_path)
if isinstance(loaded, dict):
@@ -2081,9 +2600,7 @@ def main(argv: list[str] | None = None) -> int:
merge_config_into_state(state, config)
input_headers = config.get("headers")
if isinstance(input_headers, dict):
- args.header.extend(f"{k}: {v}" for k, v in input_headers.items())
- args.auth_token = args.auth_token or config.get("auth_token")
- args.authorization = args.authorization or config.get("authorization")
+ args.header.extend(f"{k}: {v}" for k, v in input_headers.items() if str(k).lower() != "authorization")
if config.get("login_username") is not None:
args.login_username = str(config.get("login_username") or "")
if config.get("login_password") is not None:
@@ -2094,6 +2611,10 @@ def main(argv: list[str] | None = None) -> int:
args.no_auto_login = True
if config.get("verify_ssl") is not None:
args.verify_ssl = bool(config.get("verify_ssl"))
+ if config.get("downstream_audiences") is not None:
+ args.downstream_audiences = as_int(config.get("downstream_audiences")) or args.downstream_audiences
+ if config.get("downstream_pathways") is not None:
+ args.downstream_pathways = as_int(config.get("downstream_pathways")) or args.downstream_pathways
cli_config = {
"api_base": args.api_base,
@@ -2105,7 +2626,8 @@ def main(argv: list[str] | None = None) -> int:
}
merge_config_into_state(state, {k: v for k, v in cli_config.items() if v is not None})
api_base = state.get("api_base") or "https://ch1.b.uat.4.cn"
- headers = parse_headers(args.header, args.auth_token, args.authorization)
+ headers = parse_headers(args.header)
+ # ctx owns runtime knobs; state owns business data and resumable ids.
ctx = FlowContext(
api_base=api_base,
headers=headers,
@@ -2118,6 +2640,8 @@ def main(argv: list[str] | None = None) -> int:
max_pathways=max(args.max_pathways, 1),
max_vulns=max(args.max_vulns, 1),
max_strategies=max(args.max_strategies, 1),
+ downstream_audiences=max(args.downstream_audiences, 1),
+ downstream_pathways=max(args.downstream_pathways, 1),
sync_step_status=not args.no_step_status,
model_retries=max(args.model_retries, 0),
verify_ssl=bool(args.verify_ssl),
@@ -2131,11 +2655,17 @@ def main(argv: list[str] | None = None) -> int:
state["_summaryPath"] = str(ctx.summary_path) if ctx.summary_path else ""
state["_autoLogin"] = ctx.auto_login
state["_verifySsl"] = ctx.verify_ssl
+ state["_downstreamLimits"] = {
+ "audiences": ctx.downstream_audiences,
+ "pathways": ctx.downstream_pathways,
+ }
record_event(state, "runner", f"start mode={args.mode} dry_run={ctx.dry_run}")
try:
require_context(state, "taskId", "actionId", "mubiao")
- if ctx.auto_login and not has_authorization(ctx.headers):
- try_auto_login(ctx, state, "runner/auto-login")
+ if ctx.auto_login and not ctx.dry_run:
+ # Always acquire a token through the imported auth package before the first business call.
+ if not try_auto_login(ctx, state, "runner/initial-login", force=True):
+ raise FlowError("启动阶段获取 token 失败,已停止执行,避免无 token 调用业务接口")
if args.mode == "full":
run_full(ctx, state)
else:
@@ -2145,13 +2675,14 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(f"ERROR: {exc}", file=sys.stderr)
+ print(f"错误:{exc}", file=sys.stderr)
return 2
except Exception as exc: # noqa: BLE001
record_error(state, "runner", "未捕获异常", response=traceback.format_exc(), exception=exc)
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
+ print("未捕获异常堆栈:", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
return 1
@@ -2159,20 +2690,23 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(json.dumps(
- {
- "ok": True,
- "mode": args.mode,
- "state": str(state_path),
- "summary": str(ctx.summary_path) if ctx.summary_path else "",
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
- "errors": len(state.get("errors") or []),
- },
- ensure_ascii=False,
- ))
+ # Final JSON is intentionally compact; detailed per-step status has already
+ # been emitted as Chinese step feedback.
+ final_payload = {
+ "日志类型": "执行完成",
+ "是否成功": "是",
+ "执行模式": mode_label(args.mode),
+ "状态文件": str(state_path),
+ "摘要文件": str(ctx.summary_path) if ctx.summary_path else "",
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "已选择受众数": len(state.get("selectedDownstreamAudiences") or []),
+ "已选择传播节点数": len(state.get("selectedDownstreamPathways") or []),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
+ "错误数": len(state.get("errors") or []),
+ }
+ print(LOG_PREFIXES["done"] + " " + json.dumps(final_payload, ensure_ascii=False))
return 0
diff --git a/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_run_full_steps.py b/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_run_full_steps.py
new file mode 100644
index 00000000..d6468b51
--- /dev/null
+++ b/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_run_full_steps.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+"""Run the xdfx flow one step at a time.
+
+The heavy business logic intentionally stays in xdfx_flow.py. This wrapper only
+keeps "full flow" orchestration readable and preserves per-step feedback: every
+subprocess line is flushed immediately, so OP/SAVE/ERROR/STEP feedback remains
+visible to the calling agent.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+
+STEPS = ("step1", "step2", "step3", "step4", "step5", "step6")
+
+STEP_LABELS = {
+ "step1": "受众目标分析与入库",
+ "step2": "关键传播节点分析与入库",
+ "step3": "受众脆弱点分析与入库",
+ "step4": "传播节点脆弱点分析与入库",
+ "step5": "受众策略分析与入库",
+ "step6": "传播节点策略分析与入库",
+}
+
+
+def build_parser() -> argparse.ArgumentParser:
+ """Parse only wrapper-owned flags; pass all other flags to xdfx_flow.py."""
+ parser = argparse.ArgumentParser(
+ description="按六个步骤运行 xdfx_flow.py,并实时转发中文反馈日志。",
+ add_help=True,
+ )
+ parser.add_argument("--from-step", choices=STEPS, default="step1")
+ parser.add_argument("--to-step", choices=STEPS, default="step6")
+ parser.add_argument(
+ "--continue-on-error",
+ action="store_true",
+ help="某一步返回非零退出码时仍继续执行后续步骤。",
+ )
+ return parser
+
+
+def selected_steps(first: str, last: str) -> list[str]:
+ """Return an inclusive step slice for resume/partial reruns."""
+ start = STEPS.index(first)
+ end = STEPS.index(last)
+ if start > end:
+ raise SystemExit("起始步骤必须小于或等于结束步骤")
+ return list(STEPS[start : end + 1])
+
+
+def stream_command(step: str, cmd: list[str]) -> int:
+ """Run one step and forward stdout/stderr lines as they are produced."""
+ print(f"运行步骤:{STEP_LABELS.get(step, step)};执行命令:" + " ".join(cmd), flush=True)
+ env = os.environ.copy()
+ env["PYTHONIOENCODING"] = "utf-8"
+ process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ env=env,
+ )
+ assert process.stdout is not None
+ for line in process.stdout:
+ sys.stdout.buffer.write(line.encode("utf-8", errors="replace"))
+ sys.stdout.buffer.flush()
+ return process.wait()
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args, passthrough = parser.parse_known_args(argv)
+ flow_script = Path(__file__).with_name("xdfx_flow.py")
+ exit_code = 0
+ for step in selected_steps(args.from_step, args.to_step):
+ # Keep all business flags unchanged so the wrapper behaves like a thin
+ # streaming version of repeated xdfx_flow.py --mode stepN calls.
+ cmd = [sys.executable, str(flow_script), "--mode", step, *passthrough]
+ code = stream_command(step, cmd)
+ if code:
+ exit_code = code
+ if not args.continue_on_error:
+ return code
+ return exit_code
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/skills-data/xdfx-split-controller/SKILL.md b/skills-data/xdfx-split-controller/SKILL.md
index f9444d5b..391463cb 100644
--- a/skills-data/xdfx-split-controller/SKILL.md
+++ b/skills-data/xdfx-split-controller/SKILL.md
@@ -28,7 +28,7 @@ scripts/xdfx_flow.py
## 推荐执行顺序
-默认会自动处理登录态:当命令没有传 `Authorization`,或某个接口返回 401 时,脚本会用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/v1/auth/login/username?password=ch%40user`,请求体为 `{"username":"admin"}`。拿到 `access_token` 后写入 `Authorization: Bearer ...` 并重试原接口一次。默认关闭 HTTPS 证书校验以兼容 UAT/内网自签证书;如果环境证书链正常,可以追加 `--verify-ssl`。如果已有可用 token,可追加 `--auth-token TOKEN` 或 `--header "Authorization: Bearer TOKEN"`。
+默认会自动处理登录态:脚本启动时先 `import scripts/xdfx_auth.py`,用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/v1/auth/login/username?password=ch%40user`,请求体为 `{"username":"admin"}`。拿到 `access_token` 后写入内部 `Authorization: Bearer ...`,再调用第一步业务接口;不要通过任何命令行参数传 token。401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次。默认关闭 HTTPS 证书校验以兼容 UAT/内网自签证书;如果环境证书链正常,可以追加 `--verify-ssl`。
总控按顺序执行下面 6 条命令,所有步骤共用同一个 `xdfx-flow-state.json`:
@@ -41,13 +41,23 @@ python scripts/xdfx_flow.py --mode step5 --input xdfx-input.json --state xdfx-fl
python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
-每条命令结束都会输出一行 `STEP_FEEDBACK {...}`。总控必须在每个 step 命令结束后立即反馈用户,再继续下一步。
+每条命令结束都会输出一行 `步骤反馈 {...}`。总控必须在每个 step 命令结束后立即反馈用户,再继续下一步。
-每次接口调用前会输出 `OP_FEEDBACK {...}`。总控必须告诉用户当前正在做什么、调用哪个接口。
+每次接口调用前会输出 `操作反馈 {...}`。总控必须告诉用户当前正在做什么、调用哪个接口。
-如果接口或模型处理报错,会立即输出 `ERROR_FEEDBACK {...}`。总控必须立刻把失败阶段、URL、状态码、请求摘要和响应摘要告诉用户。
+每次保存接口成功拿到返回 ID 后会输出 `入库反馈 {...}`。总控必须告诉用户已入库的对象类型、名称和返回 ID,例如“已入库关键传播节点:xxx,pathwayAccountId=123”。
-如果模型返回文本经过自动修复仍无法解析,会输出 `JSON_PARSE_REVIEW {...}`。总控必须查看响应片段,判断是否需要重跑当前 step 或重新调用对应接口。
+如果接口或模型处理报错,会立即输出 `错误反馈 {...}`。总控必须立刻把失败阶段、URL、状态码、请求摘要和响应摘要告诉用户。
+
+如果模型返回文本经过自动修复仍无法解析,会输出 `JSON解析复核 {...}`。总控必须查看响应片段,判断是否需要重跑当前 step 或重新调用对应接口。
+
+## 失败重试规则
+
+- 任意 step 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,总控必须先重跑当前失败的同一条 `--mode stepN` 命令。
+- 重试前必须告诉用户失败阶段、接口 URL、状态码、响应摘要和“正在重试当前步骤”,不能静默重试。
+- 当前 step 未重试成功前,不允许继续执行下一 step;不要因为某一步失败就跳过进入后续分析或入库。
+- 同一 step 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
+- 只有用户明确要求忽略该失败,才可以继续后续 step;回复中必须说明这是用户确认后的带风险继续。
如果只想一条命令跑完,也可以使用下面命令做无人值守兜底,但不建议作为默认方式,因为外层工具通常会等命令结束后才返回输出,无法保证用户看到实时反馈:
@@ -55,21 +65,32 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
python scripts/xdfx_flow.py --mode full --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
+更推荐的一条命令方式是使用包装脚本;它内部仍按 step1-step6 分步调用,并逐行转发反馈:
+
+```bash
+python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
+```
+
## 断点续跑
- Step1 成功后会写入 `topAudiences`。
+- Step1 的受众目标抽取结果里,`用户编号列表` / `编号id` / `id` 必须对应上一轮 `zhanghao` 账号数组的索引值;脚本会按这个索引依次找到账号并保存受众账号关系。
- Step2 成功后会写入 `topPathways`。
-- Step3/Step4 会写入两类脆弱点。
-- Step5/Step6 会写入两类策略。
+- Step1 默认入库最优 3 个受众;Step2 默认入库最优 3 个传播节点。
+- Step3/Step4 默认只选择前 2 个已入库受众/传播节点继续,每个生成最多 3 个脆弱点;可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。
+- Step5/Step6 默认对上述选中的每个受众/传播节点生成最多 3 个策略。
- 各步骤会同步前端 `task/task-action.step`;需要禁用时每条命令追加 `--no-step-status`。
- 策略步骤会同步写入 channel、narrate、message、策略与受众/传播节点关系表,保证原前端策略表格/编排页可继续读取。
-- 每个步骤完成都会输出 `STEP_FEEDBACK`,总控必须逐步转述给用户。
-- 每次操作和接口调用都会输出 `OP_FEEDBACK`,总控必须逐条转述当前操作。
-- 每次错误都会输出 `ERROR_FEEDBACK`,总控必须立即报告错误细节。
-- 每次 JSON 彻底解析失败都会输出 `JSON_PARSE_REVIEW`,总控必须复核响应片段并判断是否重试当前 step。
+- 策略步骤保存 `信息形式/模态` 时,必须使用脚本读取 `sys/sys-dict-data` 的 `modelType` 字典并把文字转成 value 逗号串,不能把“图片,视频”等文字直接写入 `task-action-message.modelType`。
+- 每个步骤完成都会输出 `步骤反馈`,总控必须逐步转述给用户。
+- 每次操作和接口调用都会输出 `操作反馈`,总控必须逐条转述当前操作。
+- 每次保存成功都会输出 `入库反馈`,总控必须逐条转述入库对象。
+- 每次错误都会输出 `错误反馈`,总控必须立即报告错误细节。
+- 每次 JSON 彻底解析失败都会输出 `JSON解析复核`,总控必须复核响应片段并判断是否重试当前 step。
- 每个步骤里的流式模型接口第一次空响应、返回 `y模型没有响应`/`模型没有响应` 或超时类字符串时,会自动再调用一次;仍失败才写入 `errors`。
-- 某一步失败后,修正问题再重跑同一个 `--mode stepN`。
+- 某一步失败后,必须先重跑同一个 `--mode stepN`;重试仍失败再等待用户处理或确认,不能自动跳到后续步骤。
## 汇报规则
-执行过程中按 `OP_FEEDBACK` 告诉用户当前操作和接口;按 `ERROR_FEEDBACK` 立即报告错误;每个 step 结束后再根据 `STEP_FEEDBACK` 回复用户一次。全部步骤结束后,读取 `xdfx-flow-summary.md` 和 `xdfx-flow-state.json.errors` 再做最终汇总。只要存在错误,就明确说“部分完成”,并列出失败阶段、接口 URL、状态码和响应摘要。
+执行过程中按 `操作反馈` 告诉用户当前操作和接口;按 `入库反馈` 告诉用户已入库什么对象和返回 ID;按 `错误反馈` 立即报告错误;每个步骤结束后再根据 `步骤反馈` 回复用户一次。全部步骤结束后,读取 `xdfx-flow-summary.md` 和 `xdfx-flow-state.json.errors` 再做最终汇总。只要存在错误,就明确说“部分完成”,并列出失败阶段、接口 URL、状态码和响应摘要。
+
diff --git a/skills-data/xdfx-split-controller/scripts/xdfx_auth.py b/skills-data/xdfx-split-controller/scripts/xdfx_auth.py
new file mode 100644
index 00000000..fe46f809
--- /dev/null
+++ b/skills-data/xdfx-split-controller/scripts/xdfx_auth.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""Authentication helper for xdfx skill scripts.
+
+The caller should not pass tokens through command-line arguments. This module
+owns the fixed admin login flow and returns a Bearer token before business
+interfaces are called.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import ssl
+import urllib.error
+import urllib.parse
+import urllib.request
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass
+class AuthResult:
+ token: str
+ url: str
+ status: int | None
+ raw: str
+
+
+class AuthError(RuntimeError):
+ def __init__(
+ self,
+ message: str,
+ *,
+ url: str,
+ status: int | None = None,
+ response: str = "",
+ exception: BaseException | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.url = url
+ self.status = status
+ self.response = response
+ self.exception = exception
+
+
+def auth_header_value(token: str) -> str:
+ token = token.strip()
+ return token if token.lower().startswith("bearer ") else f"Bearer {token}"
+
+
+def redact_url(url: str) -> str:
+ return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
+
+
+def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
+ if verify_ssl:
+ return None
+ return ssl._create_unverified_context()
+
+
+def build_login_url(api_base: str, path: str, password: str) -> str:
+ base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
+ if password:
+ separator = "&" if "?" in base else "?"
+ base = f"{base}{separator}password={urllib.parse.quote(password, safe='')}"
+ return base
+
+
+def extract_access_token(data: Any) -> str | None:
+ if isinstance(data, dict):
+ for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
+ value = data.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ for value in data.values():
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ elif isinstance(data, list):
+ for value in data:
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ return None
+
+
+def parse_json_loose(text: str) -> Any:
+ try:
+ return json.loads(text)
+ except Exception:
+ pass
+ match = re.search(r"[\[{]", text or "")
+ if not match:
+ return {}
+ start = match.start()
+ for end in range(len(text), start, -1):
+ try:
+ return json.loads(text[start:end])
+ except Exception:
+ continue
+ return {}
+
+
+def fetch_access_token(
+ *,
+ api_base: str,
+ username: str = "admin",
+ password: str = "ch@user",
+ login_path: str = "/api/v1/auth/login/username",
+ timeout: float = 90,
+ verify_ssl: bool = False,
+) -> AuthResult:
+ """Login once and return the extracted access token."""
+ url = build_login_url(api_base, login_path, password)
+ payload = {"username": username}
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ parsed = parse_json_loose(raw)
+ token = extract_access_token(parsed)
+ if not token:
+ raise AuthError(
+ "登录接口已响应但未找到 access_token",
+ url=redact_url(url),
+ status=getattr(resp, "status", None),
+ response=raw,
+ )
+ return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
+ raise AuthError(
+ "登录接口返回错误",
+ url=redact_url(url),
+ status=exc.code,
+ response=body,
+ exception=exc,
+ ) from exc
+ except AuthError:
+ raise
+ except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
+ raise AuthError(
+ "登录接口调用异常",
+ url=redact_url(url),
+ response=str(exc),
+ exception=exc,
+ ) from exc
diff --git a/skills-data/xdfx-split-controller/scripts/xdfx_flow.py b/skills-data/xdfx-split-controller/scripts/xdfx_flow.py
index 52880e82..5f405cc8 100644
--- a/skills-data/xdfx-split-controller/scripts/xdfx_flow.py
+++ b/skills-data/xdfx-split-controller/scripts/xdfx_flow.py
@@ -26,7 +26,10 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
+import xdfx_auth
+
+# Keep every backend path in one table so endpoint drift is easy to audit.
ENDPOINTS = {
"aiuser": "/gpt/chat/chat_aiuser",
"group": "/gpt/chat/chat_qianqian_group_chat",
@@ -44,10 +47,12 @@ ENDPOINTS = {
"save_narrate": "/api/base/base-narrate",
"save_message": "/api/task/task-action-message",
"save_strategy_relation": "/api/task/task-action-design-strategy-relation",
+ "dict_data": "/api/sys/sys-dict-data",
}
+# Human-readable names are used only in the Chinese feedback logs.
ENDPOINT_LABELS = {
- "login": "登录获取访问 token",
+ "login": "登录获取访问令牌",
"aiuser": "调用候选账号/受众分析模型接口",
"group": "调用受众目标结构化抽取接口",
"spread": "调用关键传播节点结构化抽取接口",
@@ -64,8 +69,86 @@ ENDPOINT_LABELS = {
"save_narrate": "保存策略信息主题",
"save_message": "保存策略消息配置",
"save_strategy_relation": "保存策略与受众/传播节点关系",
+ "dict_data": "读取前端字典数据",
}
+LOG_PREFIXES = {
+ "operation": "操作反馈",
+ "error": "错误反馈",
+ "json_review": "JSON解析复核",
+ "save": "入库反馈",
+ "step": "步骤反馈",
+ "done": "执行完成",
+}
+
+STATUS_LABELS = {
+ "ok": "成功",
+ "partial": "部分完成",
+ "skipped": "已跳过",
+}
+
+STEP_NUMBER_LABELS = {
+ "step1": "第一步",
+ "step2": "第二步",
+ "step3": "第三步",
+ "step4": "第四步",
+ "step5": "第五步",
+ "step6": "第六步",
+}
+
+HTTP_METHOD_LABELS = {
+ "GET": "读取(GET)",
+ "POST": "提交(POST)",
+ "PUT": "更新(PUT)",
+ "PATCH": "局部更新(PATCH)",
+ "DELETE": "删除(DELETE)",
+}
+
+STAGE_SEGMENT_LABELS = {
+ "analyze": "模型分析",
+ "extract": "结构化抽取",
+ "save": "保存数据",
+ "read": "读取数据",
+ "status": "同步状态",
+}
+
+REQUEST_FIELD_LABELS = {
+ "history": "历史消息",
+ "max_tokens": "最大生成令牌数",
+ "model_name": "模型名称",
+ "otherinfo": "其他信息",
+ "prompt_name": "提示词名称",
+ "stream": "是否流式",
+ "temperature": "温度",
+ "usermessage": "用户消息",
+ "user_message": "用户消息",
+ "mubiao": "行动目标",
+ "zhanghao": "账号列表",
+ "taskId": "任务ID",
+ "actionId": "行动ID",
+ "taskActionId": "行动ID",
+ "designId": "受众ID",
+ "pathwayId": "传播节点ID",
+ "strategyId": "策略ID",
+ "accountId": "账号ID",
+ "accountName": "账号名称",
+ "accountType": "账号类型",
+ "targetAudience": "受众目标",
+ "vulnerabilities": "脆弱点",
+ "topic": "信息标题",
+ "narrate": "叙事主题",
+ "platform": "平台",
+ "persona": "发布人设",
+ "modelType": "信息形式",
+ "quantity": "数量",
+ "publishTime": "发布时间段",
+ "reason": "理由",
+ "ids": "ID列表",
+ "id": "ID",
+ "step": "步骤状态",
+}
+
+# Platform values mirror the frontend dictionaries used by xdfx strategy pages.
PLATFORM_MAP = {
"facebook": 1,
"fb": 1,
@@ -78,6 +161,8 @@ PLATFORM_MAP = {
"yt": 4,
}
+# The model service sometimes returns a successful HTTP response with these
+# strings instead of useful content. Treat them like transient failures.
MODEL_RETRY_MARKERS = (
"y模型没有响应",
"模型没有响应",
@@ -118,6 +203,12 @@ class FlowError(RuntimeError):
@dataclass
class FlowContext:
+ """Runtime options shared by every step.
+
+ Keep this object boring and serializable-ish: it is passed through every
+ helper, while durable progress belongs in the state JSON file.
+ """
+
api_base: str
headers: dict[str, str]
timeout: float
@@ -129,6 +220,8 @@ class FlowContext:
max_pathways: int = 3
max_vulns: int = 3
max_strategies: int = 3
+ downstream_audiences: int = 2
+ downstream_pathways: int = 2
sync_step_status: bool = True
model_retries: int = 1
verify_ssl: bool = False
@@ -153,7 +246,7 @@ def compact(value: Any, limit: int = 1400) -> str:
text = repr(value)
if len(text) <= limit:
return text
- return text[:limit] + f"... "
+ return text[:limit] + f"... <已截断 {len(text) - limit} 个字符>"
def stable_json(value: Any) -> str:
@@ -223,6 +316,11 @@ def save_json_file(path: str | Path, value: Any) -> None:
def default_state() -> dict[str, Any]:
+ """Create a resumable state container.
+
+ The script can be run as six independent commands. IDs returned by one step
+ are persisted here so later steps can resume without redoing earlier saves.
+ """
return {
"api_base": "",
"taskId": "",
@@ -247,6 +345,7 @@ def default_state() -> dict[str, Any]:
def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> None:
+ # Accept both frontend-style camelCase and CLI-friendly snake_case inputs.
aliases = {
"task_id": "taskId",
"action_id": "actionId",
@@ -260,16 +359,12 @@ def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> No
continue
if dest in {"taskId", "actionId"}:
state[dest] = str(value)
- elif dest in state or dest in {
- "api_base",
- "headers",
- "auth_token",
- "authorization",
- }:
+ elif dest in state or dest in {"api_base", "headers"}:
state[dest] = value
def record_event(state: dict[str, Any], stage: str, message: str, data: Any | None = None) -> None:
+ """Append a compact diagnostic event to the state file."""
state.setdefault("events", []).append(
{
"time": utc_now(),
@@ -286,9 +381,13 @@ def feedback_step(stage: str) -> str:
return match.group(1) if match else ""
+def feedback_step_label(stage: str) -> str:
+ return STEP_NUMBER_LABELS.get(feedback_step(stage), "")
+
+
def request_keys(payload: Any) -> list[str]:
if isinstance(payload, dict):
- return sorted(str(key) for key in payload.keys())
+ return sorted(REQUEST_FIELD_LABELS.get(str(key), str(key)) for key in payload.keys())
return []
@@ -306,6 +405,77 @@ def endpoint_label(endpoint_key: str | None, method: str, url: str) -> str:
return f"调用接口 {method}"
+def method_label(method: str) -> str:
+ return HTTP_METHOD_LABELS.get(str(method).upper(), str(method))
+
+
+def yes_no(value: bool) -> str:
+ return "是" if value else "否"
+
+
+def stage_label(stage: Any) -> str:
+ text = str(stage or "")
+ if not text:
+ return ""
+ if text in STEP_LABELS:
+ return STEP_LABELS[text]
+ if text == "runner":
+ return "流程执行器"
+ parts = text.split("/")
+ base = parts[0]
+ match = re.search(r"(step[1-6])", base)
+ labels: list[str] = []
+ if match:
+ labels.append(STEP_LABELS.get(match.group(1), match.group(1)))
+ elif base == "runner":
+ labels.append("流程执行器")
+ else:
+ labels.append(base)
+ for segment in parts[1:]:
+ normalized = segment.replace("-", "_")
+ attempt = re.fullmatch(r"model-call-attempt-(\d+)", segment)
+ json_attempt = re.fullmatch(r"model-json-attempt-(\d+)", segment)
+ if attempt:
+ labels.append(f"模型接口第 {attempt.group(1)} 次尝试")
+ elif json_attempt:
+ labels.append(f"JSON解析第 {json_attempt.group(1)} 次尝试")
+ elif segment == "auto-login":
+ labels.append("自动登录")
+ elif segment in STAGE_SEGMENT_LABELS:
+ labels.append(STAGE_SEGMENT_LABELS[segment])
+ elif normalized in ENDPOINT_LABELS:
+ labels.append(ENDPOINT_LABELS[normalized])
+ else:
+ labels.append(segment)
+ return " / ".join(labels)
+
+
+def status_label(status: Any) -> str:
+ if status is None:
+ return ""
+ return STATUS_LABELS.get(str(status), str(status))
+
+
+def mode_label(mode: str) -> str:
+ if mode == "full":
+ return "全流程"
+ return STEP_LABELS.get(mode, mode)
+
+
+def exception_type_label(exception_type: Any) -> str:
+ name = str(exception_type or "")
+ labels = {
+ "HTTPError": "HTTP 调用异常",
+ "URLError": "网络地址异常",
+ "TimeoutError": "请求超时异常",
+ "FlowError": "流程异常",
+ "ValueError": "数据格式异常",
+ "KeyError": "字段缺失异常",
+ "RuntimeError": "运行异常",
+ }
+ return labels.get(name, "程序异常" if name else "")
+
+
def emit_operation_feedback(
state: dict[str, Any],
stage: str,
@@ -317,39 +487,41 @@ def emit_operation_feedback(
attempt: int = 1,
dry_run: bool = False,
) -> None:
+ # Feedback lines are a mini protocol consumed by the calling agent. They
+ # must stay single-line JSON so weak models/tools can relay them reliably.
payload_obj = {
- "type": "OP_FEEDBACK",
- "step": feedback_step(stage),
- "stage": stage,
- "operation": endpoint_label(endpoint_key, method, url),
- "method": method,
- "url": url,
- "endpoint": endpoint_key or "",
- "attempt": attempt,
- "dryRun": dry_run,
- "requestKeys": request_keys(payload),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "操作反馈",
+ "步骤": feedback_step_label(stage),
+ "阶段": stage_label(stage),
+ "操作": endpoint_label(endpoint_key, method, url),
+ "请求方式": method_label(method),
+ "接口地址": url,
+ "接口名称": endpoint_label(endpoint_key, method, url),
+ "第几次尝试": attempt,
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("OP_FEEDBACK " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["operation"] + " " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
def emit_error_feedback(error: dict[str, Any]) -> None:
payload = {
- "type": "ERROR_FEEDBACK",
- "stage": error.get("stage"),
- "step": feedback_step(str(error.get("stage") or "")),
- "message": error.get("message"),
- "url": error.get("url"),
- "status": error.get("status"),
- "request": error.get("request"),
- "response": error.get("response"),
- "exceptionType": error.get("exceptionType"),
- "exception": error.get("exception"),
- "time": error.get("time"),
+ "日志类型": "错误反馈",
+ "阶段": stage_label(error.get("stage")),
+ "步骤": feedback_step_label(str(error.get("stage") or "")),
+ "错误信息": error.get("message"),
+ "接口地址": error.get("url"),
+ "状态码": error.get("status"),
+ "请求摘要": error.get("request"),
+ "响应摘要": error.get("response"),
+ "异常类型": exception_type_label(error.get("exceptionType")),
+ "异常信息": error.get("exception"),
+ "时间": error.get("time"),
}
- print("ERROR_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["error"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def emit_json_parse_review(
@@ -360,18 +532,89 @@ def emit_json_parse_review(
candidate_count: int,
) -> None:
payload = {
- "type": "JSON_PARSE_REVIEW",
- "stage": stage,
- "step": feedback_step(stage),
- "endpoint": endpoint_key,
- "message": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
- "candidateCount": candidate_count,
- "responseExcerpt": compact(text, 3000),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "JSON解析复核",
+ "阶段": stage_label(stage),
+ "步骤": feedback_step_label(stage),
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, endpoint_key),
+ "提示": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
+ "候选数量": candidate_count,
+ "响应片段": compact(text, 3000),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("JSON_PARSE_REVIEW " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["json_review"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
+
+
+SAVE_ENTITY_LABELS = {
+ "save_design": "受众目标",
+ "save_design_account": "受众目标账号",
+ "save_pathway": "关键传播节点",
+ "save_relation": "受众-传播节点关系",
+ "save_design_vuln": "受众脆弱点",
+ "save_pathway_vuln": "传播节点脆弱点",
+ "save_strategy": "策略",
+ "save_channel": "策略平台与人设",
+ "save_narrate": "策略信息主题",
+ "save_message": "策略消息配置",
+ "save_strategy_relation": "策略关联关系",
+}
+
+
+def saved_entity_name(endpoint_key: str, payload: dict[str, Any]) -> str:
+ """Return the most user-readable name for an inserted row."""
+ candidates = [
+ payload.get("targetAudience"),
+ payload.get("accountName"),
+ payload.get("vulnerabilities"),
+ payload.get("topic"),
+ payload.get("narrate"),
+ payload.get("platform"),
+ payload.get("strategyId"),
+ payload.get("designId"),
+ payload.get("pathwayId"),
+ ]
+ for value in candidates:
+ if value is not None and str(value).strip():
+ return str(value).strip()
+ return endpoint_key
+
+
+def emit_save_feedback(
+ state: dict[str, Any],
+ stage: str,
+ endpoint_key: str,
+ payload: dict[str, Any],
+ row_id: int,
+ *,
+ dry_run: bool,
+) -> None:
+ # Emit only after an id is known. Downstream steps use these ids and should
+ # fail loudly rather than inventing relations against missing rows.
+ label = SAVE_ENTITY_LABELS.get(endpoint_key, endpoint_key)
+ name = saved_entity_name(endpoint_key, payload)
+ feedback = {
+ "日志类型": "入库反馈",
+ "步骤": feedback_step(stage),
+ "阶段": stage,
+ "入库对象类型": label,
+ "入库对象名称": name,
+ "返回ID": row_id,
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, label),
+ "接口路径": ENDPOINTS.get(endpoint_key, ""),
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
+ }
+ print(LOG_PREFIXES["save"] + " " + json.dumps(feedback, ensure_ascii=False), flush=True)
+ record_event(
+ state,
+ stage,
+ f"入库{label}成功:{name}",
+ {"id": row_id, "endpoint": endpoint_key, "dryRun": dry_run},
+ )
def record_error(
@@ -385,6 +628,8 @@ def record_error(
response: Any | None = None,
exception: BaseException | None = None,
) -> None:
+ # Persist full-enough error context for post-run diagnosis, then immediately
+ # emit a short line the agent can show to the user.
item = {
"time": utc_now(),
"stage": stage,
@@ -430,65 +675,25 @@ def url_for_path(ctx: FlowContext, path: str) -> str:
return ctx.api_base.rstrip("/") + "/" + path.lstrip("/")
-def parse_headers(header_values: list[str], auth_token: str | None, authorization: str | None) -> dict[str, str]:
+def parse_headers(header_values: list[str]) -> dict[str, str]:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
- if auth_token:
- headers["Authorization"] = auth_token if auth_token.lower().startswith("bearer ") else f"Bearer {auth_token}"
- if authorization:
- headers["Authorization"] = authorization
for raw in header_values:
if ":" not in raw:
raise FlowError(f"header 格式错误,应为 Key: Value,实际:{raw}")
key, value = raw.split(":", 1)
- headers[key.strip()] = value.strip()
+ normalized = key.strip()
+ if normalized.lower() == "authorization":
+ continue
+ headers[normalized] = value.strip()
return headers
-def has_authorization(headers: dict[str, str]) -> bool:
- return any(key.lower() == "authorization" and str(value).strip() for key, value in headers.items())
-
-
def ssl_context(ctx: FlowContext) -> ssl.SSLContext | None:
if ctx.verify_ssl:
return None
return ssl._create_unverified_context()
-def auth_header_value(token: str) -> str:
- token = token.strip()
- return token if token.lower().startswith("bearer ") else f"Bearer {token}"
-
-
-def extract_access_token(data: Any) -> str | None:
- if isinstance(data, dict):
- for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
- value = data.get(key)
- if isinstance(value, str) and value.strip():
- return value.strip()
- for value in data.values():
- nested = extract_access_token(value)
- if nested:
- return nested
- elif isinstance(data, list):
- for value in data:
- nested = extract_access_token(value)
- if nested:
- return nested
- return None
-
-
-def redact_url(url: str) -> str:
- return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
-
-
-def build_login_url(ctx: FlowContext, path: str) -> str:
- base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else url_for_path(ctx, path)
- if ctx.login_password:
- separator = "&" if "?" in base else "?"
- base = f"{base}{separator}password={urllib.parse.quote(ctx.login_password, safe='')}"
- return base
-
-
def login_candidate_paths(ctx: FlowContext) -> list[str]:
paths = [
ctx.login_path or "/api/v1/auth/login/username",
@@ -506,6 +711,7 @@ def login_candidate_paths(ctx: FlowContext) -> list[str]:
def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force: bool = False) -> bool:
+ """Fetch a fresh token through the local auth package before business calls."""
if not ctx.auto_login:
return False
if ctx.dry_run:
@@ -515,7 +721,7 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
record_error(state, stage, "自动登录失败:缺少 login_username")
return False
if not force and ctx.login_attempt_count > 0:
- return has_authorization(ctx.headers)
+ return any(key.lower() == "authorization" and str(value).strip() for key, value in ctx.headers.items())
if force and ctx.login_attempt_count >= 2:
return False
@@ -525,8 +731,7 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
last_response = ""
last_exc: BaseException | None = None
for attempt, path in enumerate(login_candidate_paths(ctx), start=1):
- url = build_login_url(ctx, path)
- display_url = redact_url(url)
+ display_url = xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, path, ctx.login_password))
emit_operation_feedback(
state,
stage,
@@ -537,63 +742,39 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
attempt=attempt,
)
try:
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- headers = {
- "Content-Type": "application/json",
- "Accept": "application/json",
- }
- req = urllib.request.Request(url, data=data, headers=headers, method="POST")
- with urllib.request.urlopen(req, timeout=ctx.timeout, context=ssl_context(ctx)) as resp:
- raw = resp.read().decode("utf-8", errors="replace")
- parsed = parse_json_lenient(raw)
- token = extract_access_token(parsed)
- if not token:
- last_status = getattr(resp, "status", None)
- last_response = raw
- record_event(
- state,
- stage,
- "登录接口已响应但未找到 access_token",
- {"url": display_url, "status": last_status, "response": compact(raw, 1000)},
- )
- continue
- ctx.headers["Authorization"] = auth_header_value(token)
- record_event(
- state,
- stage,
- "自动登录成功,已写入 Authorization Bearer token",
- {
- "url": display_url,
- "status": getattr(resp, "status", None),
- "username": ctx.login_username,
- },
- )
- return True
- except urllib.error.HTTPError as exc:
- last_status = exc.code
- last_response = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
- last_exc = exc
- record_event(
- state,
- stage,
- "登录接口返回错误,准备尝试下一个候选登录路径",
- {"url": display_url, "status": exc.code, "response": compact(last_response, 1000)},
+ result = xdfx_auth.fetch_access_token(
+ api_base=ctx.api_base,
+ username=ctx.login_username,
+ password=ctx.login_password,
+ login_path=path,
+ timeout=ctx.timeout,
+ verify_ssl=ctx.verify_ssl,
)
- except Exception as exc: # noqa: BLE001 - auto-login must report every failure.
- last_exc = exc
- last_response = traceback.format_exc()
+ ctx.headers["Authorization"] = xdfx_auth.auth_header_value(result.token)
+ state["_tokenSource"] = "xdfx_auth"
record_event(
state,
stage,
- "登录接口调用异常,准备尝试下一个候选登录路径",
- {"url": display_url, "exceptionType": type(exc).__name__, "exception": str(exc)},
+ "启动阶段已通过 xdfx_auth 包获取 token,并写入 Authorization",
+ {"url": result.url, "status": result.status, "username": ctx.login_username},
+ )
+ return True
+ except xdfx_auth.AuthError as exc:
+ last_status = exc.status
+ last_response = exc.response
+ last_exc = exc
+ record_event(
+ state,
+ stage,
+ "xdfx_auth 获取 token 失败,准备尝试下一个候选登录路径",
+ {"url": exc.url, "status": exc.status, "response": compact(exc.response, 1000), "exception": str(exc)},
)
record_error(
state,
stage,
- "自动登录失败,未能获取 access_token",
- url=redact_url(build_login_url(ctx, ctx.login_path or "/api/v1/auth/login/username")),
+ "自动登录失败,未能通过 xdfx_auth 获取 access_token",
+ url=xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, ctx.login_path or "/api/v1/auth/login/username", ctx.login_password)),
status=last_status,
request=payload,
response=last_response,
@@ -612,6 +793,7 @@ def http_request_json_url(
endpoint_key: str | None = None,
allow_auto_login: bool = True,
) -> tuple[Any | None, str]:
+ """Call an HTTP JSON endpoint with logging, retries, and 401 recovery."""
method = method.upper()
if ctx.dry_run:
emit_operation_feedback(state, stage, method, url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
@@ -640,6 +822,7 @@ def http_request_json_url(
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
if exc.code == 401 and allow_auto_login and ctx.auto_login:
+ # Refresh once on 401, then replay the original request exactly.
refreshed = try_auto_login(ctx, state, f"{stage}/auto-login", force=True)
if refreshed:
record_event(
@@ -709,6 +892,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
emit_operation_feedback(state, stage, "POST", url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
record_event(state, stage, f"DRY RUN POST {url}", {"payload": payload})
if endpoint_key.startswith("save_"):
+ # Synthetic ids let downstream dry-run code exercise relation wiring.
synthetic_id = abs(hash(stage + stable_json(payload))) % 100000000
return {"id": synthetic_id, "dryRun": True}, json.dumps({"id": synthetic_id, "dryRun": True})
return {"dryRun": True}, json.dumps({"dryRun": True})
@@ -716,6 +900,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
def parse_json_lenient(text: str) -> Any | None:
+ """Parse normal JSON plus adjacent/SSE-like JSON objects."""
stripped = text.strip()
if not stripped:
return None
@@ -730,6 +915,7 @@ def parse_json_lenient(text: str) -> Any | None:
def parse_concatenated_json_objects(text: str) -> list[Any]:
+ """Read streams such as {"answer": "..."}{"answer": "..."} or data: lines."""
decoder = json.JSONDecoder()
objects: list[Any] = []
i = 0
@@ -766,6 +952,7 @@ def parse_concatenated_json_objects(text: str) -> list[Any]:
def model_text_from_response(parsed: Any, raw: str) -> tuple[str, list[Any]]:
+ """Flatten streamed model chunks into one text buffer."""
objects: list[Any]
if isinstance(parsed, list):
objects = parsed
@@ -824,6 +1011,7 @@ def extract_json_from_text(text: str) -> Any | None:
def extract_json_candidates_from_text(text: str) -> list[Any]:
+ """Collect every plausible JSON object/array from messy model text."""
cleaned = strip_think(text)
candidates: list[Any] = []
seen: set[str] = set()
@@ -860,6 +1048,7 @@ def extract_json_candidates_from_text(text: str) -> list[Any]:
def select_json_candidate(candidates: list[Any], endpoint_key: str, stage: str) -> Any | None:
+ """Pick the candidate whose keys best match the current step."""
if not candidates:
return None
scored = [(json_candidate_score(candidate, endpoint_key, stage), idx, candidate) for idx, candidate in enumerate(candidates)]
@@ -1010,6 +1199,7 @@ def extract_first_balanced(text: str, open_ch: str, close_ch: str) -> str | None
def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[str, list[Any], Any | None]:
+ """Call a model endpoint and retry only known empty/timeout/no-response cases."""
max_attempts = max(ctx.model_retries, 0) + 1
last_text = ""
last_objects: list[Any] = []
@@ -1048,6 +1238,11 @@ def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str,
def call_model_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[dict[str, Any], str, list[Any]]:
+ """Return the best structured JSON for a model step.
+
+ If the first parse fails, the second attempt includes an explicit repair
+ instruction so weak models can re-emit only the required JSON.
+ """
last_text = ""
last_objects: list[Any] = []
last_candidate_count = 0
@@ -1202,23 +1397,85 @@ def profile_info(account: dict[str, Any]) -> str:
def parse_index_list(value: Any) -> list[str]:
- if value is None:
- return []
- if isinstance(value, list):
- return [str(v).strip() for v in value if str(v).strip()]
- text = str(value)
- return [part.strip().strip("<>").strip() for part in re.split(r"[,,;;、\s]+", text) if part.strip()]
+ refs: list[str] = []
+
+ def add(raw: Any) -> None:
+ if raw is None:
+ return
+ if isinstance(raw, dict):
+ keys = (
+ "编号id",
+ "编号ID",
+ "用户编号id",
+ "用户编号ID",
+ "用户编号",
+ "账号编号",
+ "引用序号",
+ "索引",
+ "index",
+ "id",
+ "ids",
+ "accountIndex",
+ "accountIndexes",
+ "userIndex",
+ "userIndexes",
+ )
+ for key in keys:
+ if key in raw:
+ add(raw.get(key))
+ return
+ if isinstance(raw, (list, tuple, set)):
+ for item in raw:
+ add(item)
+ return
+ text = str(raw).strip()
+ if not text:
+ return
+ angle_refs = re.findall(r"<<\s*([^<>]+?)\s*>>", text)
+ if angle_refs:
+ for token in angle_refs:
+ add(token)
+ return
+ for part in re.split(r"[,,;;、\s]+", text):
+ token = part.strip().strip("<>").strip()
+ if token:
+ refs.append(token)
+
+ add(value)
+ deduped: list[str] = []
+ seen: set[str] = set()
+ for ref in refs:
+ if ref in seen:
+ continue
+ seen.add(ref)
+ deduped.append(ref)
+ return deduped
+
+
+def account_ref_numbers(account: dict[str, Any]) -> set[int]:
+ numbers: set[int] = set()
+ meta = account.get("metadata") if isinstance(account.get("metadata"), dict) else {}
+ for key in ("编号id", "编号ID", "用户编号id", "用户编号ID", "用户编号", "账号编号", "引用序号", "索引", "index", "accountIndex"):
+ for container in (account, meta):
+ number = as_int(container.get(key)) if isinstance(container, dict) else None
+ if number is not None:
+ numbers.add(number)
+ return numbers
def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, Any] | None:
+ """Resolve model references like <<3>>, index numbers, names, or account ids."""
if not accounts:
return None
text = str(ref).strip().strip("<>").strip()
number = as_int(text)
if number is not None:
for account in accounts:
- if any(as_int(account.get(key)) == number for key in ("引用序号", "索引", "index")):
+ if number in account_ref_numbers(account):
return account
+ # Step1 group extraction returns 编号/id values that refer to the
+ # previous zhanghao array index. Prefer exact index fields above, then
+ # fall back to zero-based and one-based array positions.
if 0 <= number < len(accounts):
return accounts[number]
if 1 <= number <= len(accounts):
@@ -1232,11 +1489,25 @@ def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, A
def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Normalize audience JSON whether the model used numbered fields or items[]."""
items: list[dict[str, Any]] = []
for i in range(1, 21):
group = first_present(data.get(f"群体{i}"), data.get(f"受众{i}"), data.get(f"targetAudience{i}"))
reason = first_present(data.get(f"理由{i}"), data.get(f"推荐理由{i}"), data.get(f"recReason{i}"))
- users = first_present(data.get(f"用户编号列表{i}"), data.get(f"用户列表{i}"), data.get(f"账号编号列表{i}"))
+ users = first_present(
+ data.get(f"用户编号列表{i}"),
+ data.get(f"编号id列表{i}"),
+ data.get(f"编号ID列表{i}"),
+ data.get(f"用户编号id列表{i}"),
+ data.get(f"用户编号ID列表{i}"),
+ data.get(f"用户列表{i}"),
+ data.get(f"账号编号列表{i}"),
+ data.get(f"索引列表{i}"),
+ data.get(f"编号{i}"),
+ data.get(f"id{i}"),
+ data.get(f"ids{i}"),
+ data.get(f"索引{i}"),
+ )
if group:
items.append(
{
@@ -1255,7 +1526,27 @@ def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
{
"targetAudience": str(group).strip(),
"recReason": str(first_present(item.get("理由"), item.get("recReason"), item.get("reason")) or "").strip(),
- "userIndexes": parse_index_list(first_present(item.get("用户编号列表"), item.get("users"), item.get("accounts"))),
+ "userIndexes": parse_index_list(
+ first_present(
+ item.get("用户编号列表"),
+ item.get("编号id列表"),
+ item.get("编号ID列表"),
+ item.get("用户编号id列表"),
+ item.get("用户编号ID列表"),
+ item.get("编号id"),
+ item.get("编号ID"),
+ item.get("用户编号id"),
+ item.get("用户编号ID"),
+ item.get("编号"),
+ item.get("索引"),
+ item.get("id"),
+ item.get("ids"),
+ item.get("users"),
+ item.get("accounts"),
+ item.get("userIndexes"),
+ item.get("accountIndexes"),
+ )
+ ),
"rank": i,
}
)
@@ -1272,6 +1563,7 @@ def audience_score(item: dict[str, Any]) -> tuple[int, int]:
def select_top_audiences(items: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
+ """Deduplicate audience names and keep the strongest candidates."""
unique: dict[str, dict[str, Any]] = {}
for item in items:
key = item.get("targetAudience", "")
@@ -1301,6 +1593,7 @@ def pathway_score(item: dict[str, Any]) -> tuple[int, int, int]:
def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Merge the same account found from different audiences before saving top 3."""
merged: dict[str, dict[str, Any]] = {}
for item in candidates:
account = item.get("account") or {}
@@ -1325,6 +1618,7 @@ def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str,
def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
+ """Normalize weak-point model output into a capped list."""
vulns: list[dict[str, str]] = []
for i in range(1, 21):
vuln = first_present(data.get(f"脆弱点{i}"), data.get(f"vulnerability{i}"), data.get(f"vuln{i}"))
@@ -1339,6 +1633,7 @@ def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
def extract_strategies(data: dict[str, Any], limit: int) -> list[dict[str, Any]]:
+ """Normalize strategy output and preserve the modal text for later mapping."""
strategies: list[dict[str, Any]] = []
for i in range(1, 21):
title = first_present(data.get(f"信息标题{i}"), data.get(f"主题{i}"), data.get(f"topic{i}"))
@@ -1395,11 +1690,29 @@ def response_id(response: Any) -> int | None:
return None
+def response_rows(response: Any) -> list[Any]:
+ """Extract list rows from common CRUD response shapes."""
+ if isinstance(response, list):
+ return response
+ if not isinstance(response, dict):
+ return []
+ for key in ("data", "rows", "items", "records", "list"):
+ value = response.get(key)
+ if isinstance(value, list):
+ return value
+ rows = response_rows(value)
+ if rows:
+ return rows
+ return []
+
+
def save_with_id(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[int | None, Any | None]:
response, _ = http_post_json(ctx, state, endpoint_key, payload, stage)
rid = response_id(response)
if rid is None:
- record_error(state, stage, "保存接口未返回 id,视为失败", request=payload, response=response)
+ record_error(state, stage, "保存接口未返回 ID,视为失败", request=payload, response=response)
+ else:
+ emit_save_feedback(state, stage, endpoint_key, payload, rid, dry_run=ctx.dry_run)
return rid, response
@@ -1429,6 +1742,7 @@ def parse_step_flags(raw: Any) -> dict[str, Any]:
def mark_step_status(ctx: FlowContext, state: dict[str, Any], links: list[str], stage: str) -> None:
+ """Update the frontend step bar without overwriting unrelated step flags."""
if not ctx.sync_step_status:
record_event(state, stage, "跳过 task-action.step 状态同步")
return
@@ -1477,12 +1791,126 @@ def strategy_platform(value: Any) -> Any:
return platform_to_number(value) or 1
-def strategy_model_type(value: Any) -> str:
- # Frontend defaults to text model type "1" when no modal type is selected.
- raw = first_present(value, "1")
- if isinstance(raw, list):
- return ",".join(str(v) for v in raw if str(v).strip()) or "1"
- return str(raw or "1")
+def choice_tokens(value: Any) -> list[Any]:
+ """Split frontend choice values from arrays, JSON strings, and mixed delimiters."""
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return choice_tokens(
+ first_present(value.get("value"), value.get("dictValue"), value.get("label"), value.get("dictLabel"))
+ )
+ if isinstance(value, (list, tuple, set)):
+ tokens: list[Any] = []
+ for item in value:
+ tokens.extend(choice_tokens(item))
+ return tokens
+ text = str(value).strip()
+ if not text:
+ return []
+ if text[0] in "[{":
+ try:
+ parsed = json.loads(text)
+ if parsed != value:
+ return choice_tokens(parsed)
+ except Exception:
+ pass
+ return [part.strip() for part in re.split(r"[,,、;/|]+", text) if part.strip()]
+
+
+def dict_choices(
+ ctx: FlowContext,
+ state: dict[str, Any],
+ dict_type: str,
+ stage: str,
+) -> list[dict[str, Any]]:
+ """Fetch and cache frontend dictionary choices such as modelType."""
+ cache = state.setdefault("_dictChoiceCache", {})
+ if dict_type in cache:
+ cached = cache[dict_type]
+ return cached if isinstance(cached, list) else []
+ if ctx.dry_run:
+ cache[dict_type] = []
+ return []
+
+ query = urllib.parse.urlencode({"filter": f"dictType||$eq||{dict_type}", "limit": 999, "page": 1})
+ url = f"{url_for(ctx, 'dict_data')}?{query}"
+ response, _ = http_request_json_url(ctx, state, "GET", url, None, stage, endpoint_key="dict_data")
+ choices: list[dict[str, Any]] = []
+ for row in response_rows(response):
+ if not isinstance(row, dict):
+ continue
+ label = str(first_present(row.get("dictLabel"), row.get("label"), "") or "").strip()
+ raw_value = first_present(row.get("dictValue"), row.get("value"))
+ if not label or raw_value is None:
+ continue
+ number = as_int(raw_value)
+ choices.append({"label": label, "value": number if number is not None else str(raw_value).strip()})
+ for alias in choice_tokens(row.get("alias")):
+ alias_label = str(alias).strip()
+ if alias_label:
+ choices.append({"label": alias_label, "value": number if number is not None else str(raw_value).strip()})
+ cache[dict_type] = choices
+ return choices
+
+
+def choice_value_by_label(choices: list[dict[str, Any]] | None, label: str) -> Any | None:
+ needle = re.sub(r"\s+", "", str(label or "").lower())
+ if not needle:
+ return None
+ for item in choices or []:
+ item_label = re.sub(r"\s+", "", str(item.get("label") or "").lower())
+ if item_label and (needle == item_label or needle in item_label or item_label in needle):
+ return item.get("value")
+ return None
+
+
+def model_type_token_value(token: Any, choices: list[dict[str, Any]] | None = None) -> Any:
+ """Convert modal labels like 图片/视频 into frontend dictionary values."""
+ number = as_int(token)
+ if number is not None:
+ return number
+
+ text = str(token or "").strip()
+ lower = text.lower()
+ exact = choice_value_by_label(choices, text)
+ if exact is not None:
+ return exact
+
+ label = ""
+ fallback_value = 2
+ if "视" in text or "video" in lower:
+ label, fallback_value = "视频", 3
+ elif "图" in text or "image" in lower or "picture" in lower or "photo" in lower:
+ label, fallback_value = "图片", 2
+ elif "音" in text or "audio" in lower or "sound" in lower:
+ label, fallback_value = "音频", 4
+ elif "发文" in text or "文本" in text or "文字" in text or "text" in lower:
+ label, fallback_value = "文本", 1
+
+ if label:
+ matched = choice_value_by_label(choices, label)
+ if matched is not None:
+ return matched
+ return fallback_value
+
+ # Mirrors the frontend helper's unknown-token default.
+ return 2
+
+
+def strategy_model_type(value: Any, choices: list[dict[str, Any]] | None = None) -> str:
+ """Return the comma-separated value string expected by task-action-message."""
+ tokens = choice_tokens(value)
+ if not tokens:
+ # Frontend save paths fall back to "1" when no modal type is selected.
+ return "1"
+ values: list[str] = []
+ seen: set[str] = set()
+ for token in tokens:
+ normalized = str(model_type_token_value(token, choices)).strip()
+ if normalized and normalized not in seen:
+ values.append(normalized)
+ seen.add(normalized)
+ return ",".join(values) or "1"
def strategy_language(value: Any) -> int:
@@ -1501,11 +1929,28 @@ def save_strategy_details(
*,
audience: bool,
) -> None:
- platform = strategy_platform(first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})))
+ """Save all frontend-readable strategy child rows.
+
+ The strategy table and arrangement page do not read only the main strategy
+ row; they also expect channel, narrate, message, and relation rows.
+ """
+ platform = strategy_platform(
+ first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
+ )
+ account_type = str(
+ first_present(
+ strategy.get("accountType"),
+ item.get("accountType"),
+ item.get("type"),
+ "目标受众账号" if audience else "关键传播节点账号",
+ )
+ or ""
+ ).strip()
persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
title = str(strategy.get("title") or "").strip()
+ model_type_choices = dict_choices(ctx, state, "modelType", stage + "/dict-modelType")
channel_id, _ = save_with_id(
ctx,
@@ -1516,6 +1961,7 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"platform": platform,
+ "accountType": account_type,
"personaJson": json.dumps([{"persona": persona, "size": persona_size}], ensure_ascii=False),
},
stage + "/save-channel",
@@ -1531,7 +1977,10 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"channelId": channel_id,
- "modelType": strategy_model_type(first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType"))),
+ "modelType": strategy_model_type(
+ first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType")),
+ model_type_choices,
+ ),
"language": strategy_language(first_present(strategy.get("language"), strategy.get("语种"))),
"respondingCount": responding_count,
"messageCount": responding_count,
@@ -1564,7 +2013,39 @@ def object_for_audience(audience: dict[str, Any]) -> dict[str, Any]:
}
+def downstream_limit(ctx: FlowContext, *, audience: bool) -> int:
+ """Limit expensive downstream analysis while keeping 3 upstream rows stored."""
+ raw = ctx.downstream_audiences if audience else ctx.downstream_pathways
+ return max(1, raw)
+
+
+def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, audience: bool) -> list[dict[str, Any]]:
+ """Select the 1-2 best stored rows for vulnerability/strategy generation."""
+ source_key = "topAudiences" if audience else "topPathways"
+ id_key = "designId" if audience else "pathwayAccountId"
+ selected_key = "selectedDownstreamAudiences" if audience else "selectedDownstreamPathways"
+ items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)]
+ limit = downstream_limit(ctx, audience=audience)
+ selected = items[:limit]
+ state[selected_key] = [
+ {
+ "id": item.get(id_key),
+ "name": item.get("targetAudience") if audience else item.get("accountName"),
+ }
+ for item in selected
+ ]
+ if items:
+ record_event(
+ state,
+ stage,
+ f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}",
+ {"limit": limit, "selected": state[selected_key]},
+ )
+ return selected
+
+
def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze audiences, save top 3, and attach any referenced accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step1-audience"
if state.get("topAudiences") and not ctx.dry_run:
@@ -1582,12 +2063,15 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
state["audienceRawText"] = text
+ # The first model call may return raw text plus embedded account objects.
accounts = accounts_from_response(parsed, objects, text)
state["audienceAccounts"] = accounts
+ # The second model call converts the long audience analysis into numbered
+ # groups that can be saved as task-action-design rows.
group_payload = {
"mubiao": state["mubiao"],
- "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
"zhanghao": accounts,
"history": [],
"stream": True,
@@ -1602,6 +2086,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
top = select_top_audiences(candidates, ctx.max_audiences)
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the audience first; account rows and downstream analyses depend
+ # on the returned designId.
design_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
@@ -1614,6 +2100,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
continue
item = {**item, "designId": design_id, "saveResponse": resp, "accounts": [], "savedAccountIds": []}
for ref in item.get("userIndexes") or []:
+ # Model references are intentionally fuzzy, so resolve them against
+ # both explicit indexes and account names/ids.
account = resolve_account_ref(ref, accounts)
if account is None:
record_error(state, stage + "/save-design-account", f"用户编号 {ref} 未匹配到候选账号")
@@ -1645,6 +2133,7 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze key pathway accounts for saved audiences and store top 3."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step2-pathway"
audiences = [a for a in state.get("topAudiences") or [] if a.get("designId")]
@@ -1656,6 +2145,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
return
all_candidates: list[dict[str, Any]] = []
for audience in audiences:
+ # Analyze pathway candidates in the context of each saved audience.
payload = {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。",
"otherinfo": {
@@ -1673,6 +2163,8 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
accounts = accounts_from_response(parsed, objects, text)
+ # Ask the model to point back to candidate accounts, then resolve those
+ # references to concrete account objects.
spread_payload = {
"mubiao": state["mubiao"],
"user_message": "分析其中关键信息。请从候选账号中抽取核心传播节点,字段必须是:账号1、理由1、账号2、理由2 ...,最多输出5个。账号字段优先返回候选数组的引用编号或索引,只返回JSON。",
@@ -1707,6 +2199,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways]
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the account row before writing the design-pathway relation.
account = item["account"]
aid = account_id(account)
name = account_name(account)
@@ -1757,12 +2250,14 @@ def run_step4(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate and save weak points for selected audiences or pathway accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step3-audience-vulns" if audience else "step4-pathway-vulns"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
output_key = "audienceVulns" if audience else "pathwayVulns"
state.setdefault(output_key, {})
for item in items:
+ # owner_id is designId for audiences and pathwayAccountId for accounts.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
if not owner_id:
continue
@@ -1789,6 +2284,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) ->
vulns = extract_vulns(data, ctx.max_vulns)
saved: list[dict[str, Any]] = []
for vuln in vulns:
+ # The two weak-point tables have nearly identical payloads but
+ # different foreign-key names.
if audience:
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
@@ -1835,14 +2332,16 @@ def run_step6(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate strategies from saved weak points and persist frontend child rows."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step5-audience-strategy" if audience else "step6-pathway-strategy"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {}
output_key = "audienceStrategies" if audience else "pathwayStrategies"
state.setdefault(output_key, [])
existing_topics = {s.get("topic") for s in state[output_key] if isinstance(s, dict)}
for item in items:
+ # Strategies are only meaningful after the matching weak points exist.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
vulns = vulns_by_owner.get(str(owner_id)) or []
vuln_texts = [v.get("vulnerabilities") for v in vulns if v.get("vulnerabilities")]
@@ -1876,16 +2375,20 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
continue
if topic in existing_topics:
continue
+ # Save the main row first; channel/narrate/message/relation rows all
+ # require the returned strategy id.
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
"targetAudience": target,
"accountType": account_type,
"topic": topic,
- "strategyTitle": "",
+ "strategyTitle": str(strategy.get("title") or "").strip(),
}
strategy_id, resp = save_with_id(ctx, state, "save_strategy", save_payload, stage + "/save")
if strategy_id is not None:
+ # Frontend strategy pages read these child tables, so a strategy
+ # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience)
existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp})
@@ -1914,13 +2417,14 @@ STEP_LABELS = {
def state_counts(state: dict[str, Any]) -> dict[str, int]:
+ """Summarize stored objects for step feedback."""
return {
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceVulns": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
- "pathwayVulns": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "受众脆弱点数量": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
+ "传播节点脆弱点数量": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
}
@@ -1932,23 +2436,26 @@ def emit_step_feedback(
status: str | None = None,
note: str = "",
) -> None:
+ # One feedback line per step keeps the single-skill and split-skill versions
+ # equally transparent to the user.
total_errors = len(state.get("errors") or [])
new_errors = max(0, total_errors - before_error_count)
final_status = status or ("partial" if new_errors else "ok")
payload = {
- "type": "STEP_FEEDBACK",
- "step": step,
- "label": STEP_LABELS.get(step, step),
- "status": final_status,
- "note": note,
- "newErrors": new_errors,
- "totalErrors": total_errors,
- "counts": state_counts(state),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "updatedAt": state.get("updatedAt"),
+ "日志类型": "步骤反馈",
+ "步骤": STEP_NUMBER_LABELS.get(step, step),
+ "步骤名称": STEP_LABELS.get(step, step),
+ "状态": STATUS_LABELS.get(final_status, final_status),
+ "执行状态": status_label(final_status),
+ "备注": note,
+ "本步骤新增错误数": new_errors,
+ "累计错误数": total_errors,
+ "累计入库数量": state_counts(state),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "更新时间": state.get("updatedAt"),
}
- print("STEP_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["step"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def finish_step(
@@ -1960,6 +2467,8 @@ def finish_step(
status: str | None = None,
note: str = "",
) -> None:
+ # Always persist before emitting step feedback so the user can inspect files
+ # immediately after a reported step completes.
state["updatedAt"] = utc_now()
save_json_file(ctx.state_path, state)
if ctx.summary_path:
@@ -1980,6 +2489,11 @@ def skip_step_with_feedback(ctx: FlowContext, state: dict[str, Any], step: str,
def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Legacy full runner.
+
+ The wrapper script is preferred for real-time logs, but this remains useful
+ for dry-runs and unattended local troubleshooting.
+ """
run_step_with_feedback(ctx, state, "step1")
run_step_with_feedback(ctx, state, "step2")
run_step_with_feedback(ctx, state, "step3")
@@ -1995,6 +2509,7 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
def write_summary(path: Path, state: dict[str, Any]) -> None:
+ """Write a human-readable checkpoint beside the machine state JSON."""
audience_vuln_count = sum(len(v) for v in (state.get("audienceVulns") or {}).values())
pathway_vuln_count = sum(len(v) for v in (state.get("pathwayVulns") or {}).values())
lines = [
@@ -2005,6 +2520,8 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
f"- actionId:{state.get('actionId')}",
f"- 受众入库:{len(state.get('topAudiences') or [])}",
f"- 传播节点入库:{len(state.get('topPathways') or [])}",
+ f"- 下游受众选择:{len(state.get('selectedDownstreamAudiences') or [])}",
+ f"- 下游传播节点选择:{len(state.get('selectedDownstreamPathways') or [])}",
f"- 受众脆弱点入库:{audience_vuln_count}",
f"- 传播节点脆弱点入库:{pathway_vuln_count}",
f"- 受众策略入库:{len(state.get('audienceStrategies') or [])}",
@@ -2021,9 +2538,9 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
if state.get("errors"):
lines += ["", "## 失败明细"]
for err in state["errors"]:
- lines.append(f"- [{err.get('stage')}] {err.get('message')} status={err.get('status')} url={err.get('url')}")
+ lines.append(f"- [{stage_label(err.get('stage'))}] {err.get('message')} 状态码={err.get('status')} 接口={err.get('url')}")
if err.get("response"):
- lines.append(f" - response: {err.get('response')}")
+ lines.append(f" - 响应摘要:{err.get('response')}")
text = "\n".join(lines) + "\n"
try:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -2034,34 +2551,34 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
def build_arg_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(description="Run xdfx analysis/storage flow.")
+ parser = argparse.ArgumentParser(description="运行 xdfx 分析与入库流程。")
parser.add_argument("--mode", choices=["full", *STEP_FUNCS.keys()], default="full")
- parser.add_argument("--input", help="JSON config file. Keys: api_base, taskId, actionId, mubiao, direction, platform, headers.")
- parser.add_argument("--state", default="xdfx-flow-state.json", help="State JSON path for resume and step handoff.")
- parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown summary output path.")
+ parser.add_argument("--input", help="JSON 配置文件。常用字段:api_base、taskId、actionId、mubiao、direction、platform、headers。")
+ parser.add_argument("--state", default="xdfx-flow-state.json", help="用于断点续跑和步骤交接的状态 JSON 文件路径。")
+ parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown 执行摘要输出路径。")
parser.add_argument("--api-base")
parser.add_argument("--task-id")
parser.add_argument("--action-id")
parser.add_argument("--mubiao")
parser.add_argument("--direction", default=None)
parser.add_argument("--platform", default=None)
- parser.add_argument("--auth-token", default=os.getenv("XDFX_AUTH_TOKEN"))
- parser.add_argument("--authorization", default=os.getenv("AUTHORIZATION"))
- parser.add_argument("--header", action="append", default=[], help="Extra HTTP header, format 'Key: Value'.")
- parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="Username for automatic login when Authorization is missing or stale.")
- parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="Password query parameter for automatic username login.")
- parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="Login endpoint path or absolute URL.")
- parser.add_argument("--no-auto-login", action="store_true", help="Disable automatic username login and 401 token refresh.")
- parser.add_argument("--verify-ssl", action="store_true", help="Verify HTTPS certificates. Default is disabled for internal/UAT certificates.")
+ parser.add_argument("--header", action="append", default=[], help="附加非鉴权 HTTP 请求头,格式为 'Key: Value';Authorization 会被忽略。")
+ parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="xdfx_auth 启动阶段自动登录使用的用户名。")
+ parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="用户名登录接口的 password 查询参数。")
+ parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="登录接口路径或完整 URL。")
+ parser.add_argument("--no-auto-login", action="store_true", help="关闭 xdfx_auth 自动登录和 401 刷新。")
+ parser.add_argument("--verify-ssl", action="store_true", help="校验 HTTPS 证书。默认不校验,以兼容内网/UAT 自签证书。")
parser.add_argument("--timeout", type=float, default=90)
parser.add_argument("--retries", type=int, default=2)
parser.add_argument("--dry-run", action="store_true")
- parser.add_argument("--no-step-status", action="store_true", help="Do not update task/task-action.step after completed steps.")
- parser.add_argument("--model-retries", type=int, default=1, help="Retry model/stream endpoints when the first response is empty, timeout-like, or says the model did not respond.")
+ parser.add_argument("--no-step-status", action="store_true", help="步骤完成后不更新 task/task-action.step。")
+ parser.add_argument("--model-retries", type=int, default=1, help="模型/流式接口首次空响应、超时或提示模型无响应时的重试次数。")
parser.add_argument("--max-audiences", type=int, default=3)
parser.add_argument("--max-pathways", type=int, default=3)
parser.add_argument("--max-vulns", type=int, default=3)
parser.add_argument("--max-strategies", type=int, default=3)
+ parser.add_argument("--downstream-audiences", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库受众数量,默认 2。")
+ parser.add_argument("--downstream-pathways", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库传播节点数量,默认 2。")
return parser
@@ -2070,6 +2587,8 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
state_path = Path(args.state)
state = default_state()
+ # Load previous state first, then overlay input JSON and finally CLI flags.
+ # That ordering makes resume safe while still allowing explicit overrides.
if state_path.exists():
loaded = load_json_file(state_path)
if isinstance(loaded, dict):
@@ -2081,9 +2600,7 @@ def main(argv: list[str] | None = None) -> int:
merge_config_into_state(state, config)
input_headers = config.get("headers")
if isinstance(input_headers, dict):
- args.header.extend(f"{k}: {v}" for k, v in input_headers.items())
- args.auth_token = args.auth_token or config.get("auth_token")
- args.authorization = args.authorization or config.get("authorization")
+ args.header.extend(f"{k}: {v}" for k, v in input_headers.items() if str(k).lower() != "authorization")
if config.get("login_username") is not None:
args.login_username = str(config.get("login_username") or "")
if config.get("login_password") is not None:
@@ -2094,6 +2611,10 @@ def main(argv: list[str] | None = None) -> int:
args.no_auto_login = True
if config.get("verify_ssl") is not None:
args.verify_ssl = bool(config.get("verify_ssl"))
+ if config.get("downstream_audiences") is not None:
+ args.downstream_audiences = as_int(config.get("downstream_audiences")) or args.downstream_audiences
+ if config.get("downstream_pathways") is not None:
+ args.downstream_pathways = as_int(config.get("downstream_pathways")) or args.downstream_pathways
cli_config = {
"api_base": args.api_base,
@@ -2105,7 +2626,8 @@ def main(argv: list[str] | None = None) -> int:
}
merge_config_into_state(state, {k: v for k, v in cli_config.items() if v is not None})
api_base = state.get("api_base") or "https://ch1.b.uat.4.cn"
- headers = parse_headers(args.header, args.auth_token, args.authorization)
+ headers = parse_headers(args.header)
+ # ctx owns runtime knobs; state owns business data and resumable ids.
ctx = FlowContext(
api_base=api_base,
headers=headers,
@@ -2118,6 +2640,8 @@ def main(argv: list[str] | None = None) -> int:
max_pathways=max(args.max_pathways, 1),
max_vulns=max(args.max_vulns, 1),
max_strategies=max(args.max_strategies, 1),
+ downstream_audiences=max(args.downstream_audiences, 1),
+ downstream_pathways=max(args.downstream_pathways, 1),
sync_step_status=not args.no_step_status,
model_retries=max(args.model_retries, 0),
verify_ssl=bool(args.verify_ssl),
@@ -2131,11 +2655,17 @@ def main(argv: list[str] | None = None) -> int:
state["_summaryPath"] = str(ctx.summary_path) if ctx.summary_path else ""
state["_autoLogin"] = ctx.auto_login
state["_verifySsl"] = ctx.verify_ssl
+ state["_downstreamLimits"] = {
+ "audiences": ctx.downstream_audiences,
+ "pathways": ctx.downstream_pathways,
+ }
record_event(state, "runner", f"start mode={args.mode} dry_run={ctx.dry_run}")
try:
require_context(state, "taskId", "actionId", "mubiao")
- if ctx.auto_login and not has_authorization(ctx.headers):
- try_auto_login(ctx, state, "runner/auto-login")
+ if ctx.auto_login and not ctx.dry_run:
+ # Always acquire a token through the imported auth package before the first business call.
+ if not try_auto_login(ctx, state, "runner/initial-login", force=True):
+ raise FlowError("启动阶段获取 token 失败,已停止执行,避免无 token 调用业务接口")
if args.mode == "full":
run_full(ctx, state)
else:
@@ -2145,13 +2675,14 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(f"ERROR: {exc}", file=sys.stderr)
+ print(f"错误:{exc}", file=sys.stderr)
return 2
except Exception as exc: # noqa: BLE001
record_error(state, "runner", "未捕获异常", response=traceback.format_exc(), exception=exc)
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
+ print("未捕获异常堆栈:", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
return 1
@@ -2159,20 +2690,23 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(json.dumps(
- {
- "ok": True,
- "mode": args.mode,
- "state": str(state_path),
- "summary": str(ctx.summary_path) if ctx.summary_path else "",
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
- "errors": len(state.get("errors") or []),
- },
- ensure_ascii=False,
- ))
+ # Final JSON is intentionally compact; detailed per-step status has already
+ # been emitted as Chinese step feedback.
+ final_payload = {
+ "日志类型": "执行完成",
+ "是否成功": "是",
+ "执行模式": mode_label(args.mode),
+ "状态文件": str(state_path),
+ "摘要文件": str(ctx.summary_path) if ctx.summary_path else "",
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "已选择受众数": len(state.get("selectedDownstreamAudiences") or []),
+ "已选择传播节点数": len(state.get("selectedDownstreamPathways") or []),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
+ "错误数": len(state.get("errors") or []),
+ }
+ print(LOG_PREFIXES["done"] + " " + json.dumps(final_payload, ensure_ascii=False))
return 0
diff --git a/skills-data/xdfx-split-controller/scripts/xdfx_run_full_steps.py b/skills-data/xdfx-split-controller/scripts/xdfx_run_full_steps.py
new file mode 100644
index 00000000..d6468b51
--- /dev/null
+++ b/skills-data/xdfx-split-controller/scripts/xdfx_run_full_steps.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+"""Run the xdfx flow one step at a time.
+
+The heavy business logic intentionally stays in xdfx_flow.py. This wrapper only
+keeps "full flow" orchestration readable and preserves per-step feedback: every
+subprocess line is flushed immediately, so OP/SAVE/ERROR/STEP feedback remains
+visible to the calling agent.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+
+STEPS = ("step1", "step2", "step3", "step4", "step5", "step6")
+
+STEP_LABELS = {
+ "step1": "受众目标分析与入库",
+ "step2": "关键传播节点分析与入库",
+ "step3": "受众脆弱点分析与入库",
+ "step4": "传播节点脆弱点分析与入库",
+ "step5": "受众策略分析与入库",
+ "step6": "传播节点策略分析与入库",
+}
+
+
+def build_parser() -> argparse.ArgumentParser:
+ """Parse only wrapper-owned flags; pass all other flags to xdfx_flow.py."""
+ parser = argparse.ArgumentParser(
+ description="按六个步骤运行 xdfx_flow.py,并实时转发中文反馈日志。",
+ add_help=True,
+ )
+ parser.add_argument("--from-step", choices=STEPS, default="step1")
+ parser.add_argument("--to-step", choices=STEPS, default="step6")
+ parser.add_argument(
+ "--continue-on-error",
+ action="store_true",
+ help="某一步返回非零退出码时仍继续执行后续步骤。",
+ )
+ return parser
+
+
+def selected_steps(first: str, last: str) -> list[str]:
+ """Return an inclusive step slice for resume/partial reruns."""
+ start = STEPS.index(first)
+ end = STEPS.index(last)
+ if start > end:
+ raise SystemExit("起始步骤必须小于或等于结束步骤")
+ return list(STEPS[start : end + 1])
+
+
+def stream_command(step: str, cmd: list[str]) -> int:
+ """Run one step and forward stdout/stderr lines as they are produced."""
+ print(f"运行步骤:{STEP_LABELS.get(step, step)};执行命令:" + " ".join(cmd), flush=True)
+ env = os.environ.copy()
+ env["PYTHONIOENCODING"] = "utf-8"
+ process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ env=env,
+ )
+ assert process.stdout is not None
+ for line in process.stdout:
+ sys.stdout.buffer.write(line.encode("utf-8", errors="replace"))
+ sys.stdout.buffer.flush()
+ return process.wait()
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args, passthrough = parser.parse_known_args(argv)
+ flow_script = Path(__file__).with_name("xdfx_flow.py")
+ exit_code = 0
+ for step in selected_steps(args.from_step, args.to_step):
+ # Keep all business flags unchanged so the wrapper behaves like a thin
+ # streaming version of repeated xdfx_flow.py --mode stepN calls.
+ cmd = [sys.executable, str(flow_script), "--mode", step, *passthrough]
+ code = stream_command(step, cmd)
+ if code:
+ exit_code = code
+ if not args.continue_on_error:
+ return code
+ return exit_code
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/skills-data/xdfx-split-flow-skill-guide.md b/skills-data/xdfx-split-flow-skill-guide.md
index f40c8834..305c43a2 100644
--- a/skills-data/xdfx-split-flow-skill-guide.md
+++ b/skills-data/xdfx-split-flow-skill-guide.md
@@ -45,12 +45,12 @@ scripts/xdfx_flow.py
xdfx-flow-state.json
```
-默认会自动处理登录态:缺少 `Authorization` 或接口返回 401 时,脚本会用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/v1/auth/login/username?password=ch%40user`,请求体为 `{"username":"admin"}`,拿到 `access_token` 后写入 `Authorization: Bearer ...` 并重试原接口一次。默认关闭 HTTPS 证书校验,用于兼容 UAT/内网自签证书;需要严格校验证书时追加 `--verify-ssl`。已有 token 时可追加 `--auth-token TOKEN` 或 `--header "Authorization: Bearer TOKEN"`。
+默认会自动处理登录态:脚本启动时先 `import scripts/xdfx_auth.py`,用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/v1/auth/login/username?password=ch%40user`,请求体为 `{"username":"admin"}`,拿到 `access_token` 后写入内部 `Authorization: Bearer ...`,再调用第一步业务接口;不要通过任何命令行参数传 token。默认关闭 HTTPS 证书校验,用于兼容 UAT/内网自签证书;需要严格校验证书时追加 `--verify-ssl`。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次。
每一步命令结束都会输出一行:
```text
-STEP_FEEDBACK {...}
+步骤反馈 {...}
```
总控智能体必须在每一步结束后立即把这行反馈转述给用户,再执行下一步。
@@ -58,15 +58,23 @@ STEP_FEEDBACK {...}
每次执行具体操作或调用接口前,脚本会输出:
```text
-OP_FEEDBACK {...}
+操作反馈 {...}
```
-总控智能体必须把 `operation`、`method` 和 `url` 转述给用户。
+总控智能体必须把 `操作`、`请求方式` 和 `接口地址` 转述给用户。
+
+每次保存接口成功拿到 `id` 后,脚本会输出:
+
+```text
+入库反馈 {...}
+```
+
+总控智能体必须把 `入库对象类型`、`入库对象名称` 和 `返回ID` 转述给用户,例如“已入库受众目标:xxx,designId=123”或“已入库策略:xxx,id=456”。
如果报错,脚本会立即输出:
```text
-ERROR_FEEDBACK {...}
+错误反馈 {...}
```
总控智能体必须立刻告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
@@ -74,12 +82,12 @@ ERROR_FEEDBACK {...}
如果模型 JSON 经过自动提取和修复仍无法解析,脚本会输出:
```text
-JSON_PARSE_REVIEW {...}
+JSON解析复核 {...}
```
-总控智能体必须查看 `responseExcerpt`,判断是否能识别真正 JSON;无法确认时重跑当前 step 或重新调用对应模型接口。
+总控智能体必须查看 `响应片段`,判断是否能识别真正 JSON;无法确认时重跑当前步骤或重新调用对应模型接口。
-每一步中的流式模型接口第一次返回空响应、`y模型没有响应`、`模型没有响应` 或超时类字符串时,脚本会自动再调用一次;如果第二次仍失败,才写入 `errors` 并在该步骤反馈里体现为 `partial`。
+每一步中的流式模型接口第一次返回空响应、`y模型没有响应`、`模型没有响应` 或超时类字符串时,脚本会自动再调用一次;如果第二次仍失败,才写入 `errors` 并在该步骤反馈里体现为 `部分完成`。
## 推荐执行顺序
@@ -96,23 +104,39 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
如果某一步失败,修复接口、鉴权或输入后,只重跑对应 `stepN` 即可。
+如果需要一条命令完成分步编排,可以在 `xdfx-split-controller` 目录使用包装脚本;它内部仍按 step1-step6 调用执行器,并逐行转发反馈:
+
+```bash
+python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
+```
+
## 每步反馈格式
-脚本会输出三类反馈行:
+脚本会输出五类反馈行:
-- `OP_FEEDBACK`:当前正在做什么、调用哪个接口。
-- `ERROR_FEEDBACK`:当前错误的阶段、接口、状态码、请求摘要和响应摘要。
-- `JSON_PARSE_REVIEW`:模型返回内容无法稳定解析为 JSON,需要执行智能体复核是否重试。
-- `STEP_FEEDBACK`:当前步骤最终状态和累计入库数量。
+- `操作反馈`:当前正在做什么、调用哪个接口。
+- `入库反馈`:当前已入库对象的类型、名称和返回 ID。
+- `错误反馈`:当前错误的阶段、接口、状态码、请求摘要和响应摘要。
+- `JSON解析复核`:模型返回内容无法稳定解析为 JSON,需要执行智能体复核是否重试。
+- `步骤反馈`:当前步骤最终状态和累计入库数量。
-`STEP_FEEDBACK` 中包含:
+`步骤反馈` 中包含:
-- `step`:步骤编号。
-- `label`:步骤名称。
-- `status`:`ok`、`partial` 或 `skipped`。
-- `counts`:当前累计入库数量。
-- `newErrors`:本步骤新增错误数。
-- `totalErrors`:累计错误数。
+- `步骤`:步骤编号。
+- `步骤名称`:步骤名称。
+- `状态`:`成功`、`部分完成` 或 `已跳过`。
+- `执行状态`:中文执行状态。
+- `累计入库数量`:当前累计入库数量。
+- `本步骤新增错误数`:本步骤新增错误数。
+- `累计错误数`:累计错误数。
+
+默认数量规则:
+
+- Step1 入库最优 3 个受众。
+- Step2 入库最优 3 个传播节点。
+- Step3/Step4 只从已入库结果中默认选择前 2 个受众/传播节点继续,每个生成最多 3 个脆弱点;可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。
+- Step5/Step6 对上述选中的每个受众/传播节点默认生成最多 3 个策略。
+- Step5/Step6 的策略模态必须按前端 `modelType` 字典保存为 value 逗号串;脚本会自动把“图片,视频/文本,图片”等文字转成 `task-action-message.modelType` 需要的值。
总控反馈用户时建议使用这种格式:
@@ -120,9 +144,9 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
Step2 关键传播节点分析完成:当前已入库传播节点 X 个,本步骤新增错误 Y 个。
```
-如果 `status=partial`,说明该步骤部分完成,并继续执行后续可执行步骤。
+如果 `状态` 为 `部分完成`,说明该步骤部分完成,并继续执行后续可执行步骤。
-如果 `status=skipped`,说明跳过原因。
+如果 `状态` 为 `已跳过`,说明跳过原因。
## 各步骤职责
@@ -254,7 +278,7 @@ xdfx-flow-summary.md
xdfx-flow-state.json
```
-优先依据本步骤的 `STEP_FEEDBACK` 给即时反馈;最终汇总时再读取完整 summary 和 errors。
+优先依据本步骤的 `步骤反馈` 给即时反馈;最终汇总时再读取完整 summary 和 errors。
如果 `errors` 非空,必须告诉用户:
@@ -266,3 +290,4 @@ xdfx-flow-state.json
- 哪些数据已成功入库,哪些数据未完成。
只要存在错误,就说“部分完成”,不要说“全部完成”。
+
diff --git a/skills-data/xdfx-step1-audience/SKILL.md b/skills-data/xdfx-step1-audience/SKILL.md
index 0496ce2d..5b9f7474 100644
--- a/skills-data/xdfx-step1-audience/SKILL.md
+++ b/skills-data/xdfx-step1-audience/SKILL.md
@@ -24,7 +24,7 @@ python scripts/xdfx_flow.py --mode step1 --input xdfx-input.json --state xdfx-fl
}
```
-鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
+鉴权和证书由脚本兜底处理:脚本启动时先 `import scripts/xdfx_auth.py`,自动用 `admin / ch@user` 获取 `access_token`,并在第一次业务接口调用前写入内部 Authorization;不要通过命令行传 token。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
@@ -32,7 +32,8 @@ python scripts/xdfx_flow.py --mode step1 --input xdfx-input.json --state xdfx-fl
- 自动解析流式 answer 与候选账号。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 抽取结构化受众。
-- 选择最优 3 个受众。
+- 抽取结果中的 `用户编号列表` / `编号id` / `id` 必须对应上一轮 `zhanghao` 账号数组的索引值;脚本按索引依次找到账号并保存。
+- 选择并入库最优 3 个受众。
- 保存受众目标。
- 保存受众目标账号。
- 同步前端步骤状态 `step-analysis-audience` 与 `step-analysis-keypoint`。
@@ -40,12 +41,22 @@ python scripts/xdfx_flow.py --mode step1 --input xdfx-input.json --state xdfx-fl
执行后检查 `xdfx-flow-summary.md`。如果 `errors` 非空,向用户报告失败接口、状态码、响应正文摘要。
+## 失败重试规则
+
+- 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前 Step1 命令。
+- 重试前必须告诉用户失败阶段、接口 URL、状态码、响应摘要和“正在重试 Step1”,不能静默重试。
+- Step1 未重试成功前,不允许继续 Step2;不要因为 Step1 失败就跳过受众分析。
+- Step1 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
+
## 步骤反馈
-命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step1 的完成状态、当前受众入库数量和新增错误数,再决定是否继续下一步。
+命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step1 的完成状态、当前受众入库数量和新增错误数,再决定是否继续下一步。
-执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
+执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。
-如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的受众、受众账号和返回 ID。
+
+如果出现 `错误反馈 {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+
+如果出现 `JSON解析复核 {...}`,必须查看 `响应片段`,判断是否能识别受众 JSON;无法确认时重跑 Step1。
-如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别受众 JSON;无法确认时重跑 Step1。
diff --git a/skills-data/xdfx-step1-audience/scripts/xdfx_auth.py b/skills-data/xdfx-step1-audience/scripts/xdfx_auth.py
new file mode 100644
index 00000000..fe46f809
--- /dev/null
+++ b/skills-data/xdfx-step1-audience/scripts/xdfx_auth.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""Authentication helper for xdfx skill scripts.
+
+The caller should not pass tokens through command-line arguments. This module
+owns the fixed admin login flow and returns a Bearer token before business
+interfaces are called.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import ssl
+import urllib.error
+import urllib.parse
+import urllib.request
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass
+class AuthResult:
+ token: str
+ url: str
+ status: int | None
+ raw: str
+
+
+class AuthError(RuntimeError):
+ def __init__(
+ self,
+ message: str,
+ *,
+ url: str,
+ status: int | None = None,
+ response: str = "",
+ exception: BaseException | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.url = url
+ self.status = status
+ self.response = response
+ self.exception = exception
+
+
+def auth_header_value(token: str) -> str:
+ token = token.strip()
+ return token if token.lower().startswith("bearer ") else f"Bearer {token}"
+
+
+def redact_url(url: str) -> str:
+ return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
+
+
+def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
+ if verify_ssl:
+ return None
+ return ssl._create_unverified_context()
+
+
+def build_login_url(api_base: str, path: str, password: str) -> str:
+ base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
+ if password:
+ separator = "&" if "?" in base else "?"
+ base = f"{base}{separator}password={urllib.parse.quote(password, safe='')}"
+ return base
+
+
+def extract_access_token(data: Any) -> str | None:
+ if isinstance(data, dict):
+ for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
+ value = data.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ for value in data.values():
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ elif isinstance(data, list):
+ for value in data:
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ return None
+
+
+def parse_json_loose(text: str) -> Any:
+ try:
+ return json.loads(text)
+ except Exception:
+ pass
+ match = re.search(r"[\[{]", text or "")
+ if not match:
+ return {}
+ start = match.start()
+ for end in range(len(text), start, -1):
+ try:
+ return json.loads(text[start:end])
+ except Exception:
+ continue
+ return {}
+
+
+def fetch_access_token(
+ *,
+ api_base: str,
+ username: str = "admin",
+ password: str = "ch@user",
+ login_path: str = "/api/v1/auth/login/username",
+ timeout: float = 90,
+ verify_ssl: bool = False,
+) -> AuthResult:
+ """Login once and return the extracted access token."""
+ url = build_login_url(api_base, login_path, password)
+ payload = {"username": username}
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ parsed = parse_json_loose(raw)
+ token = extract_access_token(parsed)
+ if not token:
+ raise AuthError(
+ "登录接口已响应但未找到 access_token",
+ url=redact_url(url),
+ status=getattr(resp, "status", None),
+ response=raw,
+ )
+ return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
+ raise AuthError(
+ "登录接口返回错误",
+ url=redact_url(url),
+ status=exc.code,
+ response=body,
+ exception=exc,
+ ) from exc
+ except AuthError:
+ raise
+ except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
+ raise AuthError(
+ "登录接口调用异常",
+ url=redact_url(url),
+ response=str(exc),
+ exception=exc,
+ ) from exc
diff --git a/skills-data/xdfx-step1-audience/scripts/xdfx_flow.py b/skills-data/xdfx-step1-audience/scripts/xdfx_flow.py
index 52880e82..5f405cc8 100644
--- a/skills-data/xdfx-step1-audience/scripts/xdfx_flow.py
+++ b/skills-data/xdfx-step1-audience/scripts/xdfx_flow.py
@@ -26,7 +26,10 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
+import xdfx_auth
+
+# Keep every backend path in one table so endpoint drift is easy to audit.
ENDPOINTS = {
"aiuser": "/gpt/chat/chat_aiuser",
"group": "/gpt/chat/chat_qianqian_group_chat",
@@ -44,10 +47,12 @@ ENDPOINTS = {
"save_narrate": "/api/base/base-narrate",
"save_message": "/api/task/task-action-message",
"save_strategy_relation": "/api/task/task-action-design-strategy-relation",
+ "dict_data": "/api/sys/sys-dict-data",
}
+# Human-readable names are used only in the Chinese feedback logs.
ENDPOINT_LABELS = {
- "login": "登录获取访问 token",
+ "login": "登录获取访问令牌",
"aiuser": "调用候选账号/受众分析模型接口",
"group": "调用受众目标结构化抽取接口",
"spread": "调用关键传播节点结构化抽取接口",
@@ -64,8 +69,86 @@ ENDPOINT_LABELS = {
"save_narrate": "保存策略信息主题",
"save_message": "保存策略消息配置",
"save_strategy_relation": "保存策略与受众/传播节点关系",
+ "dict_data": "读取前端字典数据",
}
+LOG_PREFIXES = {
+ "operation": "操作反馈",
+ "error": "错误反馈",
+ "json_review": "JSON解析复核",
+ "save": "入库反馈",
+ "step": "步骤反馈",
+ "done": "执行完成",
+}
+
+STATUS_LABELS = {
+ "ok": "成功",
+ "partial": "部分完成",
+ "skipped": "已跳过",
+}
+
+STEP_NUMBER_LABELS = {
+ "step1": "第一步",
+ "step2": "第二步",
+ "step3": "第三步",
+ "step4": "第四步",
+ "step5": "第五步",
+ "step6": "第六步",
+}
+
+HTTP_METHOD_LABELS = {
+ "GET": "读取(GET)",
+ "POST": "提交(POST)",
+ "PUT": "更新(PUT)",
+ "PATCH": "局部更新(PATCH)",
+ "DELETE": "删除(DELETE)",
+}
+
+STAGE_SEGMENT_LABELS = {
+ "analyze": "模型分析",
+ "extract": "结构化抽取",
+ "save": "保存数据",
+ "read": "读取数据",
+ "status": "同步状态",
+}
+
+REQUEST_FIELD_LABELS = {
+ "history": "历史消息",
+ "max_tokens": "最大生成令牌数",
+ "model_name": "模型名称",
+ "otherinfo": "其他信息",
+ "prompt_name": "提示词名称",
+ "stream": "是否流式",
+ "temperature": "温度",
+ "usermessage": "用户消息",
+ "user_message": "用户消息",
+ "mubiao": "行动目标",
+ "zhanghao": "账号列表",
+ "taskId": "任务ID",
+ "actionId": "行动ID",
+ "taskActionId": "行动ID",
+ "designId": "受众ID",
+ "pathwayId": "传播节点ID",
+ "strategyId": "策略ID",
+ "accountId": "账号ID",
+ "accountName": "账号名称",
+ "accountType": "账号类型",
+ "targetAudience": "受众目标",
+ "vulnerabilities": "脆弱点",
+ "topic": "信息标题",
+ "narrate": "叙事主题",
+ "platform": "平台",
+ "persona": "发布人设",
+ "modelType": "信息形式",
+ "quantity": "数量",
+ "publishTime": "发布时间段",
+ "reason": "理由",
+ "ids": "ID列表",
+ "id": "ID",
+ "step": "步骤状态",
+}
+
+# Platform values mirror the frontend dictionaries used by xdfx strategy pages.
PLATFORM_MAP = {
"facebook": 1,
"fb": 1,
@@ -78,6 +161,8 @@ PLATFORM_MAP = {
"yt": 4,
}
+# The model service sometimes returns a successful HTTP response with these
+# strings instead of useful content. Treat them like transient failures.
MODEL_RETRY_MARKERS = (
"y模型没有响应",
"模型没有响应",
@@ -118,6 +203,12 @@ class FlowError(RuntimeError):
@dataclass
class FlowContext:
+ """Runtime options shared by every step.
+
+ Keep this object boring and serializable-ish: it is passed through every
+ helper, while durable progress belongs in the state JSON file.
+ """
+
api_base: str
headers: dict[str, str]
timeout: float
@@ -129,6 +220,8 @@ class FlowContext:
max_pathways: int = 3
max_vulns: int = 3
max_strategies: int = 3
+ downstream_audiences: int = 2
+ downstream_pathways: int = 2
sync_step_status: bool = True
model_retries: int = 1
verify_ssl: bool = False
@@ -153,7 +246,7 @@ def compact(value: Any, limit: int = 1400) -> str:
text = repr(value)
if len(text) <= limit:
return text
- return text[:limit] + f"... "
+ return text[:limit] + f"... <已截断 {len(text) - limit} 个字符>"
def stable_json(value: Any) -> str:
@@ -223,6 +316,11 @@ def save_json_file(path: str | Path, value: Any) -> None:
def default_state() -> dict[str, Any]:
+ """Create a resumable state container.
+
+ The script can be run as six independent commands. IDs returned by one step
+ are persisted here so later steps can resume without redoing earlier saves.
+ """
return {
"api_base": "",
"taskId": "",
@@ -247,6 +345,7 @@ def default_state() -> dict[str, Any]:
def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> None:
+ # Accept both frontend-style camelCase and CLI-friendly snake_case inputs.
aliases = {
"task_id": "taskId",
"action_id": "actionId",
@@ -260,16 +359,12 @@ def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> No
continue
if dest in {"taskId", "actionId"}:
state[dest] = str(value)
- elif dest in state or dest in {
- "api_base",
- "headers",
- "auth_token",
- "authorization",
- }:
+ elif dest in state or dest in {"api_base", "headers"}:
state[dest] = value
def record_event(state: dict[str, Any], stage: str, message: str, data: Any | None = None) -> None:
+ """Append a compact diagnostic event to the state file."""
state.setdefault("events", []).append(
{
"time": utc_now(),
@@ -286,9 +381,13 @@ def feedback_step(stage: str) -> str:
return match.group(1) if match else ""
+def feedback_step_label(stage: str) -> str:
+ return STEP_NUMBER_LABELS.get(feedback_step(stage), "")
+
+
def request_keys(payload: Any) -> list[str]:
if isinstance(payload, dict):
- return sorted(str(key) for key in payload.keys())
+ return sorted(REQUEST_FIELD_LABELS.get(str(key), str(key)) for key in payload.keys())
return []
@@ -306,6 +405,77 @@ def endpoint_label(endpoint_key: str | None, method: str, url: str) -> str:
return f"调用接口 {method}"
+def method_label(method: str) -> str:
+ return HTTP_METHOD_LABELS.get(str(method).upper(), str(method))
+
+
+def yes_no(value: bool) -> str:
+ return "是" if value else "否"
+
+
+def stage_label(stage: Any) -> str:
+ text = str(stage or "")
+ if not text:
+ return ""
+ if text in STEP_LABELS:
+ return STEP_LABELS[text]
+ if text == "runner":
+ return "流程执行器"
+ parts = text.split("/")
+ base = parts[0]
+ match = re.search(r"(step[1-6])", base)
+ labels: list[str] = []
+ if match:
+ labels.append(STEP_LABELS.get(match.group(1), match.group(1)))
+ elif base == "runner":
+ labels.append("流程执行器")
+ else:
+ labels.append(base)
+ for segment in parts[1:]:
+ normalized = segment.replace("-", "_")
+ attempt = re.fullmatch(r"model-call-attempt-(\d+)", segment)
+ json_attempt = re.fullmatch(r"model-json-attempt-(\d+)", segment)
+ if attempt:
+ labels.append(f"模型接口第 {attempt.group(1)} 次尝试")
+ elif json_attempt:
+ labels.append(f"JSON解析第 {json_attempt.group(1)} 次尝试")
+ elif segment == "auto-login":
+ labels.append("自动登录")
+ elif segment in STAGE_SEGMENT_LABELS:
+ labels.append(STAGE_SEGMENT_LABELS[segment])
+ elif normalized in ENDPOINT_LABELS:
+ labels.append(ENDPOINT_LABELS[normalized])
+ else:
+ labels.append(segment)
+ return " / ".join(labels)
+
+
+def status_label(status: Any) -> str:
+ if status is None:
+ return ""
+ return STATUS_LABELS.get(str(status), str(status))
+
+
+def mode_label(mode: str) -> str:
+ if mode == "full":
+ return "全流程"
+ return STEP_LABELS.get(mode, mode)
+
+
+def exception_type_label(exception_type: Any) -> str:
+ name = str(exception_type or "")
+ labels = {
+ "HTTPError": "HTTP 调用异常",
+ "URLError": "网络地址异常",
+ "TimeoutError": "请求超时异常",
+ "FlowError": "流程异常",
+ "ValueError": "数据格式异常",
+ "KeyError": "字段缺失异常",
+ "RuntimeError": "运行异常",
+ }
+ return labels.get(name, "程序异常" if name else "")
+
+
def emit_operation_feedback(
state: dict[str, Any],
stage: str,
@@ -317,39 +487,41 @@ def emit_operation_feedback(
attempt: int = 1,
dry_run: bool = False,
) -> None:
+ # Feedback lines are a mini protocol consumed by the calling agent. They
+ # must stay single-line JSON so weak models/tools can relay them reliably.
payload_obj = {
- "type": "OP_FEEDBACK",
- "step": feedback_step(stage),
- "stage": stage,
- "operation": endpoint_label(endpoint_key, method, url),
- "method": method,
- "url": url,
- "endpoint": endpoint_key or "",
- "attempt": attempt,
- "dryRun": dry_run,
- "requestKeys": request_keys(payload),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "操作反馈",
+ "步骤": feedback_step_label(stage),
+ "阶段": stage_label(stage),
+ "操作": endpoint_label(endpoint_key, method, url),
+ "请求方式": method_label(method),
+ "接口地址": url,
+ "接口名称": endpoint_label(endpoint_key, method, url),
+ "第几次尝试": attempt,
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("OP_FEEDBACK " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["operation"] + " " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
def emit_error_feedback(error: dict[str, Any]) -> None:
payload = {
- "type": "ERROR_FEEDBACK",
- "stage": error.get("stage"),
- "step": feedback_step(str(error.get("stage") or "")),
- "message": error.get("message"),
- "url": error.get("url"),
- "status": error.get("status"),
- "request": error.get("request"),
- "response": error.get("response"),
- "exceptionType": error.get("exceptionType"),
- "exception": error.get("exception"),
- "time": error.get("time"),
+ "日志类型": "错误反馈",
+ "阶段": stage_label(error.get("stage")),
+ "步骤": feedback_step_label(str(error.get("stage") or "")),
+ "错误信息": error.get("message"),
+ "接口地址": error.get("url"),
+ "状态码": error.get("status"),
+ "请求摘要": error.get("request"),
+ "响应摘要": error.get("response"),
+ "异常类型": exception_type_label(error.get("exceptionType")),
+ "异常信息": error.get("exception"),
+ "时间": error.get("time"),
}
- print("ERROR_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["error"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def emit_json_parse_review(
@@ -360,18 +532,89 @@ def emit_json_parse_review(
candidate_count: int,
) -> None:
payload = {
- "type": "JSON_PARSE_REVIEW",
- "stage": stage,
- "step": feedback_step(stage),
- "endpoint": endpoint_key,
- "message": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
- "candidateCount": candidate_count,
- "responseExcerpt": compact(text, 3000),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "JSON解析复核",
+ "阶段": stage_label(stage),
+ "步骤": feedback_step_label(stage),
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, endpoint_key),
+ "提示": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
+ "候选数量": candidate_count,
+ "响应片段": compact(text, 3000),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("JSON_PARSE_REVIEW " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["json_review"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
+
+
+SAVE_ENTITY_LABELS = {
+ "save_design": "受众目标",
+ "save_design_account": "受众目标账号",
+ "save_pathway": "关键传播节点",
+ "save_relation": "受众-传播节点关系",
+ "save_design_vuln": "受众脆弱点",
+ "save_pathway_vuln": "传播节点脆弱点",
+ "save_strategy": "策略",
+ "save_channel": "策略平台与人设",
+ "save_narrate": "策略信息主题",
+ "save_message": "策略消息配置",
+ "save_strategy_relation": "策略关联关系",
+}
+
+
+def saved_entity_name(endpoint_key: str, payload: dict[str, Any]) -> str:
+ """Return the most user-readable name for an inserted row."""
+ candidates = [
+ payload.get("targetAudience"),
+ payload.get("accountName"),
+ payload.get("vulnerabilities"),
+ payload.get("topic"),
+ payload.get("narrate"),
+ payload.get("platform"),
+ payload.get("strategyId"),
+ payload.get("designId"),
+ payload.get("pathwayId"),
+ ]
+ for value in candidates:
+ if value is not None and str(value).strip():
+ return str(value).strip()
+ return endpoint_key
+
+
+def emit_save_feedback(
+ state: dict[str, Any],
+ stage: str,
+ endpoint_key: str,
+ payload: dict[str, Any],
+ row_id: int,
+ *,
+ dry_run: bool,
+) -> None:
+ # Emit only after an id is known. Downstream steps use these ids and should
+ # fail loudly rather than inventing relations against missing rows.
+ label = SAVE_ENTITY_LABELS.get(endpoint_key, endpoint_key)
+ name = saved_entity_name(endpoint_key, payload)
+ feedback = {
+ "日志类型": "入库反馈",
+ "步骤": feedback_step(stage),
+ "阶段": stage,
+ "入库对象类型": label,
+ "入库对象名称": name,
+ "返回ID": row_id,
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, label),
+ "接口路径": ENDPOINTS.get(endpoint_key, ""),
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
+ }
+ print(LOG_PREFIXES["save"] + " " + json.dumps(feedback, ensure_ascii=False), flush=True)
+ record_event(
+ state,
+ stage,
+ f"入库{label}成功:{name}",
+ {"id": row_id, "endpoint": endpoint_key, "dryRun": dry_run},
+ )
def record_error(
@@ -385,6 +628,8 @@ def record_error(
response: Any | None = None,
exception: BaseException | None = None,
) -> None:
+ # Persist full-enough error context for post-run diagnosis, then immediately
+ # emit a short line the agent can show to the user.
item = {
"time": utc_now(),
"stage": stage,
@@ -430,65 +675,25 @@ def url_for_path(ctx: FlowContext, path: str) -> str:
return ctx.api_base.rstrip("/") + "/" + path.lstrip("/")
-def parse_headers(header_values: list[str], auth_token: str | None, authorization: str | None) -> dict[str, str]:
+def parse_headers(header_values: list[str]) -> dict[str, str]:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
- if auth_token:
- headers["Authorization"] = auth_token if auth_token.lower().startswith("bearer ") else f"Bearer {auth_token}"
- if authorization:
- headers["Authorization"] = authorization
for raw in header_values:
if ":" not in raw:
raise FlowError(f"header 格式错误,应为 Key: Value,实际:{raw}")
key, value = raw.split(":", 1)
- headers[key.strip()] = value.strip()
+ normalized = key.strip()
+ if normalized.lower() == "authorization":
+ continue
+ headers[normalized] = value.strip()
return headers
-def has_authorization(headers: dict[str, str]) -> bool:
- return any(key.lower() == "authorization" and str(value).strip() for key, value in headers.items())
-
-
def ssl_context(ctx: FlowContext) -> ssl.SSLContext | None:
if ctx.verify_ssl:
return None
return ssl._create_unverified_context()
-def auth_header_value(token: str) -> str:
- token = token.strip()
- return token if token.lower().startswith("bearer ") else f"Bearer {token}"
-
-
-def extract_access_token(data: Any) -> str | None:
- if isinstance(data, dict):
- for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
- value = data.get(key)
- if isinstance(value, str) and value.strip():
- return value.strip()
- for value in data.values():
- nested = extract_access_token(value)
- if nested:
- return nested
- elif isinstance(data, list):
- for value in data:
- nested = extract_access_token(value)
- if nested:
- return nested
- return None
-
-
-def redact_url(url: str) -> str:
- return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
-
-
-def build_login_url(ctx: FlowContext, path: str) -> str:
- base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else url_for_path(ctx, path)
- if ctx.login_password:
- separator = "&" if "?" in base else "?"
- base = f"{base}{separator}password={urllib.parse.quote(ctx.login_password, safe='')}"
- return base
-
-
def login_candidate_paths(ctx: FlowContext) -> list[str]:
paths = [
ctx.login_path or "/api/v1/auth/login/username",
@@ -506,6 +711,7 @@ def login_candidate_paths(ctx: FlowContext) -> list[str]:
def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force: bool = False) -> bool:
+ """Fetch a fresh token through the local auth package before business calls."""
if not ctx.auto_login:
return False
if ctx.dry_run:
@@ -515,7 +721,7 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
record_error(state, stage, "自动登录失败:缺少 login_username")
return False
if not force and ctx.login_attempt_count > 0:
- return has_authorization(ctx.headers)
+ return any(key.lower() == "authorization" and str(value).strip() for key, value in ctx.headers.items())
if force and ctx.login_attempt_count >= 2:
return False
@@ -525,8 +731,7 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
last_response = ""
last_exc: BaseException | None = None
for attempt, path in enumerate(login_candidate_paths(ctx), start=1):
- url = build_login_url(ctx, path)
- display_url = redact_url(url)
+ display_url = xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, path, ctx.login_password))
emit_operation_feedback(
state,
stage,
@@ -537,63 +742,39 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
attempt=attempt,
)
try:
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- headers = {
- "Content-Type": "application/json",
- "Accept": "application/json",
- }
- req = urllib.request.Request(url, data=data, headers=headers, method="POST")
- with urllib.request.urlopen(req, timeout=ctx.timeout, context=ssl_context(ctx)) as resp:
- raw = resp.read().decode("utf-8", errors="replace")
- parsed = parse_json_lenient(raw)
- token = extract_access_token(parsed)
- if not token:
- last_status = getattr(resp, "status", None)
- last_response = raw
- record_event(
- state,
- stage,
- "登录接口已响应但未找到 access_token",
- {"url": display_url, "status": last_status, "response": compact(raw, 1000)},
- )
- continue
- ctx.headers["Authorization"] = auth_header_value(token)
- record_event(
- state,
- stage,
- "自动登录成功,已写入 Authorization Bearer token",
- {
- "url": display_url,
- "status": getattr(resp, "status", None),
- "username": ctx.login_username,
- },
- )
- return True
- except urllib.error.HTTPError as exc:
- last_status = exc.code
- last_response = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
- last_exc = exc
- record_event(
- state,
- stage,
- "登录接口返回错误,准备尝试下一个候选登录路径",
- {"url": display_url, "status": exc.code, "response": compact(last_response, 1000)},
+ result = xdfx_auth.fetch_access_token(
+ api_base=ctx.api_base,
+ username=ctx.login_username,
+ password=ctx.login_password,
+ login_path=path,
+ timeout=ctx.timeout,
+ verify_ssl=ctx.verify_ssl,
)
- except Exception as exc: # noqa: BLE001 - auto-login must report every failure.
- last_exc = exc
- last_response = traceback.format_exc()
+ ctx.headers["Authorization"] = xdfx_auth.auth_header_value(result.token)
+ state["_tokenSource"] = "xdfx_auth"
record_event(
state,
stage,
- "登录接口调用异常,准备尝试下一个候选登录路径",
- {"url": display_url, "exceptionType": type(exc).__name__, "exception": str(exc)},
+ "启动阶段已通过 xdfx_auth 包获取 token,并写入 Authorization",
+ {"url": result.url, "status": result.status, "username": ctx.login_username},
+ )
+ return True
+ except xdfx_auth.AuthError as exc:
+ last_status = exc.status
+ last_response = exc.response
+ last_exc = exc
+ record_event(
+ state,
+ stage,
+ "xdfx_auth 获取 token 失败,准备尝试下一个候选登录路径",
+ {"url": exc.url, "status": exc.status, "response": compact(exc.response, 1000), "exception": str(exc)},
)
record_error(
state,
stage,
- "自动登录失败,未能获取 access_token",
- url=redact_url(build_login_url(ctx, ctx.login_path or "/api/v1/auth/login/username")),
+ "自动登录失败,未能通过 xdfx_auth 获取 access_token",
+ url=xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, ctx.login_path or "/api/v1/auth/login/username", ctx.login_password)),
status=last_status,
request=payload,
response=last_response,
@@ -612,6 +793,7 @@ def http_request_json_url(
endpoint_key: str | None = None,
allow_auto_login: bool = True,
) -> tuple[Any | None, str]:
+ """Call an HTTP JSON endpoint with logging, retries, and 401 recovery."""
method = method.upper()
if ctx.dry_run:
emit_operation_feedback(state, stage, method, url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
@@ -640,6 +822,7 @@ def http_request_json_url(
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
if exc.code == 401 and allow_auto_login and ctx.auto_login:
+ # Refresh once on 401, then replay the original request exactly.
refreshed = try_auto_login(ctx, state, f"{stage}/auto-login", force=True)
if refreshed:
record_event(
@@ -709,6 +892,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
emit_operation_feedback(state, stage, "POST", url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
record_event(state, stage, f"DRY RUN POST {url}", {"payload": payload})
if endpoint_key.startswith("save_"):
+ # Synthetic ids let downstream dry-run code exercise relation wiring.
synthetic_id = abs(hash(stage + stable_json(payload))) % 100000000
return {"id": synthetic_id, "dryRun": True}, json.dumps({"id": synthetic_id, "dryRun": True})
return {"dryRun": True}, json.dumps({"dryRun": True})
@@ -716,6 +900,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
def parse_json_lenient(text: str) -> Any | None:
+ """Parse normal JSON plus adjacent/SSE-like JSON objects."""
stripped = text.strip()
if not stripped:
return None
@@ -730,6 +915,7 @@ def parse_json_lenient(text: str) -> Any | None:
def parse_concatenated_json_objects(text: str) -> list[Any]:
+ """Read streams such as {"answer": "..."}{"answer": "..."} or data: lines."""
decoder = json.JSONDecoder()
objects: list[Any] = []
i = 0
@@ -766,6 +952,7 @@ def parse_concatenated_json_objects(text: str) -> list[Any]:
def model_text_from_response(parsed: Any, raw: str) -> tuple[str, list[Any]]:
+ """Flatten streamed model chunks into one text buffer."""
objects: list[Any]
if isinstance(parsed, list):
objects = parsed
@@ -824,6 +1011,7 @@ def extract_json_from_text(text: str) -> Any | None:
def extract_json_candidates_from_text(text: str) -> list[Any]:
+ """Collect every plausible JSON object/array from messy model text."""
cleaned = strip_think(text)
candidates: list[Any] = []
seen: set[str] = set()
@@ -860,6 +1048,7 @@ def extract_json_candidates_from_text(text: str) -> list[Any]:
def select_json_candidate(candidates: list[Any], endpoint_key: str, stage: str) -> Any | None:
+ """Pick the candidate whose keys best match the current step."""
if not candidates:
return None
scored = [(json_candidate_score(candidate, endpoint_key, stage), idx, candidate) for idx, candidate in enumerate(candidates)]
@@ -1010,6 +1199,7 @@ def extract_first_balanced(text: str, open_ch: str, close_ch: str) -> str | None
def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[str, list[Any], Any | None]:
+ """Call a model endpoint and retry only known empty/timeout/no-response cases."""
max_attempts = max(ctx.model_retries, 0) + 1
last_text = ""
last_objects: list[Any] = []
@@ -1048,6 +1238,11 @@ def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str,
def call_model_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[dict[str, Any], str, list[Any]]:
+ """Return the best structured JSON for a model step.
+
+ If the first parse fails, the second attempt includes an explicit repair
+ instruction so weak models can re-emit only the required JSON.
+ """
last_text = ""
last_objects: list[Any] = []
last_candidate_count = 0
@@ -1202,23 +1397,85 @@ def profile_info(account: dict[str, Any]) -> str:
def parse_index_list(value: Any) -> list[str]:
- if value is None:
- return []
- if isinstance(value, list):
- return [str(v).strip() for v in value if str(v).strip()]
- text = str(value)
- return [part.strip().strip("<>").strip() for part in re.split(r"[,,;;、\s]+", text) if part.strip()]
+ refs: list[str] = []
+
+ def add(raw: Any) -> None:
+ if raw is None:
+ return
+ if isinstance(raw, dict):
+ keys = (
+ "编号id",
+ "编号ID",
+ "用户编号id",
+ "用户编号ID",
+ "用户编号",
+ "账号编号",
+ "引用序号",
+ "索引",
+ "index",
+ "id",
+ "ids",
+ "accountIndex",
+ "accountIndexes",
+ "userIndex",
+ "userIndexes",
+ )
+ for key in keys:
+ if key in raw:
+ add(raw.get(key))
+ return
+ if isinstance(raw, (list, tuple, set)):
+ for item in raw:
+ add(item)
+ return
+ text = str(raw).strip()
+ if not text:
+ return
+ angle_refs = re.findall(r"<<\s*([^<>]+?)\s*>>", text)
+ if angle_refs:
+ for token in angle_refs:
+ add(token)
+ return
+ for part in re.split(r"[,,;;、\s]+", text):
+ token = part.strip().strip("<>").strip()
+ if token:
+ refs.append(token)
+
+ add(value)
+ deduped: list[str] = []
+ seen: set[str] = set()
+ for ref in refs:
+ if ref in seen:
+ continue
+ seen.add(ref)
+ deduped.append(ref)
+ return deduped
+
+
+def account_ref_numbers(account: dict[str, Any]) -> set[int]:
+ numbers: set[int] = set()
+ meta = account.get("metadata") if isinstance(account.get("metadata"), dict) else {}
+ for key in ("编号id", "编号ID", "用户编号id", "用户编号ID", "用户编号", "账号编号", "引用序号", "索引", "index", "accountIndex"):
+ for container in (account, meta):
+ number = as_int(container.get(key)) if isinstance(container, dict) else None
+ if number is not None:
+ numbers.add(number)
+ return numbers
def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, Any] | None:
+ """Resolve model references like <<3>>, index numbers, names, or account ids."""
if not accounts:
return None
text = str(ref).strip().strip("<>").strip()
number = as_int(text)
if number is not None:
for account in accounts:
- if any(as_int(account.get(key)) == number for key in ("引用序号", "索引", "index")):
+ if number in account_ref_numbers(account):
return account
+ # Step1 group extraction returns 编号/id values that refer to the
+ # previous zhanghao array index. Prefer exact index fields above, then
+ # fall back to zero-based and one-based array positions.
if 0 <= number < len(accounts):
return accounts[number]
if 1 <= number <= len(accounts):
@@ -1232,11 +1489,25 @@ def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, A
def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Normalize audience JSON whether the model used numbered fields or items[]."""
items: list[dict[str, Any]] = []
for i in range(1, 21):
group = first_present(data.get(f"群体{i}"), data.get(f"受众{i}"), data.get(f"targetAudience{i}"))
reason = first_present(data.get(f"理由{i}"), data.get(f"推荐理由{i}"), data.get(f"recReason{i}"))
- users = first_present(data.get(f"用户编号列表{i}"), data.get(f"用户列表{i}"), data.get(f"账号编号列表{i}"))
+ users = first_present(
+ data.get(f"用户编号列表{i}"),
+ data.get(f"编号id列表{i}"),
+ data.get(f"编号ID列表{i}"),
+ data.get(f"用户编号id列表{i}"),
+ data.get(f"用户编号ID列表{i}"),
+ data.get(f"用户列表{i}"),
+ data.get(f"账号编号列表{i}"),
+ data.get(f"索引列表{i}"),
+ data.get(f"编号{i}"),
+ data.get(f"id{i}"),
+ data.get(f"ids{i}"),
+ data.get(f"索引{i}"),
+ )
if group:
items.append(
{
@@ -1255,7 +1526,27 @@ def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
{
"targetAudience": str(group).strip(),
"recReason": str(first_present(item.get("理由"), item.get("recReason"), item.get("reason")) or "").strip(),
- "userIndexes": parse_index_list(first_present(item.get("用户编号列表"), item.get("users"), item.get("accounts"))),
+ "userIndexes": parse_index_list(
+ first_present(
+ item.get("用户编号列表"),
+ item.get("编号id列表"),
+ item.get("编号ID列表"),
+ item.get("用户编号id列表"),
+ item.get("用户编号ID列表"),
+ item.get("编号id"),
+ item.get("编号ID"),
+ item.get("用户编号id"),
+ item.get("用户编号ID"),
+ item.get("编号"),
+ item.get("索引"),
+ item.get("id"),
+ item.get("ids"),
+ item.get("users"),
+ item.get("accounts"),
+ item.get("userIndexes"),
+ item.get("accountIndexes"),
+ )
+ ),
"rank": i,
}
)
@@ -1272,6 +1563,7 @@ def audience_score(item: dict[str, Any]) -> tuple[int, int]:
def select_top_audiences(items: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
+ """Deduplicate audience names and keep the strongest candidates."""
unique: dict[str, dict[str, Any]] = {}
for item in items:
key = item.get("targetAudience", "")
@@ -1301,6 +1593,7 @@ def pathway_score(item: dict[str, Any]) -> tuple[int, int, int]:
def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Merge the same account found from different audiences before saving top 3."""
merged: dict[str, dict[str, Any]] = {}
for item in candidates:
account = item.get("account") or {}
@@ -1325,6 +1618,7 @@ def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str,
def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
+ """Normalize weak-point model output into a capped list."""
vulns: list[dict[str, str]] = []
for i in range(1, 21):
vuln = first_present(data.get(f"脆弱点{i}"), data.get(f"vulnerability{i}"), data.get(f"vuln{i}"))
@@ -1339,6 +1633,7 @@ def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
def extract_strategies(data: dict[str, Any], limit: int) -> list[dict[str, Any]]:
+ """Normalize strategy output and preserve the modal text for later mapping."""
strategies: list[dict[str, Any]] = []
for i in range(1, 21):
title = first_present(data.get(f"信息标题{i}"), data.get(f"主题{i}"), data.get(f"topic{i}"))
@@ -1395,11 +1690,29 @@ def response_id(response: Any) -> int | None:
return None
+def response_rows(response: Any) -> list[Any]:
+ """Extract list rows from common CRUD response shapes."""
+ if isinstance(response, list):
+ return response
+ if not isinstance(response, dict):
+ return []
+ for key in ("data", "rows", "items", "records", "list"):
+ value = response.get(key)
+ if isinstance(value, list):
+ return value
+ rows = response_rows(value)
+ if rows:
+ return rows
+ return []
+
+
def save_with_id(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[int | None, Any | None]:
response, _ = http_post_json(ctx, state, endpoint_key, payload, stage)
rid = response_id(response)
if rid is None:
- record_error(state, stage, "保存接口未返回 id,视为失败", request=payload, response=response)
+ record_error(state, stage, "保存接口未返回 ID,视为失败", request=payload, response=response)
+ else:
+ emit_save_feedback(state, stage, endpoint_key, payload, rid, dry_run=ctx.dry_run)
return rid, response
@@ -1429,6 +1742,7 @@ def parse_step_flags(raw: Any) -> dict[str, Any]:
def mark_step_status(ctx: FlowContext, state: dict[str, Any], links: list[str], stage: str) -> None:
+ """Update the frontend step bar without overwriting unrelated step flags."""
if not ctx.sync_step_status:
record_event(state, stage, "跳过 task-action.step 状态同步")
return
@@ -1477,12 +1791,126 @@ def strategy_platform(value: Any) -> Any:
return platform_to_number(value) or 1
-def strategy_model_type(value: Any) -> str:
- # Frontend defaults to text model type "1" when no modal type is selected.
- raw = first_present(value, "1")
- if isinstance(raw, list):
- return ",".join(str(v) for v in raw if str(v).strip()) or "1"
- return str(raw or "1")
+def choice_tokens(value: Any) -> list[Any]:
+ """Split frontend choice values from arrays, JSON strings, and mixed delimiters."""
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return choice_tokens(
+ first_present(value.get("value"), value.get("dictValue"), value.get("label"), value.get("dictLabel"))
+ )
+ if isinstance(value, (list, tuple, set)):
+ tokens: list[Any] = []
+ for item in value:
+ tokens.extend(choice_tokens(item))
+ return tokens
+ text = str(value).strip()
+ if not text:
+ return []
+ if text[0] in "[{":
+ try:
+ parsed = json.loads(text)
+ if parsed != value:
+ return choice_tokens(parsed)
+ except Exception:
+ pass
+ return [part.strip() for part in re.split(r"[,,、;/|]+", text) if part.strip()]
+
+
+def dict_choices(
+ ctx: FlowContext,
+ state: dict[str, Any],
+ dict_type: str,
+ stage: str,
+) -> list[dict[str, Any]]:
+ """Fetch and cache frontend dictionary choices such as modelType."""
+ cache = state.setdefault("_dictChoiceCache", {})
+ if dict_type in cache:
+ cached = cache[dict_type]
+ return cached if isinstance(cached, list) else []
+ if ctx.dry_run:
+ cache[dict_type] = []
+ return []
+
+ query = urllib.parse.urlencode({"filter": f"dictType||$eq||{dict_type}", "limit": 999, "page": 1})
+ url = f"{url_for(ctx, 'dict_data')}?{query}"
+ response, _ = http_request_json_url(ctx, state, "GET", url, None, stage, endpoint_key="dict_data")
+ choices: list[dict[str, Any]] = []
+ for row in response_rows(response):
+ if not isinstance(row, dict):
+ continue
+ label = str(first_present(row.get("dictLabel"), row.get("label"), "") or "").strip()
+ raw_value = first_present(row.get("dictValue"), row.get("value"))
+ if not label or raw_value is None:
+ continue
+ number = as_int(raw_value)
+ choices.append({"label": label, "value": number if number is not None else str(raw_value).strip()})
+ for alias in choice_tokens(row.get("alias")):
+ alias_label = str(alias).strip()
+ if alias_label:
+ choices.append({"label": alias_label, "value": number if number is not None else str(raw_value).strip()})
+ cache[dict_type] = choices
+ return choices
+
+
+def choice_value_by_label(choices: list[dict[str, Any]] | None, label: str) -> Any | None:
+ needle = re.sub(r"\s+", "", str(label or "").lower())
+ if not needle:
+ return None
+ for item in choices or []:
+ item_label = re.sub(r"\s+", "", str(item.get("label") or "").lower())
+ if item_label and (needle == item_label or needle in item_label or item_label in needle):
+ return item.get("value")
+ return None
+
+
+def model_type_token_value(token: Any, choices: list[dict[str, Any]] | None = None) -> Any:
+ """Convert modal labels like 图片/视频 into frontend dictionary values."""
+ number = as_int(token)
+ if number is not None:
+ return number
+
+ text = str(token or "").strip()
+ lower = text.lower()
+ exact = choice_value_by_label(choices, text)
+ if exact is not None:
+ return exact
+
+ label = ""
+ fallback_value = 2
+ if "视" in text or "video" in lower:
+ label, fallback_value = "视频", 3
+ elif "图" in text or "image" in lower or "picture" in lower or "photo" in lower:
+ label, fallback_value = "图片", 2
+ elif "音" in text or "audio" in lower or "sound" in lower:
+ label, fallback_value = "音频", 4
+ elif "发文" in text or "文本" in text or "文字" in text or "text" in lower:
+ label, fallback_value = "文本", 1
+
+ if label:
+ matched = choice_value_by_label(choices, label)
+ if matched is not None:
+ return matched
+ return fallback_value
+
+ # Mirrors the frontend helper's unknown-token default.
+ return 2
+
+
+def strategy_model_type(value: Any, choices: list[dict[str, Any]] | None = None) -> str:
+ """Return the comma-separated value string expected by task-action-message."""
+ tokens = choice_tokens(value)
+ if not tokens:
+ # Frontend save paths fall back to "1" when no modal type is selected.
+ return "1"
+ values: list[str] = []
+ seen: set[str] = set()
+ for token in tokens:
+ normalized = str(model_type_token_value(token, choices)).strip()
+ if normalized and normalized not in seen:
+ values.append(normalized)
+ seen.add(normalized)
+ return ",".join(values) or "1"
def strategy_language(value: Any) -> int:
@@ -1501,11 +1929,28 @@ def save_strategy_details(
*,
audience: bool,
) -> None:
- platform = strategy_platform(first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})))
+ """Save all frontend-readable strategy child rows.
+
+ The strategy table and arrangement page do not read only the main strategy
+ row; they also expect channel, narrate, message, and relation rows.
+ """
+ platform = strategy_platform(
+ first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
+ )
+ account_type = str(
+ first_present(
+ strategy.get("accountType"),
+ item.get("accountType"),
+ item.get("type"),
+ "目标受众账号" if audience else "关键传播节点账号",
+ )
+ or ""
+ ).strip()
persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
title = str(strategy.get("title") or "").strip()
+ model_type_choices = dict_choices(ctx, state, "modelType", stage + "/dict-modelType")
channel_id, _ = save_with_id(
ctx,
@@ -1516,6 +1961,7 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"platform": platform,
+ "accountType": account_type,
"personaJson": json.dumps([{"persona": persona, "size": persona_size}], ensure_ascii=False),
},
stage + "/save-channel",
@@ -1531,7 +1977,10 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"channelId": channel_id,
- "modelType": strategy_model_type(first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType"))),
+ "modelType": strategy_model_type(
+ first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType")),
+ model_type_choices,
+ ),
"language": strategy_language(first_present(strategy.get("language"), strategy.get("语种"))),
"respondingCount": responding_count,
"messageCount": responding_count,
@@ -1564,7 +2013,39 @@ def object_for_audience(audience: dict[str, Any]) -> dict[str, Any]:
}
+def downstream_limit(ctx: FlowContext, *, audience: bool) -> int:
+ """Limit expensive downstream analysis while keeping 3 upstream rows stored."""
+ raw = ctx.downstream_audiences if audience else ctx.downstream_pathways
+ return max(1, raw)
+
+
+def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, audience: bool) -> list[dict[str, Any]]:
+ """Select the 1-2 best stored rows for vulnerability/strategy generation."""
+ source_key = "topAudiences" if audience else "topPathways"
+ id_key = "designId" if audience else "pathwayAccountId"
+ selected_key = "selectedDownstreamAudiences" if audience else "selectedDownstreamPathways"
+ items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)]
+ limit = downstream_limit(ctx, audience=audience)
+ selected = items[:limit]
+ state[selected_key] = [
+ {
+ "id": item.get(id_key),
+ "name": item.get("targetAudience") if audience else item.get("accountName"),
+ }
+ for item in selected
+ ]
+ if items:
+ record_event(
+ state,
+ stage,
+ f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}",
+ {"limit": limit, "selected": state[selected_key]},
+ )
+ return selected
+
+
def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze audiences, save top 3, and attach any referenced accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step1-audience"
if state.get("topAudiences") and not ctx.dry_run:
@@ -1582,12 +2063,15 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
state["audienceRawText"] = text
+ # The first model call may return raw text plus embedded account objects.
accounts = accounts_from_response(parsed, objects, text)
state["audienceAccounts"] = accounts
+ # The second model call converts the long audience analysis into numbered
+ # groups that can be saved as task-action-design rows.
group_payload = {
"mubiao": state["mubiao"],
- "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
"zhanghao": accounts,
"history": [],
"stream": True,
@@ -1602,6 +2086,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
top = select_top_audiences(candidates, ctx.max_audiences)
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the audience first; account rows and downstream analyses depend
+ # on the returned designId.
design_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
@@ -1614,6 +2100,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
continue
item = {**item, "designId": design_id, "saveResponse": resp, "accounts": [], "savedAccountIds": []}
for ref in item.get("userIndexes") or []:
+ # Model references are intentionally fuzzy, so resolve them against
+ # both explicit indexes and account names/ids.
account = resolve_account_ref(ref, accounts)
if account is None:
record_error(state, stage + "/save-design-account", f"用户编号 {ref} 未匹配到候选账号")
@@ -1645,6 +2133,7 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze key pathway accounts for saved audiences and store top 3."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step2-pathway"
audiences = [a for a in state.get("topAudiences") or [] if a.get("designId")]
@@ -1656,6 +2145,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
return
all_candidates: list[dict[str, Any]] = []
for audience in audiences:
+ # Analyze pathway candidates in the context of each saved audience.
payload = {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。",
"otherinfo": {
@@ -1673,6 +2163,8 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
accounts = accounts_from_response(parsed, objects, text)
+ # Ask the model to point back to candidate accounts, then resolve those
+ # references to concrete account objects.
spread_payload = {
"mubiao": state["mubiao"],
"user_message": "分析其中关键信息。请从候选账号中抽取核心传播节点,字段必须是:账号1、理由1、账号2、理由2 ...,最多输出5个。账号字段优先返回候选数组的引用编号或索引,只返回JSON。",
@@ -1707,6 +2199,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways]
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the account row before writing the design-pathway relation.
account = item["account"]
aid = account_id(account)
name = account_name(account)
@@ -1757,12 +2250,14 @@ def run_step4(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate and save weak points for selected audiences or pathway accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step3-audience-vulns" if audience else "step4-pathway-vulns"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
output_key = "audienceVulns" if audience else "pathwayVulns"
state.setdefault(output_key, {})
for item in items:
+ # owner_id is designId for audiences and pathwayAccountId for accounts.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
if not owner_id:
continue
@@ -1789,6 +2284,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) ->
vulns = extract_vulns(data, ctx.max_vulns)
saved: list[dict[str, Any]] = []
for vuln in vulns:
+ # The two weak-point tables have nearly identical payloads but
+ # different foreign-key names.
if audience:
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
@@ -1835,14 +2332,16 @@ def run_step6(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate strategies from saved weak points and persist frontend child rows."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step5-audience-strategy" if audience else "step6-pathway-strategy"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {}
output_key = "audienceStrategies" if audience else "pathwayStrategies"
state.setdefault(output_key, [])
existing_topics = {s.get("topic") for s in state[output_key] if isinstance(s, dict)}
for item in items:
+ # Strategies are only meaningful after the matching weak points exist.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
vulns = vulns_by_owner.get(str(owner_id)) or []
vuln_texts = [v.get("vulnerabilities") for v in vulns if v.get("vulnerabilities")]
@@ -1876,16 +2375,20 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
continue
if topic in existing_topics:
continue
+ # Save the main row first; channel/narrate/message/relation rows all
+ # require the returned strategy id.
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
"targetAudience": target,
"accountType": account_type,
"topic": topic,
- "strategyTitle": "",
+ "strategyTitle": str(strategy.get("title") or "").strip(),
}
strategy_id, resp = save_with_id(ctx, state, "save_strategy", save_payload, stage + "/save")
if strategy_id is not None:
+ # Frontend strategy pages read these child tables, so a strategy
+ # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience)
existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp})
@@ -1914,13 +2417,14 @@ STEP_LABELS = {
def state_counts(state: dict[str, Any]) -> dict[str, int]:
+ """Summarize stored objects for step feedback."""
return {
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceVulns": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
- "pathwayVulns": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "受众脆弱点数量": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
+ "传播节点脆弱点数量": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
}
@@ -1932,23 +2436,26 @@ def emit_step_feedback(
status: str | None = None,
note: str = "",
) -> None:
+ # One feedback line per step keeps the single-skill and split-skill versions
+ # equally transparent to the user.
total_errors = len(state.get("errors") or [])
new_errors = max(0, total_errors - before_error_count)
final_status = status or ("partial" if new_errors else "ok")
payload = {
- "type": "STEP_FEEDBACK",
- "step": step,
- "label": STEP_LABELS.get(step, step),
- "status": final_status,
- "note": note,
- "newErrors": new_errors,
- "totalErrors": total_errors,
- "counts": state_counts(state),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "updatedAt": state.get("updatedAt"),
+ "日志类型": "步骤反馈",
+ "步骤": STEP_NUMBER_LABELS.get(step, step),
+ "步骤名称": STEP_LABELS.get(step, step),
+ "状态": STATUS_LABELS.get(final_status, final_status),
+ "执行状态": status_label(final_status),
+ "备注": note,
+ "本步骤新增错误数": new_errors,
+ "累计错误数": total_errors,
+ "累计入库数量": state_counts(state),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "更新时间": state.get("updatedAt"),
}
- print("STEP_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["step"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def finish_step(
@@ -1960,6 +2467,8 @@ def finish_step(
status: str | None = None,
note: str = "",
) -> None:
+ # Always persist before emitting step feedback so the user can inspect files
+ # immediately after a reported step completes.
state["updatedAt"] = utc_now()
save_json_file(ctx.state_path, state)
if ctx.summary_path:
@@ -1980,6 +2489,11 @@ def skip_step_with_feedback(ctx: FlowContext, state: dict[str, Any], step: str,
def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Legacy full runner.
+
+ The wrapper script is preferred for real-time logs, but this remains useful
+ for dry-runs and unattended local troubleshooting.
+ """
run_step_with_feedback(ctx, state, "step1")
run_step_with_feedback(ctx, state, "step2")
run_step_with_feedback(ctx, state, "step3")
@@ -1995,6 +2509,7 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
def write_summary(path: Path, state: dict[str, Any]) -> None:
+ """Write a human-readable checkpoint beside the machine state JSON."""
audience_vuln_count = sum(len(v) for v in (state.get("audienceVulns") or {}).values())
pathway_vuln_count = sum(len(v) for v in (state.get("pathwayVulns") or {}).values())
lines = [
@@ -2005,6 +2520,8 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
f"- actionId:{state.get('actionId')}",
f"- 受众入库:{len(state.get('topAudiences') or [])}",
f"- 传播节点入库:{len(state.get('topPathways') or [])}",
+ f"- 下游受众选择:{len(state.get('selectedDownstreamAudiences') or [])}",
+ f"- 下游传播节点选择:{len(state.get('selectedDownstreamPathways') or [])}",
f"- 受众脆弱点入库:{audience_vuln_count}",
f"- 传播节点脆弱点入库:{pathway_vuln_count}",
f"- 受众策略入库:{len(state.get('audienceStrategies') or [])}",
@@ -2021,9 +2538,9 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
if state.get("errors"):
lines += ["", "## 失败明细"]
for err in state["errors"]:
- lines.append(f"- [{err.get('stage')}] {err.get('message')} status={err.get('status')} url={err.get('url')}")
+ lines.append(f"- [{stage_label(err.get('stage'))}] {err.get('message')} 状态码={err.get('status')} 接口={err.get('url')}")
if err.get("response"):
- lines.append(f" - response: {err.get('response')}")
+ lines.append(f" - 响应摘要:{err.get('response')}")
text = "\n".join(lines) + "\n"
try:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -2034,34 +2551,34 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
def build_arg_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(description="Run xdfx analysis/storage flow.")
+ parser = argparse.ArgumentParser(description="运行 xdfx 分析与入库流程。")
parser.add_argument("--mode", choices=["full", *STEP_FUNCS.keys()], default="full")
- parser.add_argument("--input", help="JSON config file. Keys: api_base, taskId, actionId, mubiao, direction, platform, headers.")
- parser.add_argument("--state", default="xdfx-flow-state.json", help="State JSON path for resume and step handoff.")
- parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown summary output path.")
+ parser.add_argument("--input", help="JSON 配置文件。常用字段:api_base、taskId、actionId、mubiao、direction、platform、headers。")
+ parser.add_argument("--state", default="xdfx-flow-state.json", help="用于断点续跑和步骤交接的状态 JSON 文件路径。")
+ parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown 执行摘要输出路径。")
parser.add_argument("--api-base")
parser.add_argument("--task-id")
parser.add_argument("--action-id")
parser.add_argument("--mubiao")
parser.add_argument("--direction", default=None)
parser.add_argument("--platform", default=None)
- parser.add_argument("--auth-token", default=os.getenv("XDFX_AUTH_TOKEN"))
- parser.add_argument("--authorization", default=os.getenv("AUTHORIZATION"))
- parser.add_argument("--header", action="append", default=[], help="Extra HTTP header, format 'Key: Value'.")
- parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="Username for automatic login when Authorization is missing or stale.")
- parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="Password query parameter for automatic username login.")
- parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="Login endpoint path or absolute URL.")
- parser.add_argument("--no-auto-login", action="store_true", help="Disable automatic username login and 401 token refresh.")
- parser.add_argument("--verify-ssl", action="store_true", help="Verify HTTPS certificates. Default is disabled for internal/UAT certificates.")
+ parser.add_argument("--header", action="append", default=[], help="附加非鉴权 HTTP 请求头,格式为 'Key: Value';Authorization 会被忽略。")
+ parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="xdfx_auth 启动阶段自动登录使用的用户名。")
+ parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="用户名登录接口的 password 查询参数。")
+ parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="登录接口路径或完整 URL。")
+ parser.add_argument("--no-auto-login", action="store_true", help="关闭 xdfx_auth 自动登录和 401 刷新。")
+ parser.add_argument("--verify-ssl", action="store_true", help="校验 HTTPS 证书。默认不校验,以兼容内网/UAT 自签证书。")
parser.add_argument("--timeout", type=float, default=90)
parser.add_argument("--retries", type=int, default=2)
parser.add_argument("--dry-run", action="store_true")
- parser.add_argument("--no-step-status", action="store_true", help="Do not update task/task-action.step after completed steps.")
- parser.add_argument("--model-retries", type=int, default=1, help="Retry model/stream endpoints when the first response is empty, timeout-like, or says the model did not respond.")
+ parser.add_argument("--no-step-status", action="store_true", help="步骤完成后不更新 task/task-action.step。")
+ parser.add_argument("--model-retries", type=int, default=1, help="模型/流式接口首次空响应、超时或提示模型无响应时的重试次数。")
parser.add_argument("--max-audiences", type=int, default=3)
parser.add_argument("--max-pathways", type=int, default=3)
parser.add_argument("--max-vulns", type=int, default=3)
parser.add_argument("--max-strategies", type=int, default=3)
+ parser.add_argument("--downstream-audiences", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库受众数量,默认 2。")
+ parser.add_argument("--downstream-pathways", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库传播节点数量,默认 2。")
return parser
@@ -2070,6 +2587,8 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
state_path = Path(args.state)
state = default_state()
+ # Load previous state first, then overlay input JSON and finally CLI flags.
+ # That ordering makes resume safe while still allowing explicit overrides.
if state_path.exists():
loaded = load_json_file(state_path)
if isinstance(loaded, dict):
@@ -2081,9 +2600,7 @@ def main(argv: list[str] | None = None) -> int:
merge_config_into_state(state, config)
input_headers = config.get("headers")
if isinstance(input_headers, dict):
- args.header.extend(f"{k}: {v}" for k, v in input_headers.items())
- args.auth_token = args.auth_token or config.get("auth_token")
- args.authorization = args.authorization or config.get("authorization")
+ args.header.extend(f"{k}: {v}" for k, v in input_headers.items() if str(k).lower() != "authorization")
if config.get("login_username") is not None:
args.login_username = str(config.get("login_username") or "")
if config.get("login_password") is not None:
@@ -2094,6 +2611,10 @@ def main(argv: list[str] | None = None) -> int:
args.no_auto_login = True
if config.get("verify_ssl") is not None:
args.verify_ssl = bool(config.get("verify_ssl"))
+ if config.get("downstream_audiences") is not None:
+ args.downstream_audiences = as_int(config.get("downstream_audiences")) or args.downstream_audiences
+ if config.get("downstream_pathways") is not None:
+ args.downstream_pathways = as_int(config.get("downstream_pathways")) or args.downstream_pathways
cli_config = {
"api_base": args.api_base,
@@ -2105,7 +2626,8 @@ def main(argv: list[str] | None = None) -> int:
}
merge_config_into_state(state, {k: v for k, v in cli_config.items() if v is not None})
api_base = state.get("api_base") or "https://ch1.b.uat.4.cn"
- headers = parse_headers(args.header, args.auth_token, args.authorization)
+ headers = parse_headers(args.header)
+ # ctx owns runtime knobs; state owns business data and resumable ids.
ctx = FlowContext(
api_base=api_base,
headers=headers,
@@ -2118,6 +2640,8 @@ def main(argv: list[str] | None = None) -> int:
max_pathways=max(args.max_pathways, 1),
max_vulns=max(args.max_vulns, 1),
max_strategies=max(args.max_strategies, 1),
+ downstream_audiences=max(args.downstream_audiences, 1),
+ downstream_pathways=max(args.downstream_pathways, 1),
sync_step_status=not args.no_step_status,
model_retries=max(args.model_retries, 0),
verify_ssl=bool(args.verify_ssl),
@@ -2131,11 +2655,17 @@ def main(argv: list[str] | None = None) -> int:
state["_summaryPath"] = str(ctx.summary_path) if ctx.summary_path else ""
state["_autoLogin"] = ctx.auto_login
state["_verifySsl"] = ctx.verify_ssl
+ state["_downstreamLimits"] = {
+ "audiences": ctx.downstream_audiences,
+ "pathways": ctx.downstream_pathways,
+ }
record_event(state, "runner", f"start mode={args.mode} dry_run={ctx.dry_run}")
try:
require_context(state, "taskId", "actionId", "mubiao")
- if ctx.auto_login and not has_authorization(ctx.headers):
- try_auto_login(ctx, state, "runner/auto-login")
+ if ctx.auto_login and not ctx.dry_run:
+ # Always acquire a token through the imported auth package before the first business call.
+ if not try_auto_login(ctx, state, "runner/initial-login", force=True):
+ raise FlowError("启动阶段获取 token 失败,已停止执行,避免无 token 调用业务接口")
if args.mode == "full":
run_full(ctx, state)
else:
@@ -2145,13 +2675,14 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(f"ERROR: {exc}", file=sys.stderr)
+ print(f"错误:{exc}", file=sys.stderr)
return 2
except Exception as exc: # noqa: BLE001
record_error(state, "runner", "未捕获异常", response=traceback.format_exc(), exception=exc)
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
+ print("未捕获异常堆栈:", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
return 1
@@ -2159,20 +2690,23 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(json.dumps(
- {
- "ok": True,
- "mode": args.mode,
- "state": str(state_path),
- "summary": str(ctx.summary_path) if ctx.summary_path else "",
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
- "errors": len(state.get("errors") or []),
- },
- ensure_ascii=False,
- ))
+ # Final JSON is intentionally compact; detailed per-step status has already
+ # been emitted as Chinese step feedback.
+ final_payload = {
+ "日志类型": "执行完成",
+ "是否成功": "是",
+ "执行模式": mode_label(args.mode),
+ "状态文件": str(state_path),
+ "摘要文件": str(ctx.summary_path) if ctx.summary_path else "",
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "已选择受众数": len(state.get("selectedDownstreamAudiences") or []),
+ "已选择传播节点数": len(state.get("selectedDownstreamPathways") or []),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
+ "错误数": len(state.get("errors") or []),
+ }
+ print(LOG_PREFIXES["done"] + " " + json.dumps(final_payload, ensure_ascii=False))
return 0
diff --git a/skills-data/xdfx-step2-pathway/SKILL.md b/skills-data/xdfx-step2-pathway/SKILL.md
index 0dbc754c..9c396ebb 100644
--- a/skills-data/xdfx-step2-pathway/SKILL.md
+++ b/skills-data/xdfx-step2-pathway/SKILL.md
@@ -13,27 +13,37 @@ description: xdfx 拆分流程 step2,基于受众分析关键传播节点,
python scripts/xdfx_flow.py --mode step2 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
-鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
+鉴权和证书由脚本兜底处理:脚本启动时先 `import scripts/xdfx_auth.py`,自动用 `admin / ch@user` 获取 `access_token`,并在第一次业务接口调用前写入内部 Authorization;不要通过命令行传 token。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
- 对已入库受众逐个分析关键传播节点。
- 自动解析候选账号和传播节点 JSON。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
-- 跨受众合并去重,选择全局最优 3 个传播节点。
+- 跨受众合并去重,选择并入库全局最优 3 个传播节点。
- 保存传播节点。
- 保存传播节点与受众关系。
- 同步前端步骤状态 `step-analysis-keypoint` 与 `step-analysis-weakpoint`。
- 把 `pathwayAccountId`、关联 `designIds`、错误明细写入 state。
-关系保存失败不阻断后续流程,但必须报告给用户。
+关系保存失败会写入错误;智能体必须按失败重试规则先重跑 Step2,不能直接跳到 Step3。
+
+## 失败重试规则
+
+- 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前 Step2 命令。
+- 重试前必须告诉用户失败阶段、接口 URL、状态码、响应摘要和“正在重试 Step2”,不能静默重试。
+- Step2 未重试成功前,不允许继续 Step3;不要因为 Step2 失败就跳过关键传播节点分析。
+- Step2 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
## 步骤反馈
-命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step2 的完成状态、当前传播节点入库数量和新增错误数,再决定是否继续下一步。
+命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step2 的完成状态、当前传播节点入库数量和新增错误数,再决定是否继续下一步。
-执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
+执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。
-如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的传播节点和返回 ID。
+
+如果出现 `错误反馈 {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+
+如果出现 `JSON解析复核 {...}`,必须查看 `响应片段`,判断是否能识别传播节点 JSON;无法确认时重跑 Step2。
-如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别传播节点 JSON;无法确认时重跑 Step2。
diff --git a/skills-data/xdfx-step2-pathway/scripts/xdfx_auth.py b/skills-data/xdfx-step2-pathway/scripts/xdfx_auth.py
new file mode 100644
index 00000000..fe46f809
--- /dev/null
+++ b/skills-data/xdfx-step2-pathway/scripts/xdfx_auth.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""Authentication helper for xdfx skill scripts.
+
+The caller should not pass tokens through command-line arguments. This module
+owns the fixed admin login flow and returns a Bearer token before business
+interfaces are called.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import ssl
+import urllib.error
+import urllib.parse
+import urllib.request
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass
+class AuthResult:
+ token: str
+ url: str
+ status: int | None
+ raw: str
+
+
+class AuthError(RuntimeError):
+ def __init__(
+ self,
+ message: str,
+ *,
+ url: str,
+ status: int | None = None,
+ response: str = "",
+ exception: BaseException | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.url = url
+ self.status = status
+ self.response = response
+ self.exception = exception
+
+
+def auth_header_value(token: str) -> str:
+ token = token.strip()
+ return token if token.lower().startswith("bearer ") else f"Bearer {token}"
+
+
+def redact_url(url: str) -> str:
+ return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
+
+
+def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
+ if verify_ssl:
+ return None
+ return ssl._create_unverified_context()
+
+
+def build_login_url(api_base: str, path: str, password: str) -> str:
+ base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
+ if password:
+ separator = "&" if "?" in base else "?"
+ base = f"{base}{separator}password={urllib.parse.quote(password, safe='')}"
+ return base
+
+
+def extract_access_token(data: Any) -> str | None:
+ if isinstance(data, dict):
+ for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
+ value = data.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ for value in data.values():
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ elif isinstance(data, list):
+ for value in data:
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ return None
+
+
+def parse_json_loose(text: str) -> Any:
+ try:
+ return json.loads(text)
+ except Exception:
+ pass
+ match = re.search(r"[\[{]", text or "")
+ if not match:
+ return {}
+ start = match.start()
+ for end in range(len(text), start, -1):
+ try:
+ return json.loads(text[start:end])
+ except Exception:
+ continue
+ return {}
+
+
+def fetch_access_token(
+ *,
+ api_base: str,
+ username: str = "admin",
+ password: str = "ch@user",
+ login_path: str = "/api/v1/auth/login/username",
+ timeout: float = 90,
+ verify_ssl: bool = False,
+) -> AuthResult:
+ """Login once and return the extracted access token."""
+ url = build_login_url(api_base, login_path, password)
+ payload = {"username": username}
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ parsed = parse_json_loose(raw)
+ token = extract_access_token(parsed)
+ if not token:
+ raise AuthError(
+ "登录接口已响应但未找到 access_token",
+ url=redact_url(url),
+ status=getattr(resp, "status", None),
+ response=raw,
+ )
+ return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
+ raise AuthError(
+ "登录接口返回错误",
+ url=redact_url(url),
+ status=exc.code,
+ response=body,
+ exception=exc,
+ ) from exc
+ except AuthError:
+ raise
+ except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
+ raise AuthError(
+ "登录接口调用异常",
+ url=redact_url(url),
+ response=str(exc),
+ exception=exc,
+ ) from exc
diff --git a/skills-data/xdfx-step2-pathway/scripts/xdfx_flow.py b/skills-data/xdfx-step2-pathway/scripts/xdfx_flow.py
index 52880e82..5f405cc8 100644
--- a/skills-data/xdfx-step2-pathway/scripts/xdfx_flow.py
+++ b/skills-data/xdfx-step2-pathway/scripts/xdfx_flow.py
@@ -26,7 +26,10 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
+import xdfx_auth
+
+# Keep every backend path in one table so endpoint drift is easy to audit.
ENDPOINTS = {
"aiuser": "/gpt/chat/chat_aiuser",
"group": "/gpt/chat/chat_qianqian_group_chat",
@@ -44,10 +47,12 @@ ENDPOINTS = {
"save_narrate": "/api/base/base-narrate",
"save_message": "/api/task/task-action-message",
"save_strategy_relation": "/api/task/task-action-design-strategy-relation",
+ "dict_data": "/api/sys/sys-dict-data",
}
+# Human-readable names are used only in the Chinese feedback logs.
ENDPOINT_LABELS = {
- "login": "登录获取访问 token",
+ "login": "登录获取访问令牌",
"aiuser": "调用候选账号/受众分析模型接口",
"group": "调用受众目标结构化抽取接口",
"spread": "调用关键传播节点结构化抽取接口",
@@ -64,8 +69,86 @@ ENDPOINT_LABELS = {
"save_narrate": "保存策略信息主题",
"save_message": "保存策略消息配置",
"save_strategy_relation": "保存策略与受众/传播节点关系",
+ "dict_data": "读取前端字典数据",
}
+LOG_PREFIXES = {
+ "operation": "操作反馈",
+ "error": "错误反馈",
+ "json_review": "JSON解析复核",
+ "save": "入库反馈",
+ "step": "步骤反馈",
+ "done": "执行完成",
+}
+
+STATUS_LABELS = {
+ "ok": "成功",
+ "partial": "部分完成",
+ "skipped": "已跳过",
+}
+
+STEP_NUMBER_LABELS = {
+ "step1": "第一步",
+ "step2": "第二步",
+ "step3": "第三步",
+ "step4": "第四步",
+ "step5": "第五步",
+ "step6": "第六步",
+}
+
+HTTP_METHOD_LABELS = {
+ "GET": "读取(GET)",
+ "POST": "提交(POST)",
+ "PUT": "更新(PUT)",
+ "PATCH": "局部更新(PATCH)",
+ "DELETE": "删除(DELETE)",
+}
+
+STAGE_SEGMENT_LABELS = {
+ "analyze": "模型分析",
+ "extract": "结构化抽取",
+ "save": "保存数据",
+ "read": "读取数据",
+ "status": "同步状态",
+}
+
+REQUEST_FIELD_LABELS = {
+ "history": "历史消息",
+ "max_tokens": "最大生成令牌数",
+ "model_name": "模型名称",
+ "otherinfo": "其他信息",
+ "prompt_name": "提示词名称",
+ "stream": "是否流式",
+ "temperature": "温度",
+ "usermessage": "用户消息",
+ "user_message": "用户消息",
+ "mubiao": "行动目标",
+ "zhanghao": "账号列表",
+ "taskId": "任务ID",
+ "actionId": "行动ID",
+ "taskActionId": "行动ID",
+ "designId": "受众ID",
+ "pathwayId": "传播节点ID",
+ "strategyId": "策略ID",
+ "accountId": "账号ID",
+ "accountName": "账号名称",
+ "accountType": "账号类型",
+ "targetAudience": "受众目标",
+ "vulnerabilities": "脆弱点",
+ "topic": "信息标题",
+ "narrate": "叙事主题",
+ "platform": "平台",
+ "persona": "发布人设",
+ "modelType": "信息形式",
+ "quantity": "数量",
+ "publishTime": "发布时间段",
+ "reason": "理由",
+ "ids": "ID列表",
+ "id": "ID",
+ "step": "步骤状态",
+}
+
+# Platform values mirror the frontend dictionaries used by xdfx strategy pages.
PLATFORM_MAP = {
"facebook": 1,
"fb": 1,
@@ -78,6 +161,8 @@ PLATFORM_MAP = {
"yt": 4,
}
+# The model service sometimes returns a successful HTTP response with these
+# strings instead of useful content. Treat them like transient failures.
MODEL_RETRY_MARKERS = (
"y模型没有响应",
"模型没有响应",
@@ -118,6 +203,12 @@ class FlowError(RuntimeError):
@dataclass
class FlowContext:
+ """Runtime options shared by every step.
+
+ Keep this object boring and serializable-ish: it is passed through every
+ helper, while durable progress belongs in the state JSON file.
+ """
+
api_base: str
headers: dict[str, str]
timeout: float
@@ -129,6 +220,8 @@ class FlowContext:
max_pathways: int = 3
max_vulns: int = 3
max_strategies: int = 3
+ downstream_audiences: int = 2
+ downstream_pathways: int = 2
sync_step_status: bool = True
model_retries: int = 1
verify_ssl: bool = False
@@ -153,7 +246,7 @@ def compact(value: Any, limit: int = 1400) -> str:
text = repr(value)
if len(text) <= limit:
return text
- return text[:limit] + f"... "
+ return text[:limit] + f"... <已截断 {len(text) - limit} 个字符>"
def stable_json(value: Any) -> str:
@@ -223,6 +316,11 @@ def save_json_file(path: str | Path, value: Any) -> None:
def default_state() -> dict[str, Any]:
+ """Create a resumable state container.
+
+ The script can be run as six independent commands. IDs returned by one step
+ are persisted here so later steps can resume without redoing earlier saves.
+ """
return {
"api_base": "",
"taskId": "",
@@ -247,6 +345,7 @@ def default_state() -> dict[str, Any]:
def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> None:
+ # Accept both frontend-style camelCase and CLI-friendly snake_case inputs.
aliases = {
"task_id": "taskId",
"action_id": "actionId",
@@ -260,16 +359,12 @@ def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> No
continue
if dest in {"taskId", "actionId"}:
state[dest] = str(value)
- elif dest in state or dest in {
- "api_base",
- "headers",
- "auth_token",
- "authorization",
- }:
+ elif dest in state or dest in {"api_base", "headers"}:
state[dest] = value
def record_event(state: dict[str, Any], stage: str, message: str, data: Any | None = None) -> None:
+ """Append a compact diagnostic event to the state file."""
state.setdefault("events", []).append(
{
"time": utc_now(),
@@ -286,9 +381,13 @@ def feedback_step(stage: str) -> str:
return match.group(1) if match else ""
+def feedback_step_label(stage: str) -> str:
+ return STEP_NUMBER_LABELS.get(feedback_step(stage), "")
+
+
def request_keys(payload: Any) -> list[str]:
if isinstance(payload, dict):
- return sorted(str(key) for key in payload.keys())
+ return sorted(REQUEST_FIELD_LABELS.get(str(key), str(key)) for key in payload.keys())
return []
@@ -306,6 +405,77 @@ def endpoint_label(endpoint_key: str | None, method: str, url: str) -> str:
return f"调用接口 {method}"
+def method_label(method: str) -> str:
+ return HTTP_METHOD_LABELS.get(str(method).upper(), str(method))
+
+
+def yes_no(value: bool) -> str:
+ return "是" if value else "否"
+
+
+def stage_label(stage: Any) -> str:
+ text = str(stage or "")
+ if not text:
+ return ""
+ if text in STEP_LABELS:
+ return STEP_LABELS[text]
+ if text == "runner":
+ return "流程执行器"
+ parts = text.split("/")
+ base = parts[0]
+ match = re.search(r"(step[1-6])", base)
+ labels: list[str] = []
+ if match:
+ labels.append(STEP_LABELS.get(match.group(1), match.group(1)))
+ elif base == "runner":
+ labels.append("流程执行器")
+ else:
+ labels.append(base)
+ for segment in parts[1:]:
+ normalized = segment.replace("-", "_")
+ attempt = re.fullmatch(r"model-call-attempt-(\d+)", segment)
+ json_attempt = re.fullmatch(r"model-json-attempt-(\d+)", segment)
+ if attempt:
+ labels.append(f"模型接口第 {attempt.group(1)} 次尝试")
+ elif json_attempt:
+ labels.append(f"JSON解析第 {json_attempt.group(1)} 次尝试")
+ elif segment == "auto-login":
+ labels.append("自动登录")
+ elif segment in STAGE_SEGMENT_LABELS:
+ labels.append(STAGE_SEGMENT_LABELS[segment])
+ elif normalized in ENDPOINT_LABELS:
+ labels.append(ENDPOINT_LABELS[normalized])
+ else:
+ labels.append(segment)
+ return " / ".join(labels)
+
+
+def status_label(status: Any) -> str:
+ if status is None:
+ return ""
+ return STATUS_LABELS.get(str(status), str(status))
+
+
+def mode_label(mode: str) -> str:
+ if mode == "full":
+ return "全流程"
+ return STEP_LABELS.get(mode, mode)
+
+
+def exception_type_label(exception_type: Any) -> str:
+ name = str(exception_type or "")
+ labels = {
+ "HTTPError": "HTTP 调用异常",
+ "URLError": "网络地址异常",
+ "TimeoutError": "请求超时异常",
+ "FlowError": "流程异常",
+ "ValueError": "数据格式异常",
+ "KeyError": "字段缺失异常",
+ "RuntimeError": "运行异常",
+ }
+ return labels.get(name, "程序异常" if name else "")
+
+
def emit_operation_feedback(
state: dict[str, Any],
stage: str,
@@ -317,39 +487,41 @@ def emit_operation_feedback(
attempt: int = 1,
dry_run: bool = False,
) -> None:
+ # Feedback lines are a mini protocol consumed by the calling agent. They
+ # must stay single-line JSON so weak models/tools can relay them reliably.
payload_obj = {
- "type": "OP_FEEDBACK",
- "step": feedback_step(stage),
- "stage": stage,
- "operation": endpoint_label(endpoint_key, method, url),
- "method": method,
- "url": url,
- "endpoint": endpoint_key or "",
- "attempt": attempt,
- "dryRun": dry_run,
- "requestKeys": request_keys(payload),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "操作反馈",
+ "步骤": feedback_step_label(stage),
+ "阶段": stage_label(stage),
+ "操作": endpoint_label(endpoint_key, method, url),
+ "请求方式": method_label(method),
+ "接口地址": url,
+ "接口名称": endpoint_label(endpoint_key, method, url),
+ "第几次尝试": attempt,
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("OP_FEEDBACK " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["operation"] + " " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
def emit_error_feedback(error: dict[str, Any]) -> None:
payload = {
- "type": "ERROR_FEEDBACK",
- "stage": error.get("stage"),
- "step": feedback_step(str(error.get("stage") or "")),
- "message": error.get("message"),
- "url": error.get("url"),
- "status": error.get("status"),
- "request": error.get("request"),
- "response": error.get("response"),
- "exceptionType": error.get("exceptionType"),
- "exception": error.get("exception"),
- "time": error.get("time"),
+ "日志类型": "错误反馈",
+ "阶段": stage_label(error.get("stage")),
+ "步骤": feedback_step_label(str(error.get("stage") or "")),
+ "错误信息": error.get("message"),
+ "接口地址": error.get("url"),
+ "状态码": error.get("status"),
+ "请求摘要": error.get("request"),
+ "响应摘要": error.get("response"),
+ "异常类型": exception_type_label(error.get("exceptionType")),
+ "异常信息": error.get("exception"),
+ "时间": error.get("time"),
}
- print("ERROR_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["error"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def emit_json_parse_review(
@@ -360,18 +532,89 @@ def emit_json_parse_review(
candidate_count: int,
) -> None:
payload = {
- "type": "JSON_PARSE_REVIEW",
- "stage": stage,
- "step": feedback_step(stage),
- "endpoint": endpoint_key,
- "message": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
- "candidateCount": candidate_count,
- "responseExcerpt": compact(text, 3000),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "JSON解析复核",
+ "阶段": stage_label(stage),
+ "步骤": feedback_step_label(stage),
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, endpoint_key),
+ "提示": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
+ "候选数量": candidate_count,
+ "响应片段": compact(text, 3000),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("JSON_PARSE_REVIEW " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["json_review"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
+
+
+SAVE_ENTITY_LABELS = {
+ "save_design": "受众目标",
+ "save_design_account": "受众目标账号",
+ "save_pathway": "关键传播节点",
+ "save_relation": "受众-传播节点关系",
+ "save_design_vuln": "受众脆弱点",
+ "save_pathway_vuln": "传播节点脆弱点",
+ "save_strategy": "策略",
+ "save_channel": "策略平台与人设",
+ "save_narrate": "策略信息主题",
+ "save_message": "策略消息配置",
+ "save_strategy_relation": "策略关联关系",
+}
+
+
+def saved_entity_name(endpoint_key: str, payload: dict[str, Any]) -> str:
+ """Return the most user-readable name for an inserted row."""
+ candidates = [
+ payload.get("targetAudience"),
+ payload.get("accountName"),
+ payload.get("vulnerabilities"),
+ payload.get("topic"),
+ payload.get("narrate"),
+ payload.get("platform"),
+ payload.get("strategyId"),
+ payload.get("designId"),
+ payload.get("pathwayId"),
+ ]
+ for value in candidates:
+ if value is not None and str(value).strip():
+ return str(value).strip()
+ return endpoint_key
+
+
+def emit_save_feedback(
+ state: dict[str, Any],
+ stage: str,
+ endpoint_key: str,
+ payload: dict[str, Any],
+ row_id: int,
+ *,
+ dry_run: bool,
+) -> None:
+ # Emit only after an id is known. Downstream steps use these ids and should
+ # fail loudly rather than inventing relations against missing rows.
+ label = SAVE_ENTITY_LABELS.get(endpoint_key, endpoint_key)
+ name = saved_entity_name(endpoint_key, payload)
+ feedback = {
+ "日志类型": "入库反馈",
+ "步骤": feedback_step(stage),
+ "阶段": stage,
+ "入库对象类型": label,
+ "入库对象名称": name,
+ "返回ID": row_id,
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, label),
+ "接口路径": ENDPOINTS.get(endpoint_key, ""),
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
+ }
+ print(LOG_PREFIXES["save"] + " " + json.dumps(feedback, ensure_ascii=False), flush=True)
+ record_event(
+ state,
+ stage,
+ f"入库{label}成功:{name}",
+ {"id": row_id, "endpoint": endpoint_key, "dryRun": dry_run},
+ )
def record_error(
@@ -385,6 +628,8 @@ def record_error(
response: Any | None = None,
exception: BaseException | None = None,
) -> None:
+ # Persist full-enough error context for post-run diagnosis, then immediately
+ # emit a short line the agent can show to the user.
item = {
"time": utc_now(),
"stage": stage,
@@ -430,65 +675,25 @@ def url_for_path(ctx: FlowContext, path: str) -> str:
return ctx.api_base.rstrip("/") + "/" + path.lstrip("/")
-def parse_headers(header_values: list[str], auth_token: str | None, authorization: str | None) -> dict[str, str]:
+def parse_headers(header_values: list[str]) -> dict[str, str]:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
- if auth_token:
- headers["Authorization"] = auth_token if auth_token.lower().startswith("bearer ") else f"Bearer {auth_token}"
- if authorization:
- headers["Authorization"] = authorization
for raw in header_values:
if ":" not in raw:
raise FlowError(f"header 格式错误,应为 Key: Value,实际:{raw}")
key, value = raw.split(":", 1)
- headers[key.strip()] = value.strip()
+ normalized = key.strip()
+ if normalized.lower() == "authorization":
+ continue
+ headers[normalized] = value.strip()
return headers
-def has_authorization(headers: dict[str, str]) -> bool:
- return any(key.lower() == "authorization" and str(value).strip() for key, value in headers.items())
-
-
def ssl_context(ctx: FlowContext) -> ssl.SSLContext | None:
if ctx.verify_ssl:
return None
return ssl._create_unverified_context()
-def auth_header_value(token: str) -> str:
- token = token.strip()
- return token if token.lower().startswith("bearer ") else f"Bearer {token}"
-
-
-def extract_access_token(data: Any) -> str | None:
- if isinstance(data, dict):
- for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
- value = data.get(key)
- if isinstance(value, str) and value.strip():
- return value.strip()
- for value in data.values():
- nested = extract_access_token(value)
- if nested:
- return nested
- elif isinstance(data, list):
- for value in data:
- nested = extract_access_token(value)
- if nested:
- return nested
- return None
-
-
-def redact_url(url: str) -> str:
- return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
-
-
-def build_login_url(ctx: FlowContext, path: str) -> str:
- base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else url_for_path(ctx, path)
- if ctx.login_password:
- separator = "&" if "?" in base else "?"
- base = f"{base}{separator}password={urllib.parse.quote(ctx.login_password, safe='')}"
- return base
-
-
def login_candidate_paths(ctx: FlowContext) -> list[str]:
paths = [
ctx.login_path or "/api/v1/auth/login/username",
@@ -506,6 +711,7 @@ def login_candidate_paths(ctx: FlowContext) -> list[str]:
def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force: bool = False) -> bool:
+ """Fetch a fresh token through the local auth package before business calls."""
if not ctx.auto_login:
return False
if ctx.dry_run:
@@ -515,7 +721,7 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
record_error(state, stage, "自动登录失败:缺少 login_username")
return False
if not force and ctx.login_attempt_count > 0:
- return has_authorization(ctx.headers)
+ return any(key.lower() == "authorization" and str(value).strip() for key, value in ctx.headers.items())
if force and ctx.login_attempt_count >= 2:
return False
@@ -525,8 +731,7 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
last_response = ""
last_exc: BaseException | None = None
for attempt, path in enumerate(login_candidate_paths(ctx), start=1):
- url = build_login_url(ctx, path)
- display_url = redact_url(url)
+ display_url = xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, path, ctx.login_password))
emit_operation_feedback(
state,
stage,
@@ -537,63 +742,39 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
attempt=attempt,
)
try:
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- headers = {
- "Content-Type": "application/json",
- "Accept": "application/json",
- }
- req = urllib.request.Request(url, data=data, headers=headers, method="POST")
- with urllib.request.urlopen(req, timeout=ctx.timeout, context=ssl_context(ctx)) as resp:
- raw = resp.read().decode("utf-8", errors="replace")
- parsed = parse_json_lenient(raw)
- token = extract_access_token(parsed)
- if not token:
- last_status = getattr(resp, "status", None)
- last_response = raw
- record_event(
- state,
- stage,
- "登录接口已响应但未找到 access_token",
- {"url": display_url, "status": last_status, "response": compact(raw, 1000)},
- )
- continue
- ctx.headers["Authorization"] = auth_header_value(token)
- record_event(
- state,
- stage,
- "自动登录成功,已写入 Authorization Bearer token",
- {
- "url": display_url,
- "status": getattr(resp, "status", None),
- "username": ctx.login_username,
- },
- )
- return True
- except urllib.error.HTTPError as exc:
- last_status = exc.code
- last_response = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
- last_exc = exc
- record_event(
- state,
- stage,
- "登录接口返回错误,准备尝试下一个候选登录路径",
- {"url": display_url, "status": exc.code, "response": compact(last_response, 1000)},
+ result = xdfx_auth.fetch_access_token(
+ api_base=ctx.api_base,
+ username=ctx.login_username,
+ password=ctx.login_password,
+ login_path=path,
+ timeout=ctx.timeout,
+ verify_ssl=ctx.verify_ssl,
)
- except Exception as exc: # noqa: BLE001 - auto-login must report every failure.
- last_exc = exc
- last_response = traceback.format_exc()
+ ctx.headers["Authorization"] = xdfx_auth.auth_header_value(result.token)
+ state["_tokenSource"] = "xdfx_auth"
record_event(
state,
stage,
- "登录接口调用异常,准备尝试下一个候选登录路径",
- {"url": display_url, "exceptionType": type(exc).__name__, "exception": str(exc)},
+ "启动阶段已通过 xdfx_auth 包获取 token,并写入 Authorization",
+ {"url": result.url, "status": result.status, "username": ctx.login_username},
+ )
+ return True
+ except xdfx_auth.AuthError as exc:
+ last_status = exc.status
+ last_response = exc.response
+ last_exc = exc
+ record_event(
+ state,
+ stage,
+ "xdfx_auth 获取 token 失败,准备尝试下一个候选登录路径",
+ {"url": exc.url, "status": exc.status, "response": compact(exc.response, 1000), "exception": str(exc)},
)
record_error(
state,
stage,
- "自动登录失败,未能获取 access_token",
- url=redact_url(build_login_url(ctx, ctx.login_path or "/api/v1/auth/login/username")),
+ "自动登录失败,未能通过 xdfx_auth 获取 access_token",
+ url=xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, ctx.login_path or "/api/v1/auth/login/username", ctx.login_password)),
status=last_status,
request=payload,
response=last_response,
@@ -612,6 +793,7 @@ def http_request_json_url(
endpoint_key: str | None = None,
allow_auto_login: bool = True,
) -> tuple[Any | None, str]:
+ """Call an HTTP JSON endpoint with logging, retries, and 401 recovery."""
method = method.upper()
if ctx.dry_run:
emit_operation_feedback(state, stage, method, url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
@@ -640,6 +822,7 @@ def http_request_json_url(
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
if exc.code == 401 and allow_auto_login and ctx.auto_login:
+ # Refresh once on 401, then replay the original request exactly.
refreshed = try_auto_login(ctx, state, f"{stage}/auto-login", force=True)
if refreshed:
record_event(
@@ -709,6 +892,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
emit_operation_feedback(state, stage, "POST", url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
record_event(state, stage, f"DRY RUN POST {url}", {"payload": payload})
if endpoint_key.startswith("save_"):
+ # Synthetic ids let downstream dry-run code exercise relation wiring.
synthetic_id = abs(hash(stage + stable_json(payload))) % 100000000
return {"id": synthetic_id, "dryRun": True}, json.dumps({"id": synthetic_id, "dryRun": True})
return {"dryRun": True}, json.dumps({"dryRun": True})
@@ -716,6 +900,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
def parse_json_lenient(text: str) -> Any | None:
+ """Parse normal JSON plus adjacent/SSE-like JSON objects."""
stripped = text.strip()
if not stripped:
return None
@@ -730,6 +915,7 @@ def parse_json_lenient(text: str) -> Any | None:
def parse_concatenated_json_objects(text: str) -> list[Any]:
+ """Read streams such as {"answer": "..."}{"answer": "..."} or data: lines."""
decoder = json.JSONDecoder()
objects: list[Any] = []
i = 0
@@ -766,6 +952,7 @@ def parse_concatenated_json_objects(text: str) -> list[Any]:
def model_text_from_response(parsed: Any, raw: str) -> tuple[str, list[Any]]:
+ """Flatten streamed model chunks into one text buffer."""
objects: list[Any]
if isinstance(parsed, list):
objects = parsed
@@ -824,6 +1011,7 @@ def extract_json_from_text(text: str) -> Any | None:
def extract_json_candidates_from_text(text: str) -> list[Any]:
+ """Collect every plausible JSON object/array from messy model text."""
cleaned = strip_think(text)
candidates: list[Any] = []
seen: set[str] = set()
@@ -860,6 +1048,7 @@ def extract_json_candidates_from_text(text: str) -> list[Any]:
def select_json_candidate(candidates: list[Any], endpoint_key: str, stage: str) -> Any | None:
+ """Pick the candidate whose keys best match the current step."""
if not candidates:
return None
scored = [(json_candidate_score(candidate, endpoint_key, stage), idx, candidate) for idx, candidate in enumerate(candidates)]
@@ -1010,6 +1199,7 @@ def extract_first_balanced(text: str, open_ch: str, close_ch: str) -> str | None
def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[str, list[Any], Any | None]:
+ """Call a model endpoint and retry only known empty/timeout/no-response cases."""
max_attempts = max(ctx.model_retries, 0) + 1
last_text = ""
last_objects: list[Any] = []
@@ -1048,6 +1238,11 @@ def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str,
def call_model_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[dict[str, Any], str, list[Any]]:
+ """Return the best structured JSON for a model step.
+
+ If the first parse fails, the second attempt includes an explicit repair
+ instruction so weak models can re-emit only the required JSON.
+ """
last_text = ""
last_objects: list[Any] = []
last_candidate_count = 0
@@ -1202,23 +1397,85 @@ def profile_info(account: dict[str, Any]) -> str:
def parse_index_list(value: Any) -> list[str]:
- if value is None:
- return []
- if isinstance(value, list):
- return [str(v).strip() for v in value if str(v).strip()]
- text = str(value)
- return [part.strip().strip("<>").strip() for part in re.split(r"[,,;;、\s]+", text) if part.strip()]
+ refs: list[str] = []
+
+ def add(raw: Any) -> None:
+ if raw is None:
+ return
+ if isinstance(raw, dict):
+ keys = (
+ "编号id",
+ "编号ID",
+ "用户编号id",
+ "用户编号ID",
+ "用户编号",
+ "账号编号",
+ "引用序号",
+ "索引",
+ "index",
+ "id",
+ "ids",
+ "accountIndex",
+ "accountIndexes",
+ "userIndex",
+ "userIndexes",
+ )
+ for key in keys:
+ if key in raw:
+ add(raw.get(key))
+ return
+ if isinstance(raw, (list, tuple, set)):
+ for item in raw:
+ add(item)
+ return
+ text = str(raw).strip()
+ if not text:
+ return
+ angle_refs = re.findall(r"<<\s*([^<>]+?)\s*>>", text)
+ if angle_refs:
+ for token in angle_refs:
+ add(token)
+ return
+ for part in re.split(r"[,,;;、\s]+", text):
+ token = part.strip().strip("<>").strip()
+ if token:
+ refs.append(token)
+
+ add(value)
+ deduped: list[str] = []
+ seen: set[str] = set()
+ for ref in refs:
+ if ref in seen:
+ continue
+ seen.add(ref)
+ deduped.append(ref)
+ return deduped
+
+
+def account_ref_numbers(account: dict[str, Any]) -> set[int]:
+ numbers: set[int] = set()
+ meta = account.get("metadata") if isinstance(account.get("metadata"), dict) else {}
+ for key in ("编号id", "编号ID", "用户编号id", "用户编号ID", "用户编号", "账号编号", "引用序号", "索引", "index", "accountIndex"):
+ for container in (account, meta):
+ number = as_int(container.get(key)) if isinstance(container, dict) else None
+ if number is not None:
+ numbers.add(number)
+ return numbers
def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, Any] | None:
+ """Resolve model references like <<3>>, index numbers, names, or account ids."""
if not accounts:
return None
text = str(ref).strip().strip("<>").strip()
number = as_int(text)
if number is not None:
for account in accounts:
- if any(as_int(account.get(key)) == number for key in ("引用序号", "索引", "index")):
+ if number in account_ref_numbers(account):
return account
+ # Step1 group extraction returns 编号/id values that refer to the
+ # previous zhanghao array index. Prefer exact index fields above, then
+ # fall back to zero-based and one-based array positions.
if 0 <= number < len(accounts):
return accounts[number]
if 1 <= number <= len(accounts):
@@ -1232,11 +1489,25 @@ def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, A
def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Normalize audience JSON whether the model used numbered fields or items[]."""
items: list[dict[str, Any]] = []
for i in range(1, 21):
group = first_present(data.get(f"群体{i}"), data.get(f"受众{i}"), data.get(f"targetAudience{i}"))
reason = first_present(data.get(f"理由{i}"), data.get(f"推荐理由{i}"), data.get(f"recReason{i}"))
- users = first_present(data.get(f"用户编号列表{i}"), data.get(f"用户列表{i}"), data.get(f"账号编号列表{i}"))
+ users = first_present(
+ data.get(f"用户编号列表{i}"),
+ data.get(f"编号id列表{i}"),
+ data.get(f"编号ID列表{i}"),
+ data.get(f"用户编号id列表{i}"),
+ data.get(f"用户编号ID列表{i}"),
+ data.get(f"用户列表{i}"),
+ data.get(f"账号编号列表{i}"),
+ data.get(f"索引列表{i}"),
+ data.get(f"编号{i}"),
+ data.get(f"id{i}"),
+ data.get(f"ids{i}"),
+ data.get(f"索引{i}"),
+ )
if group:
items.append(
{
@@ -1255,7 +1526,27 @@ def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
{
"targetAudience": str(group).strip(),
"recReason": str(first_present(item.get("理由"), item.get("recReason"), item.get("reason")) or "").strip(),
- "userIndexes": parse_index_list(first_present(item.get("用户编号列表"), item.get("users"), item.get("accounts"))),
+ "userIndexes": parse_index_list(
+ first_present(
+ item.get("用户编号列表"),
+ item.get("编号id列表"),
+ item.get("编号ID列表"),
+ item.get("用户编号id列表"),
+ item.get("用户编号ID列表"),
+ item.get("编号id"),
+ item.get("编号ID"),
+ item.get("用户编号id"),
+ item.get("用户编号ID"),
+ item.get("编号"),
+ item.get("索引"),
+ item.get("id"),
+ item.get("ids"),
+ item.get("users"),
+ item.get("accounts"),
+ item.get("userIndexes"),
+ item.get("accountIndexes"),
+ )
+ ),
"rank": i,
}
)
@@ -1272,6 +1563,7 @@ def audience_score(item: dict[str, Any]) -> tuple[int, int]:
def select_top_audiences(items: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
+ """Deduplicate audience names and keep the strongest candidates."""
unique: dict[str, dict[str, Any]] = {}
for item in items:
key = item.get("targetAudience", "")
@@ -1301,6 +1593,7 @@ def pathway_score(item: dict[str, Any]) -> tuple[int, int, int]:
def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Merge the same account found from different audiences before saving top 3."""
merged: dict[str, dict[str, Any]] = {}
for item in candidates:
account = item.get("account") or {}
@@ -1325,6 +1618,7 @@ def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str,
def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
+ """Normalize weak-point model output into a capped list."""
vulns: list[dict[str, str]] = []
for i in range(1, 21):
vuln = first_present(data.get(f"脆弱点{i}"), data.get(f"vulnerability{i}"), data.get(f"vuln{i}"))
@@ -1339,6 +1633,7 @@ def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
def extract_strategies(data: dict[str, Any], limit: int) -> list[dict[str, Any]]:
+ """Normalize strategy output and preserve the modal text for later mapping."""
strategies: list[dict[str, Any]] = []
for i in range(1, 21):
title = first_present(data.get(f"信息标题{i}"), data.get(f"主题{i}"), data.get(f"topic{i}"))
@@ -1395,11 +1690,29 @@ def response_id(response: Any) -> int | None:
return None
+def response_rows(response: Any) -> list[Any]:
+ """Extract list rows from common CRUD response shapes."""
+ if isinstance(response, list):
+ return response
+ if not isinstance(response, dict):
+ return []
+ for key in ("data", "rows", "items", "records", "list"):
+ value = response.get(key)
+ if isinstance(value, list):
+ return value
+ rows = response_rows(value)
+ if rows:
+ return rows
+ return []
+
+
def save_with_id(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[int | None, Any | None]:
response, _ = http_post_json(ctx, state, endpoint_key, payload, stage)
rid = response_id(response)
if rid is None:
- record_error(state, stage, "保存接口未返回 id,视为失败", request=payload, response=response)
+ record_error(state, stage, "保存接口未返回 ID,视为失败", request=payload, response=response)
+ else:
+ emit_save_feedback(state, stage, endpoint_key, payload, rid, dry_run=ctx.dry_run)
return rid, response
@@ -1429,6 +1742,7 @@ def parse_step_flags(raw: Any) -> dict[str, Any]:
def mark_step_status(ctx: FlowContext, state: dict[str, Any], links: list[str], stage: str) -> None:
+ """Update the frontend step bar without overwriting unrelated step flags."""
if not ctx.sync_step_status:
record_event(state, stage, "跳过 task-action.step 状态同步")
return
@@ -1477,12 +1791,126 @@ def strategy_platform(value: Any) -> Any:
return platform_to_number(value) or 1
-def strategy_model_type(value: Any) -> str:
- # Frontend defaults to text model type "1" when no modal type is selected.
- raw = first_present(value, "1")
- if isinstance(raw, list):
- return ",".join(str(v) for v in raw if str(v).strip()) or "1"
- return str(raw or "1")
+def choice_tokens(value: Any) -> list[Any]:
+ """Split frontend choice values from arrays, JSON strings, and mixed delimiters."""
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return choice_tokens(
+ first_present(value.get("value"), value.get("dictValue"), value.get("label"), value.get("dictLabel"))
+ )
+ if isinstance(value, (list, tuple, set)):
+ tokens: list[Any] = []
+ for item in value:
+ tokens.extend(choice_tokens(item))
+ return tokens
+ text = str(value).strip()
+ if not text:
+ return []
+ if text[0] in "[{":
+ try:
+ parsed = json.loads(text)
+ if parsed != value:
+ return choice_tokens(parsed)
+ except Exception:
+ pass
+ return [part.strip() for part in re.split(r"[,,、;/|]+", text) if part.strip()]
+
+
+def dict_choices(
+ ctx: FlowContext,
+ state: dict[str, Any],
+ dict_type: str,
+ stage: str,
+) -> list[dict[str, Any]]:
+ """Fetch and cache frontend dictionary choices such as modelType."""
+ cache = state.setdefault("_dictChoiceCache", {})
+ if dict_type in cache:
+ cached = cache[dict_type]
+ return cached if isinstance(cached, list) else []
+ if ctx.dry_run:
+ cache[dict_type] = []
+ return []
+
+ query = urllib.parse.urlencode({"filter": f"dictType||$eq||{dict_type}", "limit": 999, "page": 1})
+ url = f"{url_for(ctx, 'dict_data')}?{query}"
+ response, _ = http_request_json_url(ctx, state, "GET", url, None, stage, endpoint_key="dict_data")
+ choices: list[dict[str, Any]] = []
+ for row in response_rows(response):
+ if not isinstance(row, dict):
+ continue
+ label = str(first_present(row.get("dictLabel"), row.get("label"), "") or "").strip()
+ raw_value = first_present(row.get("dictValue"), row.get("value"))
+ if not label or raw_value is None:
+ continue
+ number = as_int(raw_value)
+ choices.append({"label": label, "value": number if number is not None else str(raw_value).strip()})
+ for alias in choice_tokens(row.get("alias")):
+ alias_label = str(alias).strip()
+ if alias_label:
+ choices.append({"label": alias_label, "value": number if number is not None else str(raw_value).strip()})
+ cache[dict_type] = choices
+ return choices
+
+
+def choice_value_by_label(choices: list[dict[str, Any]] | None, label: str) -> Any | None:
+ needle = re.sub(r"\s+", "", str(label or "").lower())
+ if not needle:
+ return None
+ for item in choices or []:
+ item_label = re.sub(r"\s+", "", str(item.get("label") or "").lower())
+ if item_label and (needle == item_label or needle in item_label or item_label in needle):
+ return item.get("value")
+ return None
+
+
+def model_type_token_value(token: Any, choices: list[dict[str, Any]] | None = None) -> Any:
+ """Convert modal labels like 图片/视频 into frontend dictionary values."""
+ number = as_int(token)
+ if number is not None:
+ return number
+
+ text = str(token or "").strip()
+ lower = text.lower()
+ exact = choice_value_by_label(choices, text)
+ if exact is not None:
+ return exact
+
+ label = ""
+ fallback_value = 2
+ if "视" in text or "video" in lower:
+ label, fallback_value = "视频", 3
+ elif "图" in text or "image" in lower or "picture" in lower or "photo" in lower:
+ label, fallback_value = "图片", 2
+ elif "音" in text or "audio" in lower or "sound" in lower:
+ label, fallback_value = "音频", 4
+ elif "发文" in text or "文本" in text or "文字" in text or "text" in lower:
+ label, fallback_value = "文本", 1
+
+ if label:
+ matched = choice_value_by_label(choices, label)
+ if matched is not None:
+ return matched
+ return fallback_value
+
+ # Mirrors the frontend helper's unknown-token default.
+ return 2
+
+
+def strategy_model_type(value: Any, choices: list[dict[str, Any]] | None = None) -> str:
+ """Return the comma-separated value string expected by task-action-message."""
+ tokens = choice_tokens(value)
+ if not tokens:
+ # Frontend save paths fall back to "1" when no modal type is selected.
+ return "1"
+ values: list[str] = []
+ seen: set[str] = set()
+ for token in tokens:
+ normalized = str(model_type_token_value(token, choices)).strip()
+ if normalized and normalized not in seen:
+ values.append(normalized)
+ seen.add(normalized)
+ return ",".join(values) or "1"
def strategy_language(value: Any) -> int:
@@ -1501,11 +1929,28 @@ def save_strategy_details(
*,
audience: bool,
) -> None:
- platform = strategy_platform(first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})))
+ """Save all frontend-readable strategy child rows.
+
+ The strategy table and arrangement page do not read only the main strategy
+ row; they also expect channel, narrate, message, and relation rows.
+ """
+ platform = strategy_platform(
+ first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
+ )
+ account_type = str(
+ first_present(
+ strategy.get("accountType"),
+ item.get("accountType"),
+ item.get("type"),
+ "目标受众账号" if audience else "关键传播节点账号",
+ )
+ or ""
+ ).strip()
persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
title = str(strategy.get("title") or "").strip()
+ model_type_choices = dict_choices(ctx, state, "modelType", stage + "/dict-modelType")
channel_id, _ = save_with_id(
ctx,
@@ -1516,6 +1961,7 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"platform": platform,
+ "accountType": account_type,
"personaJson": json.dumps([{"persona": persona, "size": persona_size}], ensure_ascii=False),
},
stage + "/save-channel",
@@ -1531,7 +1977,10 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"channelId": channel_id,
- "modelType": strategy_model_type(first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType"))),
+ "modelType": strategy_model_type(
+ first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType")),
+ model_type_choices,
+ ),
"language": strategy_language(first_present(strategy.get("language"), strategy.get("语种"))),
"respondingCount": responding_count,
"messageCount": responding_count,
@@ -1564,7 +2013,39 @@ def object_for_audience(audience: dict[str, Any]) -> dict[str, Any]:
}
+def downstream_limit(ctx: FlowContext, *, audience: bool) -> int:
+ """Limit expensive downstream analysis while keeping 3 upstream rows stored."""
+ raw = ctx.downstream_audiences if audience else ctx.downstream_pathways
+ return max(1, raw)
+
+
+def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, audience: bool) -> list[dict[str, Any]]:
+ """Select the 1-2 best stored rows for vulnerability/strategy generation."""
+ source_key = "topAudiences" if audience else "topPathways"
+ id_key = "designId" if audience else "pathwayAccountId"
+ selected_key = "selectedDownstreamAudiences" if audience else "selectedDownstreamPathways"
+ items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)]
+ limit = downstream_limit(ctx, audience=audience)
+ selected = items[:limit]
+ state[selected_key] = [
+ {
+ "id": item.get(id_key),
+ "name": item.get("targetAudience") if audience else item.get("accountName"),
+ }
+ for item in selected
+ ]
+ if items:
+ record_event(
+ state,
+ stage,
+ f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}",
+ {"limit": limit, "selected": state[selected_key]},
+ )
+ return selected
+
+
def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze audiences, save top 3, and attach any referenced accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step1-audience"
if state.get("topAudiences") and not ctx.dry_run:
@@ -1582,12 +2063,15 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
state["audienceRawText"] = text
+ # The first model call may return raw text plus embedded account objects.
accounts = accounts_from_response(parsed, objects, text)
state["audienceAccounts"] = accounts
+ # The second model call converts the long audience analysis into numbered
+ # groups that can be saved as task-action-design rows.
group_payload = {
"mubiao": state["mubiao"],
- "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
"zhanghao": accounts,
"history": [],
"stream": True,
@@ -1602,6 +2086,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
top = select_top_audiences(candidates, ctx.max_audiences)
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the audience first; account rows and downstream analyses depend
+ # on the returned designId.
design_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
@@ -1614,6 +2100,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
continue
item = {**item, "designId": design_id, "saveResponse": resp, "accounts": [], "savedAccountIds": []}
for ref in item.get("userIndexes") or []:
+ # Model references are intentionally fuzzy, so resolve them against
+ # both explicit indexes and account names/ids.
account = resolve_account_ref(ref, accounts)
if account is None:
record_error(state, stage + "/save-design-account", f"用户编号 {ref} 未匹配到候选账号")
@@ -1645,6 +2133,7 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze key pathway accounts for saved audiences and store top 3."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step2-pathway"
audiences = [a for a in state.get("topAudiences") or [] if a.get("designId")]
@@ -1656,6 +2145,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
return
all_candidates: list[dict[str, Any]] = []
for audience in audiences:
+ # Analyze pathway candidates in the context of each saved audience.
payload = {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。",
"otherinfo": {
@@ -1673,6 +2163,8 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
accounts = accounts_from_response(parsed, objects, text)
+ # Ask the model to point back to candidate accounts, then resolve those
+ # references to concrete account objects.
spread_payload = {
"mubiao": state["mubiao"],
"user_message": "分析其中关键信息。请从候选账号中抽取核心传播节点,字段必须是:账号1、理由1、账号2、理由2 ...,最多输出5个。账号字段优先返回候选数组的引用编号或索引,只返回JSON。",
@@ -1707,6 +2199,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways]
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the account row before writing the design-pathway relation.
account = item["account"]
aid = account_id(account)
name = account_name(account)
@@ -1757,12 +2250,14 @@ def run_step4(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate and save weak points for selected audiences or pathway accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step3-audience-vulns" if audience else "step4-pathway-vulns"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
output_key = "audienceVulns" if audience else "pathwayVulns"
state.setdefault(output_key, {})
for item in items:
+ # owner_id is designId for audiences and pathwayAccountId for accounts.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
if not owner_id:
continue
@@ -1789,6 +2284,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) ->
vulns = extract_vulns(data, ctx.max_vulns)
saved: list[dict[str, Any]] = []
for vuln in vulns:
+ # The two weak-point tables have nearly identical payloads but
+ # different foreign-key names.
if audience:
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
@@ -1835,14 +2332,16 @@ def run_step6(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate strategies from saved weak points and persist frontend child rows."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step5-audience-strategy" if audience else "step6-pathway-strategy"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {}
output_key = "audienceStrategies" if audience else "pathwayStrategies"
state.setdefault(output_key, [])
existing_topics = {s.get("topic") for s in state[output_key] if isinstance(s, dict)}
for item in items:
+ # Strategies are only meaningful after the matching weak points exist.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
vulns = vulns_by_owner.get(str(owner_id)) or []
vuln_texts = [v.get("vulnerabilities") for v in vulns if v.get("vulnerabilities")]
@@ -1876,16 +2375,20 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
continue
if topic in existing_topics:
continue
+ # Save the main row first; channel/narrate/message/relation rows all
+ # require the returned strategy id.
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
"targetAudience": target,
"accountType": account_type,
"topic": topic,
- "strategyTitle": "",
+ "strategyTitle": str(strategy.get("title") or "").strip(),
}
strategy_id, resp = save_with_id(ctx, state, "save_strategy", save_payload, stage + "/save")
if strategy_id is not None:
+ # Frontend strategy pages read these child tables, so a strategy
+ # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience)
existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp})
@@ -1914,13 +2417,14 @@ STEP_LABELS = {
def state_counts(state: dict[str, Any]) -> dict[str, int]:
+ """Summarize stored objects for step feedback."""
return {
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceVulns": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
- "pathwayVulns": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "受众脆弱点数量": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
+ "传播节点脆弱点数量": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
}
@@ -1932,23 +2436,26 @@ def emit_step_feedback(
status: str | None = None,
note: str = "",
) -> None:
+ # One feedback line per step keeps the single-skill and split-skill versions
+ # equally transparent to the user.
total_errors = len(state.get("errors") or [])
new_errors = max(0, total_errors - before_error_count)
final_status = status or ("partial" if new_errors else "ok")
payload = {
- "type": "STEP_FEEDBACK",
- "step": step,
- "label": STEP_LABELS.get(step, step),
- "status": final_status,
- "note": note,
- "newErrors": new_errors,
- "totalErrors": total_errors,
- "counts": state_counts(state),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "updatedAt": state.get("updatedAt"),
+ "日志类型": "步骤反馈",
+ "步骤": STEP_NUMBER_LABELS.get(step, step),
+ "步骤名称": STEP_LABELS.get(step, step),
+ "状态": STATUS_LABELS.get(final_status, final_status),
+ "执行状态": status_label(final_status),
+ "备注": note,
+ "本步骤新增错误数": new_errors,
+ "累计错误数": total_errors,
+ "累计入库数量": state_counts(state),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "更新时间": state.get("updatedAt"),
}
- print("STEP_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["step"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def finish_step(
@@ -1960,6 +2467,8 @@ def finish_step(
status: str | None = None,
note: str = "",
) -> None:
+ # Always persist before emitting step feedback so the user can inspect files
+ # immediately after a reported step completes.
state["updatedAt"] = utc_now()
save_json_file(ctx.state_path, state)
if ctx.summary_path:
@@ -1980,6 +2489,11 @@ def skip_step_with_feedback(ctx: FlowContext, state: dict[str, Any], step: str,
def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Legacy full runner.
+
+ The wrapper script is preferred for real-time logs, but this remains useful
+ for dry-runs and unattended local troubleshooting.
+ """
run_step_with_feedback(ctx, state, "step1")
run_step_with_feedback(ctx, state, "step2")
run_step_with_feedback(ctx, state, "step3")
@@ -1995,6 +2509,7 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
def write_summary(path: Path, state: dict[str, Any]) -> None:
+ """Write a human-readable checkpoint beside the machine state JSON."""
audience_vuln_count = sum(len(v) for v in (state.get("audienceVulns") or {}).values())
pathway_vuln_count = sum(len(v) for v in (state.get("pathwayVulns") or {}).values())
lines = [
@@ -2005,6 +2520,8 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
f"- actionId:{state.get('actionId')}",
f"- 受众入库:{len(state.get('topAudiences') or [])}",
f"- 传播节点入库:{len(state.get('topPathways') or [])}",
+ f"- 下游受众选择:{len(state.get('selectedDownstreamAudiences') or [])}",
+ f"- 下游传播节点选择:{len(state.get('selectedDownstreamPathways') or [])}",
f"- 受众脆弱点入库:{audience_vuln_count}",
f"- 传播节点脆弱点入库:{pathway_vuln_count}",
f"- 受众策略入库:{len(state.get('audienceStrategies') or [])}",
@@ -2021,9 +2538,9 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
if state.get("errors"):
lines += ["", "## 失败明细"]
for err in state["errors"]:
- lines.append(f"- [{err.get('stage')}] {err.get('message')} status={err.get('status')} url={err.get('url')}")
+ lines.append(f"- [{stage_label(err.get('stage'))}] {err.get('message')} 状态码={err.get('status')} 接口={err.get('url')}")
if err.get("response"):
- lines.append(f" - response: {err.get('response')}")
+ lines.append(f" - 响应摘要:{err.get('response')}")
text = "\n".join(lines) + "\n"
try:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -2034,34 +2551,34 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
def build_arg_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(description="Run xdfx analysis/storage flow.")
+ parser = argparse.ArgumentParser(description="运行 xdfx 分析与入库流程。")
parser.add_argument("--mode", choices=["full", *STEP_FUNCS.keys()], default="full")
- parser.add_argument("--input", help="JSON config file. Keys: api_base, taskId, actionId, mubiao, direction, platform, headers.")
- parser.add_argument("--state", default="xdfx-flow-state.json", help="State JSON path for resume and step handoff.")
- parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown summary output path.")
+ parser.add_argument("--input", help="JSON 配置文件。常用字段:api_base、taskId、actionId、mubiao、direction、platform、headers。")
+ parser.add_argument("--state", default="xdfx-flow-state.json", help="用于断点续跑和步骤交接的状态 JSON 文件路径。")
+ parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown 执行摘要输出路径。")
parser.add_argument("--api-base")
parser.add_argument("--task-id")
parser.add_argument("--action-id")
parser.add_argument("--mubiao")
parser.add_argument("--direction", default=None)
parser.add_argument("--platform", default=None)
- parser.add_argument("--auth-token", default=os.getenv("XDFX_AUTH_TOKEN"))
- parser.add_argument("--authorization", default=os.getenv("AUTHORIZATION"))
- parser.add_argument("--header", action="append", default=[], help="Extra HTTP header, format 'Key: Value'.")
- parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="Username for automatic login when Authorization is missing or stale.")
- parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="Password query parameter for automatic username login.")
- parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="Login endpoint path or absolute URL.")
- parser.add_argument("--no-auto-login", action="store_true", help="Disable automatic username login and 401 token refresh.")
- parser.add_argument("--verify-ssl", action="store_true", help="Verify HTTPS certificates. Default is disabled for internal/UAT certificates.")
+ parser.add_argument("--header", action="append", default=[], help="附加非鉴权 HTTP 请求头,格式为 'Key: Value';Authorization 会被忽略。")
+ parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="xdfx_auth 启动阶段自动登录使用的用户名。")
+ parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="用户名登录接口的 password 查询参数。")
+ parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="登录接口路径或完整 URL。")
+ parser.add_argument("--no-auto-login", action="store_true", help="关闭 xdfx_auth 自动登录和 401 刷新。")
+ parser.add_argument("--verify-ssl", action="store_true", help="校验 HTTPS 证书。默认不校验,以兼容内网/UAT 自签证书。")
parser.add_argument("--timeout", type=float, default=90)
parser.add_argument("--retries", type=int, default=2)
parser.add_argument("--dry-run", action="store_true")
- parser.add_argument("--no-step-status", action="store_true", help="Do not update task/task-action.step after completed steps.")
- parser.add_argument("--model-retries", type=int, default=1, help="Retry model/stream endpoints when the first response is empty, timeout-like, or says the model did not respond.")
+ parser.add_argument("--no-step-status", action="store_true", help="步骤完成后不更新 task/task-action.step。")
+ parser.add_argument("--model-retries", type=int, default=1, help="模型/流式接口首次空响应、超时或提示模型无响应时的重试次数。")
parser.add_argument("--max-audiences", type=int, default=3)
parser.add_argument("--max-pathways", type=int, default=3)
parser.add_argument("--max-vulns", type=int, default=3)
parser.add_argument("--max-strategies", type=int, default=3)
+ parser.add_argument("--downstream-audiences", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库受众数量,默认 2。")
+ parser.add_argument("--downstream-pathways", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库传播节点数量,默认 2。")
return parser
@@ -2070,6 +2587,8 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
state_path = Path(args.state)
state = default_state()
+ # Load previous state first, then overlay input JSON and finally CLI flags.
+ # That ordering makes resume safe while still allowing explicit overrides.
if state_path.exists():
loaded = load_json_file(state_path)
if isinstance(loaded, dict):
@@ -2081,9 +2600,7 @@ def main(argv: list[str] | None = None) -> int:
merge_config_into_state(state, config)
input_headers = config.get("headers")
if isinstance(input_headers, dict):
- args.header.extend(f"{k}: {v}" for k, v in input_headers.items())
- args.auth_token = args.auth_token or config.get("auth_token")
- args.authorization = args.authorization or config.get("authorization")
+ args.header.extend(f"{k}: {v}" for k, v in input_headers.items() if str(k).lower() != "authorization")
if config.get("login_username") is not None:
args.login_username = str(config.get("login_username") or "")
if config.get("login_password") is not None:
@@ -2094,6 +2611,10 @@ def main(argv: list[str] | None = None) -> int:
args.no_auto_login = True
if config.get("verify_ssl") is not None:
args.verify_ssl = bool(config.get("verify_ssl"))
+ if config.get("downstream_audiences") is not None:
+ args.downstream_audiences = as_int(config.get("downstream_audiences")) or args.downstream_audiences
+ if config.get("downstream_pathways") is not None:
+ args.downstream_pathways = as_int(config.get("downstream_pathways")) or args.downstream_pathways
cli_config = {
"api_base": args.api_base,
@@ -2105,7 +2626,8 @@ def main(argv: list[str] | None = None) -> int:
}
merge_config_into_state(state, {k: v for k, v in cli_config.items() if v is not None})
api_base = state.get("api_base") or "https://ch1.b.uat.4.cn"
- headers = parse_headers(args.header, args.auth_token, args.authorization)
+ headers = parse_headers(args.header)
+ # ctx owns runtime knobs; state owns business data and resumable ids.
ctx = FlowContext(
api_base=api_base,
headers=headers,
@@ -2118,6 +2640,8 @@ def main(argv: list[str] | None = None) -> int:
max_pathways=max(args.max_pathways, 1),
max_vulns=max(args.max_vulns, 1),
max_strategies=max(args.max_strategies, 1),
+ downstream_audiences=max(args.downstream_audiences, 1),
+ downstream_pathways=max(args.downstream_pathways, 1),
sync_step_status=not args.no_step_status,
model_retries=max(args.model_retries, 0),
verify_ssl=bool(args.verify_ssl),
@@ -2131,11 +2655,17 @@ def main(argv: list[str] | None = None) -> int:
state["_summaryPath"] = str(ctx.summary_path) if ctx.summary_path else ""
state["_autoLogin"] = ctx.auto_login
state["_verifySsl"] = ctx.verify_ssl
+ state["_downstreamLimits"] = {
+ "audiences": ctx.downstream_audiences,
+ "pathways": ctx.downstream_pathways,
+ }
record_event(state, "runner", f"start mode={args.mode} dry_run={ctx.dry_run}")
try:
require_context(state, "taskId", "actionId", "mubiao")
- if ctx.auto_login and not has_authorization(ctx.headers):
- try_auto_login(ctx, state, "runner/auto-login")
+ if ctx.auto_login and not ctx.dry_run:
+ # Always acquire a token through the imported auth package before the first business call.
+ if not try_auto_login(ctx, state, "runner/initial-login", force=True):
+ raise FlowError("启动阶段获取 token 失败,已停止执行,避免无 token 调用业务接口")
if args.mode == "full":
run_full(ctx, state)
else:
@@ -2145,13 +2675,14 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(f"ERROR: {exc}", file=sys.stderr)
+ print(f"错误:{exc}", file=sys.stderr)
return 2
except Exception as exc: # noqa: BLE001
record_error(state, "runner", "未捕获异常", response=traceback.format_exc(), exception=exc)
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
+ print("未捕获异常堆栈:", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
return 1
@@ -2159,20 +2690,23 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(json.dumps(
- {
- "ok": True,
- "mode": args.mode,
- "state": str(state_path),
- "summary": str(ctx.summary_path) if ctx.summary_path else "",
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
- "errors": len(state.get("errors") or []),
- },
- ensure_ascii=False,
- ))
+ # Final JSON is intentionally compact; detailed per-step status has already
+ # been emitted as Chinese step feedback.
+ final_payload = {
+ "日志类型": "执行完成",
+ "是否成功": "是",
+ "执行模式": mode_label(args.mode),
+ "状态文件": str(state_path),
+ "摘要文件": str(ctx.summary_path) if ctx.summary_path else "",
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "已选择受众数": len(state.get("selectedDownstreamAudiences") or []),
+ "已选择传播节点数": len(state.get("selectedDownstreamPathways") or []),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
+ "错误数": len(state.get("errors") or []),
+ }
+ print(LOG_PREFIXES["done"] + " " + json.dumps(final_payload, ensure_ascii=False))
return 0
diff --git a/skills-data/xdfx-step3-audience-vulns/SKILL.md b/skills-data/xdfx-step3-audience-vulns/SKILL.md
index ca21e94d..552b6e5d 100644
--- a/skills-data/xdfx-step3-audience-vulns/SKILL.md
+++ b/skills-data/xdfx-step3-audience-vulns/SKILL.md
@@ -13,26 +13,37 @@ description: xdfx 拆分流程 step3,为已入库受众抽取并保存最多3
python scripts/xdfx_flow.py --mode step3 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
-鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
+鉴权和证书由脚本兜底处理:脚本启动时先 `import scripts/xdfx_auth.py`,自动用 `admin / ch@user` 获取 `access_token`,并在第一次业务接口调用前写入内部 Authorization;不要通过命令行传 token。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
-- 对每个已入库受众抽取最多 3 个脆弱点。
+- 从已入库受众中默认选择前 2 个继续分析;可用 `--downstream-audiences 1` 调成只处理 1 个。
+- 对每个被选中的受众抽取并入库最多 3 个脆弱点。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 自动修复和解析模型 JSON。
- 调用受众脆弱点保存接口。
- 只有保存成功并返回 `id` 的脆弱点才进入后续策略阶段。
- 同步前端步骤状态 `step-analysis-weakpoint`。
-- 单个受众失败不影响其他受众。
+- 单个受众失败会写入错误;智能体必须按失败重试规则先重跑 Step3,不能直接跳到 Step4/Step5。
完成后查看 `audienceVulns` 和 `errors`。
+## 失败重试规则
+
+- 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前 Step3 命令。
+- 重试前必须告诉用户失败阶段、接口 URL、状态码、响应摘要和“正在重试 Step3”,不能静默重试。
+- Step3 未重试成功前,不允许继续 Step4/Step5;不要因为 Step3 失败就跳过受众脆弱点分析。
+- Step3 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
+
## 步骤反馈
-命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step3 的完成状态、当前受众脆弱点数量和新增错误数,再决定是否继续下一步。
+命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step3 的完成状态、当前受众脆弱点数量和新增错误数,再决定是否继续下一步。
-执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
+执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。
-如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的受众脆弱点和返回 ID。
+
+如果出现 `错误反馈 {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+
+如果出现 `JSON解析复核 {...}`,必须查看 `响应片段`,判断是否能识别受众脆弱点 JSON;无法确认时重跑 Step3。
-如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别受众脆弱点 JSON;无法确认时重跑 Step3。
diff --git a/skills-data/xdfx-step3-audience-vulns/scripts/xdfx_auth.py b/skills-data/xdfx-step3-audience-vulns/scripts/xdfx_auth.py
new file mode 100644
index 00000000..fe46f809
--- /dev/null
+++ b/skills-data/xdfx-step3-audience-vulns/scripts/xdfx_auth.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""Authentication helper for xdfx skill scripts.
+
+The caller should not pass tokens through command-line arguments. This module
+owns the fixed admin login flow and returns a Bearer token before business
+interfaces are called.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import ssl
+import urllib.error
+import urllib.parse
+import urllib.request
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass
+class AuthResult:
+ token: str
+ url: str
+ status: int | None
+ raw: str
+
+
+class AuthError(RuntimeError):
+ def __init__(
+ self,
+ message: str,
+ *,
+ url: str,
+ status: int | None = None,
+ response: str = "",
+ exception: BaseException | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.url = url
+ self.status = status
+ self.response = response
+ self.exception = exception
+
+
+def auth_header_value(token: str) -> str:
+ token = token.strip()
+ return token if token.lower().startswith("bearer ") else f"Bearer {token}"
+
+
+def redact_url(url: str) -> str:
+ return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
+
+
+def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
+ if verify_ssl:
+ return None
+ return ssl._create_unverified_context()
+
+
+def build_login_url(api_base: str, path: str, password: str) -> str:
+ base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
+ if password:
+ separator = "&" if "?" in base else "?"
+ base = f"{base}{separator}password={urllib.parse.quote(password, safe='')}"
+ return base
+
+
+def extract_access_token(data: Any) -> str | None:
+ if isinstance(data, dict):
+ for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
+ value = data.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ for value in data.values():
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ elif isinstance(data, list):
+ for value in data:
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ return None
+
+
+def parse_json_loose(text: str) -> Any:
+ try:
+ return json.loads(text)
+ except Exception:
+ pass
+ match = re.search(r"[\[{]", text or "")
+ if not match:
+ return {}
+ start = match.start()
+ for end in range(len(text), start, -1):
+ try:
+ return json.loads(text[start:end])
+ except Exception:
+ continue
+ return {}
+
+
+def fetch_access_token(
+ *,
+ api_base: str,
+ username: str = "admin",
+ password: str = "ch@user",
+ login_path: str = "/api/v1/auth/login/username",
+ timeout: float = 90,
+ verify_ssl: bool = False,
+) -> AuthResult:
+ """Login once and return the extracted access token."""
+ url = build_login_url(api_base, login_path, password)
+ payload = {"username": username}
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ parsed = parse_json_loose(raw)
+ token = extract_access_token(parsed)
+ if not token:
+ raise AuthError(
+ "登录接口已响应但未找到 access_token",
+ url=redact_url(url),
+ status=getattr(resp, "status", None),
+ response=raw,
+ )
+ return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
+ raise AuthError(
+ "登录接口返回错误",
+ url=redact_url(url),
+ status=exc.code,
+ response=body,
+ exception=exc,
+ ) from exc
+ except AuthError:
+ raise
+ except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
+ raise AuthError(
+ "登录接口调用异常",
+ url=redact_url(url),
+ response=str(exc),
+ exception=exc,
+ ) from exc
diff --git a/skills-data/xdfx-step3-audience-vulns/scripts/xdfx_flow.py b/skills-data/xdfx-step3-audience-vulns/scripts/xdfx_flow.py
index 52880e82..5f405cc8 100644
--- a/skills-data/xdfx-step3-audience-vulns/scripts/xdfx_flow.py
+++ b/skills-data/xdfx-step3-audience-vulns/scripts/xdfx_flow.py
@@ -26,7 +26,10 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
+import xdfx_auth
+
+# Keep every backend path in one table so endpoint drift is easy to audit.
ENDPOINTS = {
"aiuser": "/gpt/chat/chat_aiuser",
"group": "/gpt/chat/chat_qianqian_group_chat",
@@ -44,10 +47,12 @@ ENDPOINTS = {
"save_narrate": "/api/base/base-narrate",
"save_message": "/api/task/task-action-message",
"save_strategy_relation": "/api/task/task-action-design-strategy-relation",
+ "dict_data": "/api/sys/sys-dict-data",
}
+# Human-readable names are used only in the Chinese feedback logs.
ENDPOINT_LABELS = {
- "login": "登录获取访问 token",
+ "login": "登录获取访问令牌",
"aiuser": "调用候选账号/受众分析模型接口",
"group": "调用受众目标结构化抽取接口",
"spread": "调用关键传播节点结构化抽取接口",
@@ -64,8 +69,86 @@ ENDPOINT_LABELS = {
"save_narrate": "保存策略信息主题",
"save_message": "保存策略消息配置",
"save_strategy_relation": "保存策略与受众/传播节点关系",
+ "dict_data": "读取前端字典数据",
}
+LOG_PREFIXES = {
+ "operation": "操作反馈",
+ "error": "错误反馈",
+ "json_review": "JSON解析复核",
+ "save": "入库反馈",
+ "step": "步骤反馈",
+ "done": "执行完成",
+}
+
+STATUS_LABELS = {
+ "ok": "成功",
+ "partial": "部分完成",
+ "skipped": "已跳过",
+}
+
+STEP_NUMBER_LABELS = {
+ "step1": "第一步",
+ "step2": "第二步",
+ "step3": "第三步",
+ "step4": "第四步",
+ "step5": "第五步",
+ "step6": "第六步",
+}
+
+HTTP_METHOD_LABELS = {
+ "GET": "读取(GET)",
+ "POST": "提交(POST)",
+ "PUT": "更新(PUT)",
+ "PATCH": "局部更新(PATCH)",
+ "DELETE": "删除(DELETE)",
+}
+
+STAGE_SEGMENT_LABELS = {
+ "analyze": "模型分析",
+ "extract": "结构化抽取",
+ "save": "保存数据",
+ "read": "读取数据",
+ "status": "同步状态",
+}
+
+REQUEST_FIELD_LABELS = {
+ "history": "历史消息",
+ "max_tokens": "最大生成令牌数",
+ "model_name": "模型名称",
+ "otherinfo": "其他信息",
+ "prompt_name": "提示词名称",
+ "stream": "是否流式",
+ "temperature": "温度",
+ "usermessage": "用户消息",
+ "user_message": "用户消息",
+ "mubiao": "行动目标",
+ "zhanghao": "账号列表",
+ "taskId": "任务ID",
+ "actionId": "行动ID",
+ "taskActionId": "行动ID",
+ "designId": "受众ID",
+ "pathwayId": "传播节点ID",
+ "strategyId": "策略ID",
+ "accountId": "账号ID",
+ "accountName": "账号名称",
+ "accountType": "账号类型",
+ "targetAudience": "受众目标",
+ "vulnerabilities": "脆弱点",
+ "topic": "信息标题",
+ "narrate": "叙事主题",
+ "platform": "平台",
+ "persona": "发布人设",
+ "modelType": "信息形式",
+ "quantity": "数量",
+ "publishTime": "发布时间段",
+ "reason": "理由",
+ "ids": "ID列表",
+ "id": "ID",
+ "step": "步骤状态",
+}
+
+# Platform values mirror the frontend dictionaries used by xdfx strategy pages.
PLATFORM_MAP = {
"facebook": 1,
"fb": 1,
@@ -78,6 +161,8 @@ PLATFORM_MAP = {
"yt": 4,
}
+# The model service sometimes returns a successful HTTP response with these
+# strings instead of useful content. Treat them like transient failures.
MODEL_RETRY_MARKERS = (
"y模型没有响应",
"模型没有响应",
@@ -118,6 +203,12 @@ class FlowError(RuntimeError):
@dataclass
class FlowContext:
+ """Runtime options shared by every step.
+
+ Keep this object boring and serializable-ish: it is passed through every
+ helper, while durable progress belongs in the state JSON file.
+ """
+
api_base: str
headers: dict[str, str]
timeout: float
@@ -129,6 +220,8 @@ class FlowContext:
max_pathways: int = 3
max_vulns: int = 3
max_strategies: int = 3
+ downstream_audiences: int = 2
+ downstream_pathways: int = 2
sync_step_status: bool = True
model_retries: int = 1
verify_ssl: bool = False
@@ -153,7 +246,7 @@ def compact(value: Any, limit: int = 1400) -> str:
text = repr(value)
if len(text) <= limit:
return text
- return text[:limit] + f"... "
+ return text[:limit] + f"... <已截断 {len(text) - limit} 个字符>"
def stable_json(value: Any) -> str:
@@ -223,6 +316,11 @@ def save_json_file(path: str | Path, value: Any) -> None:
def default_state() -> dict[str, Any]:
+ """Create a resumable state container.
+
+ The script can be run as six independent commands. IDs returned by one step
+ are persisted here so later steps can resume without redoing earlier saves.
+ """
return {
"api_base": "",
"taskId": "",
@@ -247,6 +345,7 @@ def default_state() -> dict[str, Any]:
def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> None:
+ # Accept both frontend-style camelCase and CLI-friendly snake_case inputs.
aliases = {
"task_id": "taskId",
"action_id": "actionId",
@@ -260,16 +359,12 @@ def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> No
continue
if dest in {"taskId", "actionId"}:
state[dest] = str(value)
- elif dest in state or dest in {
- "api_base",
- "headers",
- "auth_token",
- "authorization",
- }:
+ elif dest in state or dest in {"api_base", "headers"}:
state[dest] = value
def record_event(state: dict[str, Any], stage: str, message: str, data: Any | None = None) -> None:
+ """Append a compact diagnostic event to the state file."""
state.setdefault("events", []).append(
{
"time": utc_now(),
@@ -286,9 +381,13 @@ def feedback_step(stage: str) -> str:
return match.group(1) if match else ""
+def feedback_step_label(stage: str) -> str:
+ return STEP_NUMBER_LABELS.get(feedback_step(stage), "")
+
+
def request_keys(payload: Any) -> list[str]:
if isinstance(payload, dict):
- return sorted(str(key) for key in payload.keys())
+ return sorted(REQUEST_FIELD_LABELS.get(str(key), str(key)) for key in payload.keys())
return []
@@ -306,6 +405,77 @@ def endpoint_label(endpoint_key: str | None, method: str, url: str) -> str:
return f"调用接口 {method}"
+def method_label(method: str) -> str:
+ return HTTP_METHOD_LABELS.get(str(method).upper(), str(method))
+
+
+def yes_no(value: bool) -> str:
+ return "是" if value else "否"
+
+
+def stage_label(stage: Any) -> str:
+ text = str(stage or "")
+ if not text:
+ return ""
+ if text in STEP_LABELS:
+ return STEP_LABELS[text]
+ if text == "runner":
+ return "流程执行器"
+ parts = text.split("/")
+ base = parts[0]
+ match = re.search(r"(step[1-6])", base)
+ labels: list[str] = []
+ if match:
+ labels.append(STEP_LABELS.get(match.group(1), match.group(1)))
+ elif base == "runner":
+ labels.append("流程执行器")
+ else:
+ labels.append(base)
+ for segment in parts[1:]:
+ normalized = segment.replace("-", "_")
+ attempt = re.fullmatch(r"model-call-attempt-(\d+)", segment)
+ json_attempt = re.fullmatch(r"model-json-attempt-(\d+)", segment)
+ if attempt:
+ labels.append(f"模型接口第 {attempt.group(1)} 次尝试")
+ elif json_attempt:
+ labels.append(f"JSON解析第 {json_attempt.group(1)} 次尝试")
+ elif segment == "auto-login":
+ labels.append("自动登录")
+ elif segment in STAGE_SEGMENT_LABELS:
+ labels.append(STAGE_SEGMENT_LABELS[segment])
+ elif normalized in ENDPOINT_LABELS:
+ labels.append(ENDPOINT_LABELS[normalized])
+ else:
+ labels.append(segment)
+ return " / ".join(labels)
+
+
+def status_label(status: Any) -> str:
+ if status is None:
+ return ""
+ return STATUS_LABELS.get(str(status), str(status))
+
+
+def mode_label(mode: str) -> str:
+ if mode == "full":
+ return "全流程"
+ return STEP_LABELS.get(mode, mode)
+
+
+def exception_type_label(exception_type: Any) -> str:
+ name = str(exception_type or "")
+ labels = {
+ "HTTPError": "HTTP 调用异常",
+ "URLError": "网络地址异常",
+ "TimeoutError": "请求超时异常",
+ "FlowError": "流程异常",
+ "ValueError": "数据格式异常",
+ "KeyError": "字段缺失异常",
+ "RuntimeError": "运行异常",
+ }
+ return labels.get(name, "程序异常" if name else "")
+
+
def emit_operation_feedback(
state: dict[str, Any],
stage: str,
@@ -317,39 +487,41 @@ def emit_operation_feedback(
attempt: int = 1,
dry_run: bool = False,
) -> None:
+ # Feedback lines are a mini protocol consumed by the calling agent. They
+ # must stay single-line JSON so weak models/tools can relay them reliably.
payload_obj = {
- "type": "OP_FEEDBACK",
- "step": feedback_step(stage),
- "stage": stage,
- "operation": endpoint_label(endpoint_key, method, url),
- "method": method,
- "url": url,
- "endpoint": endpoint_key or "",
- "attempt": attempt,
- "dryRun": dry_run,
- "requestKeys": request_keys(payload),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "操作反馈",
+ "步骤": feedback_step_label(stage),
+ "阶段": stage_label(stage),
+ "操作": endpoint_label(endpoint_key, method, url),
+ "请求方式": method_label(method),
+ "接口地址": url,
+ "接口名称": endpoint_label(endpoint_key, method, url),
+ "第几次尝试": attempt,
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("OP_FEEDBACK " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["operation"] + " " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
def emit_error_feedback(error: dict[str, Any]) -> None:
payload = {
- "type": "ERROR_FEEDBACK",
- "stage": error.get("stage"),
- "step": feedback_step(str(error.get("stage") or "")),
- "message": error.get("message"),
- "url": error.get("url"),
- "status": error.get("status"),
- "request": error.get("request"),
- "response": error.get("response"),
- "exceptionType": error.get("exceptionType"),
- "exception": error.get("exception"),
- "time": error.get("time"),
+ "日志类型": "错误反馈",
+ "阶段": stage_label(error.get("stage")),
+ "步骤": feedback_step_label(str(error.get("stage") or "")),
+ "错误信息": error.get("message"),
+ "接口地址": error.get("url"),
+ "状态码": error.get("status"),
+ "请求摘要": error.get("request"),
+ "响应摘要": error.get("response"),
+ "异常类型": exception_type_label(error.get("exceptionType")),
+ "异常信息": error.get("exception"),
+ "时间": error.get("time"),
}
- print("ERROR_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["error"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def emit_json_parse_review(
@@ -360,18 +532,89 @@ def emit_json_parse_review(
candidate_count: int,
) -> None:
payload = {
- "type": "JSON_PARSE_REVIEW",
- "stage": stage,
- "step": feedback_step(stage),
- "endpoint": endpoint_key,
- "message": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
- "candidateCount": candidate_count,
- "responseExcerpt": compact(text, 3000),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "JSON解析复核",
+ "阶段": stage_label(stage),
+ "步骤": feedback_step_label(stage),
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, endpoint_key),
+ "提示": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
+ "候选数量": candidate_count,
+ "响应片段": compact(text, 3000),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("JSON_PARSE_REVIEW " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["json_review"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
+
+
+SAVE_ENTITY_LABELS = {
+ "save_design": "受众目标",
+ "save_design_account": "受众目标账号",
+ "save_pathway": "关键传播节点",
+ "save_relation": "受众-传播节点关系",
+ "save_design_vuln": "受众脆弱点",
+ "save_pathway_vuln": "传播节点脆弱点",
+ "save_strategy": "策略",
+ "save_channel": "策略平台与人设",
+ "save_narrate": "策略信息主题",
+ "save_message": "策略消息配置",
+ "save_strategy_relation": "策略关联关系",
+}
+
+
+def saved_entity_name(endpoint_key: str, payload: dict[str, Any]) -> str:
+ """Return the most user-readable name for an inserted row."""
+ candidates = [
+ payload.get("targetAudience"),
+ payload.get("accountName"),
+ payload.get("vulnerabilities"),
+ payload.get("topic"),
+ payload.get("narrate"),
+ payload.get("platform"),
+ payload.get("strategyId"),
+ payload.get("designId"),
+ payload.get("pathwayId"),
+ ]
+ for value in candidates:
+ if value is not None and str(value).strip():
+ return str(value).strip()
+ return endpoint_key
+
+
+def emit_save_feedback(
+ state: dict[str, Any],
+ stage: str,
+ endpoint_key: str,
+ payload: dict[str, Any],
+ row_id: int,
+ *,
+ dry_run: bool,
+) -> None:
+ # Emit only after an id is known. Downstream steps use these ids and should
+ # fail loudly rather than inventing relations against missing rows.
+ label = SAVE_ENTITY_LABELS.get(endpoint_key, endpoint_key)
+ name = saved_entity_name(endpoint_key, payload)
+ feedback = {
+ "日志类型": "入库反馈",
+ "步骤": feedback_step(stage),
+ "阶段": stage,
+ "入库对象类型": label,
+ "入库对象名称": name,
+ "返回ID": row_id,
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, label),
+ "接口路径": ENDPOINTS.get(endpoint_key, ""),
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
+ }
+ print(LOG_PREFIXES["save"] + " " + json.dumps(feedback, ensure_ascii=False), flush=True)
+ record_event(
+ state,
+ stage,
+ f"入库{label}成功:{name}",
+ {"id": row_id, "endpoint": endpoint_key, "dryRun": dry_run},
+ )
def record_error(
@@ -385,6 +628,8 @@ def record_error(
response: Any | None = None,
exception: BaseException | None = None,
) -> None:
+ # Persist full-enough error context for post-run diagnosis, then immediately
+ # emit a short line the agent can show to the user.
item = {
"time": utc_now(),
"stage": stage,
@@ -430,65 +675,25 @@ def url_for_path(ctx: FlowContext, path: str) -> str:
return ctx.api_base.rstrip("/") + "/" + path.lstrip("/")
-def parse_headers(header_values: list[str], auth_token: str | None, authorization: str | None) -> dict[str, str]:
+def parse_headers(header_values: list[str]) -> dict[str, str]:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
- if auth_token:
- headers["Authorization"] = auth_token if auth_token.lower().startswith("bearer ") else f"Bearer {auth_token}"
- if authorization:
- headers["Authorization"] = authorization
for raw in header_values:
if ":" not in raw:
raise FlowError(f"header 格式错误,应为 Key: Value,实际:{raw}")
key, value = raw.split(":", 1)
- headers[key.strip()] = value.strip()
+ normalized = key.strip()
+ if normalized.lower() == "authorization":
+ continue
+ headers[normalized] = value.strip()
return headers
-def has_authorization(headers: dict[str, str]) -> bool:
- return any(key.lower() == "authorization" and str(value).strip() for key, value in headers.items())
-
-
def ssl_context(ctx: FlowContext) -> ssl.SSLContext | None:
if ctx.verify_ssl:
return None
return ssl._create_unverified_context()
-def auth_header_value(token: str) -> str:
- token = token.strip()
- return token if token.lower().startswith("bearer ") else f"Bearer {token}"
-
-
-def extract_access_token(data: Any) -> str | None:
- if isinstance(data, dict):
- for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
- value = data.get(key)
- if isinstance(value, str) and value.strip():
- return value.strip()
- for value in data.values():
- nested = extract_access_token(value)
- if nested:
- return nested
- elif isinstance(data, list):
- for value in data:
- nested = extract_access_token(value)
- if nested:
- return nested
- return None
-
-
-def redact_url(url: str) -> str:
- return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
-
-
-def build_login_url(ctx: FlowContext, path: str) -> str:
- base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else url_for_path(ctx, path)
- if ctx.login_password:
- separator = "&" if "?" in base else "?"
- base = f"{base}{separator}password={urllib.parse.quote(ctx.login_password, safe='')}"
- return base
-
-
def login_candidate_paths(ctx: FlowContext) -> list[str]:
paths = [
ctx.login_path or "/api/v1/auth/login/username",
@@ -506,6 +711,7 @@ def login_candidate_paths(ctx: FlowContext) -> list[str]:
def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force: bool = False) -> bool:
+ """Fetch a fresh token through the local auth package before business calls."""
if not ctx.auto_login:
return False
if ctx.dry_run:
@@ -515,7 +721,7 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
record_error(state, stage, "自动登录失败:缺少 login_username")
return False
if not force and ctx.login_attempt_count > 0:
- return has_authorization(ctx.headers)
+ return any(key.lower() == "authorization" and str(value).strip() for key, value in ctx.headers.items())
if force and ctx.login_attempt_count >= 2:
return False
@@ -525,8 +731,7 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
last_response = ""
last_exc: BaseException | None = None
for attempt, path in enumerate(login_candidate_paths(ctx), start=1):
- url = build_login_url(ctx, path)
- display_url = redact_url(url)
+ display_url = xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, path, ctx.login_password))
emit_operation_feedback(
state,
stage,
@@ -537,63 +742,39 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
attempt=attempt,
)
try:
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- headers = {
- "Content-Type": "application/json",
- "Accept": "application/json",
- }
- req = urllib.request.Request(url, data=data, headers=headers, method="POST")
- with urllib.request.urlopen(req, timeout=ctx.timeout, context=ssl_context(ctx)) as resp:
- raw = resp.read().decode("utf-8", errors="replace")
- parsed = parse_json_lenient(raw)
- token = extract_access_token(parsed)
- if not token:
- last_status = getattr(resp, "status", None)
- last_response = raw
- record_event(
- state,
- stage,
- "登录接口已响应但未找到 access_token",
- {"url": display_url, "status": last_status, "response": compact(raw, 1000)},
- )
- continue
- ctx.headers["Authorization"] = auth_header_value(token)
- record_event(
- state,
- stage,
- "自动登录成功,已写入 Authorization Bearer token",
- {
- "url": display_url,
- "status": getattr(resp, "status", None),
- "username": ctx.login_username,
- },
- )
- return True
- except urllib.error.HTTPError as exc:
- last_status = exc.code
- last_response = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
- last_exc = exc
- record_event(
- state,
- stage,
- "登录接口返回错误,准备尝试下一个候选登录路径",
- {"url": display_url, "status": exc.code, "response": compact(last_response, 1000)},
+ result = xdfx_auth.fetch_access_token(
+ api_base=ctx.api_base,
+ username=ctx.login_username,
+ password=ctx.login_password,
+ login_path=path,
+ timeout=ctx.timeout,
+ verify_ssl=ctx.verify_ssl,
)
- except Exception as exc: # noqa: BLE001 - auto-login must report every failure.
- last_exc = exc
- last_response = traceback.format_exc()
+ ctx.headers["Authorization"] = xdfx_auth.auth_header_value(result.token)
+ state["_tokenSource"] = "xdfx_auth"
record_event(
state,
stage,
- "登录接口调用异常,准备尝试下一个候选登录路径",
- {"url": display_url, "exceptionType": type(exc).__name__, "exception": str(exc)},
+ "启动阶段已通过 xdfx_auth 包获取 token,并写入 Authorization",
+ {"url": result.url, "status": result.status, "username": ctx.login_username},
+ )
+ return True
+ except xdfx_auth.AuthError as exc:
+ last_status = exc.status
+ last_response = exc.response
+ last_exc = exc
+ record_event(
+ state,
+ stage,
+ "xdfx_auth 获取 token 失败,准备尝试下一个候选登录路径",
+ {"url": exc.url, "status": exc.status, "response": compact(exc.response, 1000), "exception": str(exc)},
)
record_error(
state,
stage,
- "自动登录失败,未能获取 access_token",
- url=redact_url(build_login_url(ctx, ctx.login_path or "/api/v1/auth/login/username")),
+ "自动登录失败,未能通过 xdfx_auth 获取 access_token",
+ url=xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, ctx.login_path or "/api/v1/auth/login/username", ctx.login_password)),
status=last_status,
request=payload,
response=last_response,
@@ -612,6 +793,7 @@ def http_request_json_url(
endpoint_key: str | None = None,
allow_auto_login: bool = True,
) -> tuple[Any | None, str]:
+ """Call an HTTP JSON endpoint with logging, retries, and 401 recovery."""
method = method.upper()
if ctx.dry_run:
emit_operation_feedback(state, stage, method, url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
@@ -640,6 +822,7 @@ def http_request_json_url(
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
if exc.code == 401 and allow_auto_login and ctx.auto_login:
+ # Refresh once on 401, then replay the original request exactly.
refreshed = try_auto_login(ctx, state, f"{stage}/auto-login", force=True)
if refreshed:
record_event(
@@ -709,6 +892,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
emit_operation_feedback(state, stage, "POST", url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
record_event(state, stage, f"DRY RUN POST {url}", {"payload": payload})
if endpoint_key.startswith("save_"):
+ # Synthetic ids let downstream dry-run code exercise relation wiring.
synthetic_id = abs(hash(stage + stable_json(payload))) % 100000000
return {"id": synthetic_id, "dryRun": True}, json.dumps({"id": synthetic_id, "dryRun": True})
return {"dryRun": True}, json.dumps({"dryRun": True})
@@ -716,6 +900,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
def parse_json_lenient(text: str) -> Any | None:
+ """Parse normal JSON plus adjacent/SSE-like JSON objects."""
stripped = text.strip()
if not stripped:
return None
@@ -730,6 +915,7 @@ def parse_json_lenient(text: str) -> Any | None:
def parse_concatenated_json_objects(text: str) -> list[Any]:
+ """Read streams such as {"answer": "..."}{"answer": "..."} or data: lines."""
decoder = json.JSONDecoder()
objects: list[Any] = []
i = 0
@@ -766,6 +952,7 @@ def parse_concatenated_json_objects(text: str) -> list[Any]:
def model_text_from_response(parsed: Any, raw: str) -> tuple[str, list[Any]]:
+ """Flatten streamed model chunks into one text buffer."""
objects: list[Any]
if isinstance(parsed, list):
objects = parsed
@@ -824,6 +1011,7 @@ def extract_json_from_text(text: str) -> Any | None:
def extract_json_candidates_from_text(text: str) -> list[Any]:
+ """Collect every plausible JSON object/array from messy model text."""
cleaned = strip_think(text)
candidates: list[Any] = []
seen: set[str] = set()
@@ -860,6 +1048,7 @@ def extract_json_candidates_from_text(text: str) -> list[Any]:
def select_json_candidate(candidates: list[Any], endpoint_key: str, stage: str) -> Any | None:
+ """Pick the candidate whose keys best match the current step."""
if not candidates:
return None
scored = [(json_candidate_score(candidate, endpoint_key, stage), idx, candidate) for idx, candidate in enumerate(candidates)]
@@ -1010,6 +1199,7 @@ def extract_first_balanced(text: str, open_ch: str, close_ch: str) -> str | None
def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[str, list[Any], Any | None]:
+ """Call a model endpoint and retry only known empty/timeout/no-response cases."""
max_attempts = max(ctx.model_retries, 0) + 1
last_text = ""
last_objects: list[Any] = []
@@ -1048,6 +1238,11 @@ def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str,
def call_model_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[dict[str, Any], str, list[Any]]:
+ """Return the best structured JSON for a model step.
+
+ If the first parse fails, the second attempt includes an explicit repair
+ instruction so weak models can re-emit only the required JSON.
+ """
last_text = ""
last_objects: list[Any] = []
last_candidate_count = 0
@@ -1202,23 +1397,85 @@ def profile_info(account: dict[str, Any]) -> str:
def parse_index_list(value: Any) -> list[str]:
- if value is None:
- return []
- if isinstance(value, list):
- return [str(v).strip() for v in value if str(v).strip()]
- text = str(value)
- return [part.strip().strip("<>").strip() for part in re.split(r"[,,;;、\s]+", text) if part.strip()]
+ refs: list[str] = []
+
+ def add(raw: Any) -> None:
+ if raw is None:
+ return
+ if isinstance(raw, dict):
+ keys = (
+ "编号id",
+ "编号ID",
+ "用户编号id",
+ "用户编号ID",
+ "用户编号",
+ "账号编号",
+ "引用序号",
+ "索引",
+ "index",
+ "id",
+ "ids",
+ "accountIndex",
+ "accountIndexes",
+ "userIndex",
+ "userIndexes",
+ )
+ for key in keys:
+ if key in raw:
+ add(raw.get(key))
+ return
+ if isinstance(raw, (list, tuple, set)):
+ for item in raw:
+ add(item)
+ return
+ text = str(raw).strip()
+ if not text:
+ return
+ angle_refs = re.findall(r"<<\s*([^<>]+?)\s*>>", text)
+ if angle_refs:
+ for token in angle_refs:
+ add(token)
+ return
+ for part in re.split(r"[,,;;、\s]+", text):
+ token = part.strip().strip("<>").strip()
+ if token:
+ refs.append(token)
+
+ add(value)
+ deduped: list[str] = []
+ seen: set[str] = set()
+ for ref in refs:
+ if ref in seen:
+ continue
+ seen.add(ref)
+ deduped.append(ref)
+ return deduped
+
+
+def account_ref_numbers(account: dict[str, Any]) -> set[int]:
+ numbers: set[int] = set()
+ meta = account.get("metadata") if isinstance(account.get("metadata"), dict) else {}
+ for key in ("编号id", "编号ID", "用户编号id", "用户编号ID", "用户编号", "账号编号", "引用序号", "索引", "index", "accountIndex"):
+ for container in (account, meta):
+ number = as_int(container.get(key)) if isinstance(container, dict) else None
+ if number is not None:
+ numbers.add(number)
+ return numbers
def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, Any] | None:
+ """Resolve model references like <<3>>, index numbers, names, or account ids."""
if not accounts:
return None
text = str(ref).strip().strip("<>").strip()
number = as_int(text)
if number is not None:
for account in accounts:
- if any(as_int(account.get(key)) == number for key in ("引用序号", "索引", "index")):
+ if number in account_ref_numbers(account):
return account
+ # Step1 group extraction returns 编号/id values that refer to the
+ # previous zhanghao array index. Prefer exact index fields above, then
+ # fall back to zero-based and one-based array positions.
if 0 <= number < len(accounts):
return accounts[number]
if 1 <= number <= len(accounts):
@@ -1232,11 +1489,25 @@ def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, A
def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Normalize audience JSON whether the model used numbered fields or items[]."""
items: list[dict[str, Any]] = []
for i in range(1, 21):
group = first_present(data.get(f"群体{i}"), data.get(f"受众{i}"), data.get(f"targetAudience{i}"))
reason = first_present(data.get(f"理由{i}"), data.get(f"推荐理由{i}"), data.get(f"recReason{i}"))
- users = first_present(data.get(f"用户编号列表{i}"), data.get(f"用户列表{i}"), data.get(f"账号编号列表{i}"))
+ users = first_present(
+ data.get(f"用户编号列表{i}"),
+ data.get(f"编号id列表{i}"),
+ data.get(f"编号ID列表{i}"),
+ data.get(f"用户编号id列表{i}"),
+ data.get(f"用户编号ID列表{i}"),
+ data.get(f"用户列表{i}"),
+ data.get(f"账号编号列表{i}"),
+ data.get(f"索引列表{i}"),
+ data.get(f"编号{i}"),
+ data.get(f"id{i}"),
+ data.get(f"ids{i}"),
+ data.get(f"索引{i}"),
+ )
if group:
items.append(
{
@@ -1255,7 +1526,27 @@ def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
{
"targetAudience": str(group).strip(),
"recReason": str(first_present(item.get("理由"), item.get("recReason"), item.get("reason")) or "").strip(),
- "userIndexes": parse_index_list(first_present(item.get("用户编号列表"), item.get("users"), item.get("accounts"))),
+ "userIndexes": parse_index_list(
+ first_present(
+ item.get("用户编号列表"),
+ item.get("编号id列表"),
+ item.get("编号ID列表"),
+ item.get("用户编号id列表"),
+ item.get("用户编号ID列表"),
+ item.get("编号id"),
+ item.get("编号ID"),
+ item.get("用户编号id"),
+ item.get("用户编号ID"),
+ item.get("编号"),
+ item.get("索引"),
+ item.get("id"),
+ item.get("ids"),
+ item.get("users"),
+ item.get("accounts"),
+ item.get("userIndexes"),
+ item.get("accountIndexes"),
+ )
+ ),
"rank": i,
}
)
@@ -1272,6 +1563,7 @@ def audience_score(item: dict[str, Any]) -> tuple[int, int]:
def select_top_audiences(items: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
+ """Deduplicate audience names and keep the strongest candidates."""
unique: dict[str, dict[str, Any]] = {}
for item in items:
key = item.get("targetAudience", "")
@@ -1301,6 +1593,7 @@ def pathway_score(item: dict[str, Any]) -> tuple[int, int, int]:
def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Merge the same account found from different audiences before saving top 3."""
merged: dict[str, dict[str, Any]] = {}
for item in candidates:
account = item.get("account") or {}
@@ -1325,6 +1618,7 @@ def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str,
def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
+ """Normalize weak-point model output into a capped list."""
vulns: list[dict[str, str]] = []
for i in range(1, 21):
vuln = first_present(data.get(f"脆弱点{i}"), data.get(f"vulnerability{i}"), data.get(f"vuln{i}"))
@@ -1339,6 +1633,7 @@ def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
def extract_strategies(data: dict[str, Any], limit: int) -> list[dict[str, Any]]:
+ """Normalize strategy output and preserve the modal text for later mapping."""
strategies: list[dict[str, Any]] = []
for i in range(1, 21):
title = first_present(data.get(f"信息标题{i}"), data.get(f"主题{i}"), data.get(f"topic{i}"))
@@ -1395,11 +1690,29 @@ def response_id(response: Any) -> int | None:
return None
+def response_rows(response: Any) -> list[Any]:
+ """Extract list rows from common CRUD response shapes."""
+ if isinstance(response, list):
+ return response
+ if not isinstance(response, dict):
+ return []
+ for key in ("data", "rows", "items", "records", "list"):
+ value = response.get(key)
+ if isinstance(value, list):
+ return value
+ rows = response_rows(value)
+ if rows:
+ return rows
+ return []
+
+
def save_with_id(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[int | None, Any | None]:
response, _ = http_post_json(ctx, state, endpoint_key, payload, stage)
rid = response_id(response)
if rid is None:
- record_error(state, stage, "保存接口未返回 id,视为失败", request=payload, response=response)
+ record_error(state, stage, "保存接口未返回 ID,视为失败", request=payload, response=response)
+ else:
+ emit_save_feedback(state, stage, endpoint_key, payload, rid, dry_run=ctx.dry_run)
return rid, response
@@ -1429,6 +1742,7 @@ def parse_step_flags(raw: Any) -> dict[str, Any]:
def mark_step_status(ctx: FlowContext, state: dict[str, Any], links: list[str], stage: str) -> None:
+ """Update the frontend step bar without overwriting unrelated step flags."""
if not ctx.sync_step_status:
record_event(state, stage, "跳过 task-action.step 状态同步")
return
@@ -1477,12 +1791,126 @@ def strategy_platform(value: Any) -> Any:
return platform_to_number(value) or 1
-def strategy_model_type(value: Any) -> str:
- # Frontend defaults to text model type "1" when no modal type is selected.
- raw = first_present(value, "1")
- if isinstance(raw, list):
- return ",".join(str(v) for v in raw if str(v).strip()) or "1"
- return str(raw or "1")
+def choice_tokens(value: Any) -> list[Any]:
+ """Split frontend choice values from arrays, JSON strings, and mixed delimiters."""
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return choice_tokens(
+ first_present(value.get("value"), value.get("dictValue"), value.get("label"), value.get("dictLabel"))
+ )
+ if isinstance(value, (list, tuple, set)):
+ tokens: list[Any] = []
+ for item in value:
+ tokens.extend(choice_tokens(item))
+ return tokens
+ text = str(value).strip()
+ if not text:
+ return []
+ if text[0] in "[{":
+ try:
+ parsed = json.loads(text)
+ if parsed != value:
+ return choice_tokens(parsed)
+ except Exception:
+ pass
+ return [part.strip() for part in re.split(r"[,,、;/|]+", text) if part.strip()]
+
+
+def dict_choices(
+ ctx: FlowContext,
+ state: dict[str, Any],
+ dict_type: str,
+ stage: str,
+) -> list[dict[str, Any]]:
+ """Fetch and cache frontend dictionary choices such as modelType."""
+ cache = state.setdefault("_dictChoiceCache", {})
+ if dict_type in cache:
+ cached = cache[dict_type]
+ return cached if isinstance(cached, list) else []
+ if ctx.dry_run:
+ cache[dict_type] = []
+ return []
+
+ query = urllib.parse.urlencode({"filter": f"dictType||$eq||{dict_type}", "limit": 999, "page": 1})
+ url = f"{url_for(ctx, 'dict_data')}?{query}"
+ response, _ = http_request_json_url(ctx, state, "GET", url, None, stage, endpoint_key="dict_data")
+ choices: list[dict[str, Any]] = []
+ for row in response_rows(response):
+ if not isinstance(row, dict):
+ continue
+ label = str(first_present(row.get("dictLabel"), row.get("label"), "") or "").strip()
+ raw_value = first_present(row.get("dictValue"), row.get("value"))
+ if not label or raw_value is None:
+ continue
+ number = as_int(raw_value)
+ choices.append({"label": label, "value": number if number is not None else str(raw_value).strip()})
+ for alias in choice_tokens(row.get("alias")):
+ alias_label = str(alias).strip()
+ if alias_label:
+ choices.append({"label": alias_label, "value": number if number is not None else str(raw_value).strip()})
+ cache[dict_type] = choices
+ return choices
+
+
+def choice_value_by_label(choices: list[dict[str, Any]] | None, label: str) -> Any | None:
+ needle = re.sub(r"\s+", "", str(label or "").lower())
+ if not needle:
+ return None
+ for item in choices or []:
+ item_label = re.sub(r"\s+", "", str(item.get("label") or "").lower())
+ if item_label and (needle == item_label or needle in item_label or item_label in needle):
+ return item.get("value")
+ return None
+
+
+def model_type_token_value(token: Any, choices: list[dict[str, Any]] | None = None) -> Any:
+ """Convert modal labels like 图片/视频 into frontend dictionary values."""
+ number = as_int(token)
+ if number is not None:
+ return number
+
+ text = str(token or "").strip()
+ lower = text.lower()
+ exact = choice_value_by_label(choices, text)
+ if exact is not None:
+ return exact
+
+ label = ""
+ fallback_value = 2
+ if "视" in text or "video" in lower:
+ label, fallback_value = "视频", 3
+ elif "图" in text or "image" in lower or "picture" in lower or "photo" in lower:
+ label, fallback_value = "图片", 2
+ elif "音" in text or "audio" in lower or "sound" in lower:
+ label, fallback_value = "音频", 4
+ elif "发文" in text or "文本" in text or "文字" in text or "text" in lower:
+ label, fallback_value = "文本", 1
+
+ if label:
+ matched = choice_value_by_label(choices, label)
+ if matched is not None:
+ return matched
+ return fallback_value
+
+ # Mirrors the frontend helper's unknown-token default.
+ return 2
+
+
+def strategy_model_type(value: Any, choices: list[dict[str, Any]] | None = None) -> str:
+ """Return the comma-separated value string expected by task-action-message."""
+ tokens = choice_tokens(value)
+ if not tokens:
+ # Frontend save paths fall back to "1" when no modal type is selected.
+ return "1"
+ values: list[str] = []
+ seen: set[str] = set()
+ for token in tokens:
+ normalized = str(model_type_token_value(token, choices)).strip()
+ if normalized and normalized not in seen:
+ values.append(normalized)
+ seen.add(normalized)
+ return ",".join(values) or "1"
def strategy_language(value: Any) -> int:
@@ -1501,11 +1929,28 @@ def save_strategy_details(
*,
audience: bool,
) -> None:
- platform = strategy_platform(first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})))
+ """Save all frontend-readable strategy child rows.
+
+ The strategy table and arrangement page do not read only the main strategy
+ row; they also expect channel, narrate, message, and relation rows.
+ """
+ platform = strategy_platform(
+ first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
+ )
+ account_type = str(
+ first_present(
+ strategy.get("accountType"),
+ item.get("accountType"),
+ item.get("type"),
+ "目标受众账号" if audience else "关键传播节点账号",
+ )
+ or ""
+ ).strip()
persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
title = str(strategy.get("title") or "").strip()
+ model_type_choices = dict_choices(ctx, state, "modelType", stage + "/dict-modelType")
channel_id, _ = save_with_id(
ctx,
@@ -1516,6 +1961,7 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"platform": platform,
+ "accountType": account_type,
"personaJson": json.dumps([{"persona": persona, "size": persona_size}], ensure_ascii=False),
},
stage + "/save-channel",
@@ -1531,7 +1977,10 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"channelId": channel_id,
- "modelType": strategy_model_type(first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType"))),
+ "modelType": strategy_model_type(
+ first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType")),
+ model_type_choices,
+ ),
"language": strategy_language(first_present(strategy.get("language"), strategy.get("语种"))),
"respondingCount": responding_count,
"messageCount": responding_count,
@@ -1564,7 +2013,39 @@ def object_for_audience(audience: dict[str, Any]) -> dict[str, Any]:
}
+def downstream_limit(ctx: FlowContext, *, audience: bool) -> int:
+ """Limit expensive downstream analysis while keeping 3 upstream rows stored."""
+ raw = ctx.downstream_audiences if audience else ctx.downstream_pathways
+ return max(1, raw)
+
+
+def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, audience: bool) -> list[dict[str, Any]]:
+ """Select the 1-2 best stored rows for vulnerability/strategy generation."""
+ source_key = "topAudiences" if audience else "topPathways"
+ id_key = "designId" if audience else "pathwayAccountId"
+ selected_key = "selectedDownstreamAudiences" if audience else "selectedDownstreamPathways"
+ items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)]
+ limit = downstream_limit(ctx, audience=audience)
+ selected = items[:limit]
+ state[selected_key] = [
+ {
+ "id": item.get(id_key),
+ "name": item.get("targetAudience") if audience else item.get("accountName"),
+ }
+ for item in selected
+ ]
+ if items:
+ record_event(
+ state,
+ stage,
+ f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}",
+ {"limit": limit, "selected": state[selected_key]},
+ )
+ return selected
+
+
def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze audiences, save top 3, and attach any referenced accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step1-audience"
if state.get("topAudiences") and not ctx.dry_run:
@@ -1582,12 +2063,15 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
state["audienceRawText"] = text
+ # The first model call may return raw text plus embedded account objects.
accounts = accounts_from_response(parsed, objects, text)
state["audienceAccounts"] = accounts
+ # The second model call converts the long audience analysis into numbered
+ # groups that can be saved as task-action-design rows.
group_payload = {
"mubiao": state["mubiao"],
- "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
"zhanghao": accounts,
"history": [],
"stream": True,
@@ -1602,6 +2086,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
top = select_top_audiences(candidates, ctx.max_audiences)
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the audience first; account rows and downstream analyses depend
+ # on the returned designId.
design_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
@@ -1614,6 +2100,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
continue
item = {**item, "designId": design_id, "saveResponse": resp, "accounts": [], "savedAccountIds": []}
for ref in item.get("userIndexes") or []:
+ # Model references are intentionally fuzzy, so resolve them against
+ # both explicit indexes and account names/ids.
account = resolve_account_ref(ref, accounts)
if account is None:
record_error(state, stage + "/save-design-account", f"用户编号 {ref} 未匹配到候选账号")
@@ -1645,6 +2133,7 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze key pathway accounts for saved audiences and store top 3."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step2-pathway"
audiences = [a for a in state.get("topAudiences") or [] if a.get("designId")]
@@ -1656,6 +2145,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
return
all_candidates: list[dict[str, Any]] = []
for audience in audiences:
+ # Analyze pathway candidates in the context of each saved audience.
payload = {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。",
"otherinfo": {
@@ -1673,6 +2163,8 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
accounts = accounts_from_response(parsed, objects, text)
+ # Ask the model to point back to candidate accounts, then resolve those
+ # references to concrete account objects.
spread_payload = {
"mubiao": state["mubiao"],
"user_message": "分析其中关键信息。请从候选账号中抽取核心传播节点,字段必须是:账号1、理由1、账号2、理由2 ...,最多输出5个。账号字段优先返回候选数组的引用编号或索引,只返回JSON。",
@@ -1707,6 +2199,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways]
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the account row before writing the design-pathway relation.
account = item["account"]
aid = account_id(account)
name = account_name(account)
@@ -1757,12 +2250,14 @@ def run_step4(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate and save weak points for selected audiences or pathway accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step3-audience-vulns" if audience else "step4-pathway-vulns"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
output_key = "audienceVulns" if audience else "pathwayVulns"
state.setdefault(output_key, {})
for item in items:
+ # owner_id is designId for audiences and pathwayAccountId for accounts.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
if not owner_id:
continue
@@ -1789,6 +2284,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) ->
vulns = extract_vulns(data, ctx.max_vulns)
saved: list[dict[str, Any]] = []
for vuln in vulns:
+ # The two weak-point tables have nearly identical payloads but
+ # different foreign-key names.
if audience:
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
@@ -1835,14 +2332,16 @@ def run_step6(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate strategies from saved weak points and persist frontend child rows."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step5-audience-strategy" if audience else "step6-pathway-strategy"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {}
output_key = "audienceStrategies" if audience else "pathwayStrategies"
state.setdefault(output_key, [])
existing_topics = {s.get("topic") for s in state[output_key] if isinstance(s, dict)}
for item in items:
+ # Strategies are only meaningful after the matching weak points exist.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
vulns = vulns_by_owner.get(str(owner_id)) or []
vuln_texts = [v.get("vulnerabilities") for v in vulns if v.get("vulnerabilities")]
@@ -1876,16 +2375,20 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
continue
if topic in existing_topics:
continue
+ # Save the main row first; channel/narrate/message/relation rows all
+ # require the returned strategy id.
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
"targetAudience": target,
"accountType": account_type,
"topic": topic,
- "strategyTitle": "",
+ "strategyTitle": str(strategy.get("title") or "").strip(),
}
strategy_id, resp = save_with_id(ctx, state, "save_strategy", save_payload, stage + "/save")
if strategy_id is not None:
+ # Frontend strategy pages read these child tables, so a strategy
+ # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience)
existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp})
@@ -1914,13 +2417,14 @@ STEP_LABELS = {
def state_counts(state: dict[str, Any]) -> dict[str, int]:
+ """Summarize stored objects for step feedback."""
return {
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceVulns": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
- "pathwayVulns": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "受众脆弱点数量": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
+ "传播节点脆弱点数量": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
}
@@ -1932,23 +2436,26 @@ def emit_step_feedback(
status: str | None = None,
note: str = "",
) -> None:
+ # One feedback line per step keeps the single-skill and split-skill versions
+ # equally transparent to the user.
total_errors = len(state.get("errors") or [])
new_errors = max(0, total_errors - before_error_count)
final_status = status or ("partial" if new_errors else "ok")
payload = {
- "type": "STEP_FEEDBACK",
- "step": step,
- "label": STEP_LABELS.get(step, step),
- "status": final_status,
- "note": note,
- "newErrors": new_errors,
- "totalErrors": total_errors,
- "counts": state_counts(state),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "updatedAt": state.get("updatedAt"),
+ "日志类型": "步骤反馈",
+ "步骤": STEP_NUMBER_LABELS.get(step, step),
+ "步骤名称": STEP_LABELS.get(step, step),
+ "状态": STATUS_LABELS.get(final_status, final_status),
+ "执行状态": status_label(final_status),
+ "备注": note,
+ "本步骤新增错误数": new_errors,
+ "累计错误数": total_errors,
+ "累计入库数量": state_counts(state),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "更新时间": state.get("updatedAt"),
}
- print("STEP_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["step"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def finish_step(
@@ -1960,6 +2467,8 @@ def finish_step(
status: str | None = None,
note: str = "",
) -> None:
+ # Always persist before emitting step feedback so the user can inspect files
+ # immediately after a reported step completes.
state["updatedAt"] = utc_now()
save_json_file(ctx.state_path, state)
if ctx.summary_path:
@@ -1980,6 +2489,11 @@ def skip_step_with_feedback(ctx: FlowContext, state: dict[str, Any], step: str,
def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Legacy full runner.
+
+ The wrapper script is preferred for real-time logs, but this remains useful
+ for dry-runs and unattended local troubleshooting.
+ """
run_step_with_feedback(ctx, state, "step1")
run_step_with_feedback(ctx, state, "step2")
run_step_with_feedback(ctx, state, "step3")
@@ -1995,6 +2509,7 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
def write_summary(path: Path, state: dict[str, Any]) -> None:
+ """Write a human-readable checkpoint beside the machine state JSON."""
audience_vuln_count = sum(len(v) for v in (state.get("audienceVulns") or {}).values())
pathway_vuln_count = sum(len(v) for v in (state.get("pathwayVulns") or {}).values())
lines = [
@@ -2005,6 +2520,8 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
f"- actionId:{state.get('actionId')}",
f"- 受众入库:{len(state.get('topAudiences') or [])}",
f"- 传播节点入库:{len(state.get('topPathways') or [])}",
+ f"- 下游受众选择:{len(state.get('selectedDownstreamAudiences') or [])}",
+ f"- 下游传播节点选择:{len(state.get('selectedDownstreamPathways') or [])}",
f"- 受众脆弱点入库:{audience_vuln_count}",
f"- 传播节点脆弱点入库:{pathway_vuln_count}",
f"- 受众策略入库:{len(state.get('audienceStrategies') or [])}",
@@ -2021,9 +2538,9 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
if state.get("errors"):
lines += ["", "## 失败明细"]
for err in state["errors"]:
- lines.append(f"- [{err.get('stage')}] {err.get('message')} status={err.get('status')} url={err.get('url')}")
+ lines.append(f"- [{stage_label(err.get('stage'))}] {err.get('message')} 状态码={err.get('status')} 接口={err.get('url')}")
if err.get("response"):
- lines.append(f" - response: {err.get('response')}")
+ lines.append(f" - 响应摘要:{err.get('response')}")
text = "\n".join(lines) + "\n"
try:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -2034,34 +2551,34 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
def build_arg_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(description="Run xdfx analysis/storage flow.")
+ parser = argparse.ArgumentParser(description="运行 xdfx 分析与入库流程。")
parser.add_argument("--mode", choices=["full", *STEP_FUNCS.keys()], default="full")
- parser.add_argument("--input", help="JSON config file. Keys: api_base, taskId, actionId, mubiao, direction, platform, headers.")
- parser.add_argument("--state", default="xdfx-flow-state.json", help="State JSON path for resume and step handoff.")
- parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown summary output path.")
+ parser.add_argument("--input", help="JSON 配置文件。常用字段:api_base、taskId、actionId、mubiao、direction、platform、headers。")
+ parser.add_argument("--state", default="xdfx-flow-state.json", help="用于断点续跑和步骤交接的状态 JSON 文件路径。")
+ parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown 执行摘要输出路径。")
parser.add_argument("--api-base")
parser.add_argument("--task-id")
parser.add_argument("--action-id")
parser.add_argument("--mubiao")
parser.add_argument("--direction", default=None)
parser.add_argument("--platform", default=None)
- parser.add_argument("--auth-token", default=os.getenv("XDFX_AUTH_TOKEN"))
- parser.add_argument("--authorization", default=os.getenv("AUTHORIZATION"))
- parser.add_argument("--header", action="append", default=[], help="Extra HTTP header, format 'Key: Value'.")
- parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="Username for automatic login when Authorization is missing or stale.")
- parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="Password query parameter for automatic username login.")
- parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="Login endpoint path or absolute URL.")
- parser.add_argument("--no-auto-login", action="store_true", help="Disable automatic username login and 401 token refresh.")
- parser.add_argument("--verify-ssl", action="store_true", help="Verify HTTPS certificates. Default is disabled for internal/UAT certificates.")
+ parser.add_argument("--header", action="append", default=[], help="附加非鉴权 HTTP 请求头,格式为 'Key: Value';Authorization 会被忽略。")
+ parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="xdfx_auth 启动阶段自动登录使用的用户名。")
+ parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="用户名登录接口的 password 查询参数。")
+ parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="登录接口路径或完整 URL。")
+ parser.add_argument("--no-auto-login", action="store_true", help="关闭 xdfx_auth 自动登录和 401 刷新。")
+ parser.add_argument("--verify-ssl", action="store_true", help="校验 HTTPS 证书。默认不校验,以兼容内网/UAT 自签证书。")
parser.add_argument("--timeout", type=float, default=90)
parser.add_argument("--retries", type=int, default=2)
parser.add_argument("--dry-run", action="store_true")
- parser.add_argument("--no-step-status", action="store_true", help="Do not update task/task-action.step after completed steps.")
- parser.add_argument("--model-retries", type=int, default=1, help="Retry model/stream endpoints when the first response is empty, timeout-like, or says the model did not respond.")
+ parser.add_argument("--no-step-status", action="store_true", help="步骤完成后不更新 task/task-action.step。")
+ parser.add_argument("--model-retries", type=int, default=1, help="模型/流式接口首次空响应、超时或提示模型无响应时的重试次数。")
parser.add_argument("--max-audiences", type=int, default=3)
parser.add_argument("--max-pathways", type=int, default=3)
parser.add_argument("--max-vulns", type=int, default=3)
parser.add_argument("--max-strategies", type=int, default=3)
+ parser.add_argument("--downstream-audiences", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库受众数量,默认 2。")
+ parser.add_argument("--downstream-pathways", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库传播节点数量,默认 2。")
return parser
@@ -2070,6 +2587,8 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
state_path = Path(args.state)
state = default_state()
+ # Load previous state first, then overlay input JSON and finally CLI flags.
+ # That ordering makes resume safe while still allowing explicit overrides.
if state_path.exists():
loaded = load_json_file(state_path)
if isinstance(loaded, dict):
@@ -2081,9 +2600,7 @@ def main(argv: list[str] | None = None) -> int:
merge_config_into_state(state, config)
input_headers = config.get("headers")
if isinstance(input_headers, dict):
- args.header.extend(f"{k}: {v}" for k, v in input_headers.items())
- args.auth_token = args.auth_token or config.get("auth_token")
- args.authorization = args.authorization or config.get("authorization")
+ args.header.extend(f"{k}: {v}" for k, v in input_headers.items() if str(k).lower() != "authorization")
if config.get("login_username") is not None:
args.login_username = str(config.get("login_username") or "")
if config.get("login_password") is not None:
@@ -2094,6 +2611,10 @@ def main(argv: list[str] | None = None) -> int:
args.no_auto_login = True
if config.get("verify_ssl") is not None:
args.verify_ssl = bool(config.get("verify_ssl"))
+ if config.get("downstream_audiences") is not None:
+ args.downstream_audiences = as_int(config.get("downstream_audiences")) or args.downstream_audiences
+ if config.get("downstream_pathways") is not None:
+ args.downstream_pathways = as_int(config.get("downstream_pathways")) or args.downstream_pathways
cli_config = {
"api_base": args.api_base,
@@ -2105,7 +2626,8 @@ def main(argv: list[str] | None = None) -> int:
}
merge_config_into_state(state, {k: v for k, v in cli_config.items() if v is not None})
api_base = state.get("api_base") or "https://ch1.b.uat.4.cn"
- headers = parse_headers(args.header, args.auth_token, args.authorization)
+ headers = parse_headers(args.header)
+ # ctx owns runtime knobs; state owns business data and resumable ids.
ctx = FlowContext(
api_base=api_base,
headers=headers,
@@ -2118,6 +2640,8 @@ def main(argv: list[str] | None = None) -> int:
max_pathways=max(args.max_pathways, 1),
max_vulns=max(args.max_vulns, 1),
max_strategies=max(args.max_strategies, 1),
+ downstream_audiences=max(args.downstream_audiences, 1),
+ downstream_pathways=max(args.downstream_pathways, 1),
sync_step_status=not args.no_step_status,
model_retries=max(args.model_retries, 0),
verify_ssl=bool(args.verify_ssl),
@@ -2131,11 +2655,17 @@ def main(argv: list[str] | None = None) -> int:
state["_summaryPath"] = str(ctx.summary_path) if ctx.summary_path else ""
state["_autoLogin"] = ctx.auto_login
state["_verifySsl"] = ctx.verify_ssl
+ state["_downstreamLimits"] = {
+ "audiences": ctx.downstream_audiences,
+ "pathways": ctx.downstream_pathways,
+ }
record_event(state, "runner", f"start mode={args.mode} dry_run={ctx.dry_run}")
try:
require_context(state, "taskId", "actionId", "mubiao")
- if ctx.auto_login and not has_authorization(ctx.headers):
- try_auto_login(ctx, state, "runner/auto-login")
+ if ctx.auto_login and not ctx.dry_run:
+ # Always acquire a token through the imported auth package before the first business call.
+ if not try_auto_login(ctx, state, "runner/initial-login", force=True):
+ raise FlowError("启动阶段获取 token 失败,已停止执行,避免无 token 调用业务接口")
if args.mode == "full":
run_full(ctx, state)
else:
@@ -2145,13 +2675,14 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(f"ERROR: {exc}", file=sys.stderr)
+ print(f"错误:{exc}", file=sys.stderr)
return 2
except Exception as exc: # noqa: BLE001
record_error(state, "runner", "未捕获异常", response=traceback.format_exc(), exception=exc)
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
+ print("未捕获异常堆栈:", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
return 1
@@ -2159,20 +2690,23 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(json.dumps(
- {
- "ok": True,
- "mode": args.mode,
- "state": str(state_path),
- "summary": str(ctx.summary_path) if ctx.summary_path else "",
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
- "errors": len(state.get("errors") or []),
- },
- ensure_ascii=False,
- ))
+ # Final JSON is intentionally compact; detailed per-step status has already
+ # been emitted as Chinese step feedback.
+ final_payload = {
+ "日志类型": "执行完成",
+ "是否成功": "是",
+ "执行模式": mode_label(args.mode),
+ "状态文件": str(state_path),
+ "摘要文件": str(ctx.summary_path) if ctx.summary_path else "",
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "已选择受众数": len(state.get("selectedDownstreamAudiences") or []),
+ "已选择传播节点数": len(state.get("selectedDownstreamPathways") or []),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
+ "错误数": len(state.get("errors") or []),
+ }
+ print(LOG_PREFIXES["done"] + " " + json.dumps(final_payload, ensure_ascii=False))
return 0
diff --git a/skills-data/xdfx-step4-pathway-vulns/SKILL.md b/skills-data/xdfx-step4-pathway-vulns/SKILL.md
index 98374914..f845c8fe 100644
--- a/skills-data/xdfx-step4-pathway-vulns/SKILL.md
+++ b/skills-data/xdfx-step4-pathway-vulns/SKILL.md
@@ -13,26 +13,37 @@ description: xdfx 拆分流程 step4,为已入库关键传播节点抽取并
python scripts/xdfx_flow.py --mode step4 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
-鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
+鉴权和证书由脚本兜底处理:脚本启动时先 `import scripts/xdfx_auth.py`,自动用 `admin / ch@user` 获取 `access_token`,并在第一次业务接口调用前写入内部 Authorization;不要通过命令行传 token。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
-- 对每个已入库关键传播节点抽取最多 3 个脆弱点。
+- 从已入库关键传播节点中默认选择前 2 个继续分析;可用 `--downstream-pathways 1` 调成只处理 1 个。
+- 对每个被选中的关键传播节点抽取并入库最多 3 个脆弱点。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 自动修复和解析模型 JSON。
- 调用传播节点脆弱点保存接口。
- 只有保存成功并返回 `id` 的脆弱点才进入后续策略阶段。
- 同步前端步骤状态 `step-analysis-weakpoint`。
-- 单个节点失败不影响其他节点。
+- 单个节点失败会写入错误;智能体必须按失败重试规则先重跑 Step4,不能直接跳到 Step5/Step6。
完成后查看 `pathwayVulns` 和 `errors`。
+## 失败重试规则
+
+- 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前 Step4 命令。
+- 重试前必须告诉用户失败阶段、接口 URL、状态码、响应摘要和“正在重试 Step4”,不能静默重试。
+- Step4 未重试成功前,不允许继续 Step5/Step6;不要因为 Step4 失败就跳过传播节点脆弱点分析。
+- Step4 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
+
## 步骤反馈
-命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step4 的完成状态、当前传播节点脆弱点数量和新增错误数,再决定是否继续下一步。
+命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step4 的完成状态、当前传播节点脆弱点数量和新增错误数,再决定是否继续下一步。
-执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
+执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。
-如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的传播节点脆弱点和返回 ID。
+
+如果出现 `错误反馈 {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+
+如果出现 `JSON解析复核 {...}`,必须查看 `响应片段`,判断是否能识别传播节点脆弱点 JSON;无法确认时重跑 Step4。
-如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别传播节点脆弱点 JSON;无法确认时重跑 Step4。
diff --git a/skills-data/xdfx-step4-pathway-vulns/scripts/xdfx_auth.py b/skills-data/xdfx-step4-pathway-vulns/scripts/xdfx_auth.py
new file mode 100644
index 00000000..fe46f809
--- /dev/null
+++ b/skills-data/xdfx-step4-pathway-vulns/scripts/xdfx_auth.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""Authentication helper for xdfx skill scripts.
+
+The caller should not pass tokens through command-line arguments. This module
+owns the fixed admin login flow and returns a Bearer token before business
+interfaces are called.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import ssl
+import urllib.error
+import urllib.parse
+import urllib.request
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass
+class AuthResult:
+ token: str
+ url: str
+ status: int | None
+ raw: str
+
+
+class AuthError(RuntimeError):
+ def __init__(
+ self,
+ message: str,
+ *,
+ url: str,
+ status: int | None = None,
+ response: str = "",
+ exception: BaseException | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.url = url
+ self.status = status
+ self.response = response
+ self.exception = exception
+
+
+def auth_header_value(token: str) -> str:
+ token = token.strip()
+ return token if token.lower().startswith("bearer ") else f"Bearer {token}"
+
+
+def redact_url(url: str) -> str:
+ return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
+
+
+def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
+ if verify_ssl:
+ return None
+ return ssl._create_unverified_context()
+
+
+def build_login_url(api_base: str, path: str, password: str) -> str:
+ base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
+ if password:
+ separator = "&" if "?" in base else "?"
+ base = f"{base}{separator}password={urllib.parse.quote(password, safe='')}"
+ return base
+
+
+def extract_access_token(data: Any) -> str | None:
+ if isinstance(data, dict):
+ for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
+ value = data.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ for value in data.values():
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ elif isinstance(data, list):
+ for value in data:
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ return None
+
+
+def parse_json_loose(text: str) -> Any:
+ try:
+ return json.loads(text)
+ except Exception:
+ pass
+ match = re.search(r"[\[{]", text or "")
+ if not match:
+ return {}
+ start = match.start()
+ for end in range(len(text), start, -1):
+ try:
+ return json.loads(text[start:end])
+ except Exception:
+ continue
+ return {}
+
+
+def fetch_access_token(
+ *,
+ api_base: str,
+ username: str = "admin",
+ password: str = "ch@user",
+ login_path: str = "/api/v1/auth/login/username",
+ timeout: float = 90,
+ verify_ssl: bool = False,
+) -> AuthResult:
+ """Login once and return the extracted access token."""
+ url = build_login_url(api_base, login_path, password)
+ payload = {"username": username}
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ parsed = parse_json_loose(raw)
+ token = extract_access_token(parsed)
+ if not token:
+ raise AuthError(
+ "登录接口已响应但未找到 access_token",
+ url=redact_url(url),
+ status=getattr(resp, "status", None),
+ response=raw,
+ )
+ return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
+ raise AuthError(
+ "登录接口返回错误",
+ url=redact_url(url),
+ status=exc.code,
+ response=body,
+ exception=exc,
+ ) from exc
+ except AuthError:
+ raise
+ except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
+ raise AuthError(
+ "登录接口调用异常",
+ url=redact_url(url),
+ response=str(exc),
+ exception=exc,
+ ) from exc
diff --git a/skills-data/xdfx-step4-pathway-vulns/scripts/xdfx_flow.py b/skills-data/xdfx-step4-pathway-vulns/scripts/xdfx_flow.py
index 52880e82..5f405cc8 100644
--- a/skills-data/xdfx-step4-pathway-vulns/scripts/xdfx_flow.py
+++ b/skills-data/xdfx-step4-pathway-vulns/scripts/xdfx_flow.py
@@ -26,7 +26,10 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
+import xdfx_auth
+
+# Keep every backend path in one table so endpoint drift is easy to audit.
ENDPOINTS = {
"aiuser": "/gpt/chat/chat_aiuser",
"group": "/gpt/chat/chat_qianqian_group_chat",
@@ -44,10 +47,12 @@ ENDPOINTS = {
"save_narrate": "/api/base/base-narrate",
"save_message": "/api/task/task-action-message",
"save_strategy_relation": "/api/task/task-action-design-strategy-relation",
+ "dict_data": "/api/sys/sys-dict-data",
}
+# Human-readable names are used only in the Chinese feedback logs.
ENDPOINT_LABELS = {
- "login": "登录获取访问 token",
+ "login": "登录获取访问令牌",
"aiuser": "调用候选账号/受众分析模型接口",
"group": "调用受众目标结构化抽取接口",
"spread": "调用关键传播节点结构化抽取接口",
@@ -64,8 +69,86 @@ ENDPOINT_LABELS = {
"save_narrate": "保存策略信息主题",
"save_message": "保存策略消息配置",
"save_strategy_relation": "保存策略与受众/传播节点关系",
+ "dict_data": "读取前端字典数据",
}
+LOG_PREFIXES = {
+ "operation": "操作反馈",
+ "error": "错误反馈",
+ "json_review": "JSON解析复核",
+ "save": "入库反馈",
+ "step": "步骤反馈",
+ "done": "执行完成",
+}
+
+STATUS_LABELS = {
+ "ok": "成功",
+ "partial": "部分完成",
+ "skipped": "已跳过",
+}
+
+STEP_NUMBER_LABELS = {
+ "step1": "第一步",
+ "step2": "第二步",
+ "step3": "第三步",
+ "step4": "第四步",
+ "step5": "第五步",
+ "step6": "第六步",
+}
+
+HTTP_METHOD_LABELS = {
+ "GET": "读取(GET)",
+ "POST": "提交(POST)",
+ "PUT": "更新(PUT)",
+ "PATCH": "局部更新(PATCH)",
+ "DELETE": "删除(DELETE)",
+}
+
+STAGE_SEGMENT_LABELS = {
+ "analyze": "模型分析",
+ "extract": "结构化抽取",
+ "save": "保存数据",
+ "read": "读取数据",
+ "status": "同步状态",
+}
+
+REQUEST_FIELD_LABELS = {
+ "history": "历史消息",
+ "max_tokens": "最大生成令牌数",
+ "model_name": "模型名称",
+ "otherinfo": "其他信息",
+ "prompt_name": "提示词名称",
+ "stream": "是否流式",
+ "temperature": "温度",
+ "usermessage": "用户消息",
+ "user_message": "用户消息",
+ "mubiao": "行动目标",
+ "zhanghao": "账号列表",
+ "taskId": "任务ID",
+ "actionId": "行动ID",
+ "taskActionId": "行动ID",
+ "designId": "受众ID",
+ "pathwayId": "传播节点ID",
+ "strategyId": "策略ID",
+ "accountId": "账号ID",
+ "accountName": "账号名称",
+ "accountType": "账号类型",
+ "targetAudience": "受众目标",
+ "vulnerabilities": "脆弱点",
+ "topic": "信息标题",
+ "narrate": "叙事主题",
+ "platform": "平台",
+ "persona": "发布人设",
+ "modelType": "信息形式",
+ "quantity": "数量",
+ "publishTime": "发布时间段",
+ "reason": "理由",
+ "ids": "ID列表",
+ "id": "ID",
+ "step": "步骤状态",
+}
+
+# Platform values mirror the frontend dictionaries used by xdfx strategy pages.
PLATFORM_MAP = {
"facebook": 1,
"fb": 1,
@@ -78,6 +161,8 @@ PLATFORM_MAP = {
"yt": 4,
}
+# The model service sometimes returns a successful HTTP response with these
+# strings instead of useful content. Treat them like transient failures.
MODEL_RETRY_MARKERS = (
"y模型没有响应",
"模型没有响应",
@@ -118,6 +203,12 @@ class FlowError(RuntimeError):
@dataclass
class FlowContext:
+ """Runtime options shared by every step.
+
+ Keep this object boring and serializable-ish: it is passed through every
+ helper, while durable progress belongs in the state JSON file.
+ """
+
api_base: str
headers: dict[str, str]
timeout: float
@@ -129,6 +220,8 @@ class FlowContext:
max_pathways: int = 3
max_vulns: int = 3
max_strategies: int = 3
+ downstream_audiences: int = 2
+ downstream_pathways: int = 2
sync_step_status: bool = True
model_retries: int = 1
verify_ssl: bool = False
@@ -153,7 +246,7 @@ def compact(value: Any, limit: int = 1400) -> str:
text = repr(value)
if len(text) <= limit:
return text
- return text[:limit] + f"... "
+ return text[:limit] + f"... <已截断 {len(text) - limit} 个字符>"
def stable_json(value: Any) -> str:
@@ -223,6 +316,11 @@ def save_json_file(path: str | Path, value: Any) -> None:
def default_state() -> dict[str, Any]:
+ """Create a resumable state container.
+
+ The script can be run as six independent commands. IDs returned by one step
+ are persisted here so later steps can resume without redoing earlier saves.
+ """
return {
"api_base": "",
"taskId": "",
@@ -247,6 +345,7 @@ def default_state() -> dict[str, Any]:
def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> None:
+ # Accept both frontend-style camelCase and CLI-friendly snake_case inputs.
aliases = {
"task_id": "taskId",
"action_id": "actionId",
@@ -260,16 +359,12 @@ def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> No
continue
if dest in {"taskId", "actionId"}:
state[dest] = str(value)
- elif dest in state or dest in {
- "api_base",
- "headers",
- "auth_token",
- "authorization",
- }:
+ elif dest in state or dest in {"api_base", "headers"}:
state[dest] = value
def record_event(state: dict[str, Any], stage: str, message: str, data: Any | None = None) -> None:
+ """Append a compact diagnostic event to the state file."""
state.setdefault("events", []).append(
{
"time": utc_now(),
@@ -286,9 +381,13 @@ def feedback_step(stage: str) -> str:
return match.group(1) if match else ""
+def feedback_step_label(stage: str) -> str:
+ return STEP_NUMBER_LABELS.get(feedback_step(stage), "")
+
+
def request_keys(payload: Any) -> list[str]:
if isinstance(payload, dict):
- return sorted(str(key) for key in payload.keys())
+ return sorted(REQUEST_FIELD_LABELS.get(str(key), str(key)) for key in payload.keys())
return []
@@ -306,6 +405,77 @@ def endpoint_label(endpoint_key: str | None, method: str, url: str) -> str:
return f"调用接口 {method}"
+def method_label(method: str) -> str:
+ return HTTP_METHOD_LABELS.get(str(method).upper(), str(method))
+
+
+def yes_no(value: bool) -> str:
+ return "是" if value else "否"
+
+
+def stage_label(stage: Any) -> str:
+ text = str(stage or "")
+ if not text:
+ return ""
+ if text in STEP_LABELS:
+ return STEP_LABELS[text]
+ if text == "runner":
+ return "流程执行器"
+ parts = text.split("/")
+ base = parts[0]
+ match = re.search(r"(step[1-6])", base)
+ labels: list[str] = []
+ if match:
+ labels.append(STEP_LABELS.get(match.group(1), match.group(1)))
+ elif base == "runner":
+ labels.append("流程执行器")
+ else:
+ labels.append(base)
+ for segment in parts[1:]:
+ normalized = segment.replace("-", "_")
+ attempt = re.fullmatch(r"model-call-attempt-(\d+)", segment)
+ json_attempt = re.fullmatch(r"model-json-attempt-(\d+)", segment)
+ if attempt:
+ labels.append(f"模型接口第 {attempt.group(1)} 次尝试")
+ elif json_attempt:
+ labels.append(f"JSON解析第 {json_attempt.group(1)} 次尝试")
+ elif segment == "auto-login":
+ labels.append("自动登录")
+ elif segment in STAGE_SEGMENT_LABELS:
+ labels.append(STAGE_SEGMENT_LABELS[segment])
+ elif normalized in ENDPOINT_LABELS:
+ labels.append(ENDPOINT_LABELS[normalized])
+ else:
+ labels.append(segment)
+ return " / ".join(labels)
+
+
+def status_label(status: Any) -> str:
+ if status is None:
+ return ""
+ return STATUS_LABELS.get(str(status), str(status))
+
+
+def mode_label(mode: str) -> str:
+ if mode == "full":
+ return "全流程"
+ return STEP_LABELS.get(mode, mode)
+
+
+def exception_type_label(exception_type: Any) -> str:
+ name = str(exception_type or "")
+ labels = {
+ "HTTPError": "HTTP 调用异常",
+ "URLError": "网络地址异常",
+ "TimeoutError": "请求超时异常",
+ "FlowError": "流程异常",
+ "ValueError": "数据格式异常",
+ "KeyError": "字段缺失异常",
+ "RuntimeError": "运行异常",
+ }
+ return labels.get(name, "程序异常" if name else "")
+
+
def emit_operation_feedback(
state: dict[str, Any],
stage: str,
@@ -317,39 +487,41 @@ def emit_operation_feedback(
attempt: int = 1,
dry_run: bool = False,
) -> None:
+ # Feedback lines are a mini protocol consumed by the calling agent. They
+ # must stay single-line JSON so weak models/tools can relay them reliably.
payload_obj = {
- "type": "OP_FEEDBACK",
- "step": feedback_step(stage),
- "stage": stage,
- "operation": endpoint_label(endpoint_key, method, url),
- "method": method,
- "url": url,
- "endpoint": endpoint_key or "",
- "attempt": attempt,
- "dryRun": dry_run,
- "requestKeys": request_keys(payload),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "操作反馈",
+ "步骤": feedback_step_label(stage),
+ "阶段": stage_label(stage),
+ "操作": endpoint_label(endpoint_key, method, url),
+ "请求方式": method_label(method),
+ "接口地址": url,
+ "接口名称": endpoint_label(endpoint_key, method, url),
+ "第几次尝试": attempt,
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("OP_FEEDBACK " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["operation"] + " " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
def emit_error_feedback(error: dict[str, Any]) -> None:
payload = {
- "type": "ERROR_FEEDBACK",
- "stage": error.get("stage"),
- "step": feedback_step(str(error.get("stage") or "")),
- "message": error.get("message"),
- "url": error.get("url"),
- "status": error.get("status"),
- "request": error.get("request"),
- "response": error.get("response"),
- "exceptionType": error.get("exceptionType"),
- "exception": error.get("exception"),
- "time": error.get("time"),
+ "日志类型": "错误反馈",
+ "阶段": stage_label(error.get("stage")),
+ "步骤": feedback_step_label(str(error.get("stage") or "")),
+ "错误信息": error.get("message"),
+ "接口地址": error.get("url"),
+ "状态码": error.get("status"),
+ "请求摘要": error.get("request"),
+ "响应摘要": error.get("response"),
+ "异常类型": exception_type_label(error.get("exceptionType")),
+ "异常信息": error.get("exception"),
+ "时间": error.get("time"),
}
- print("ERROR_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["error"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def emit_json_parse_review(
@@ -360,18 +532,89 @@ def emit_json_parse_review(
candidate_count: int,
) -> None:
payload = {
- "type": "JSON_PARSE_REVIEW",
- "stage": stage,
- "step": feedback_step(stage),
- "endpoint": endpoint_key,
- "message": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
- "candidateCount": candidate_count,
- "responseExcerpt": compact(text, 3000),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "JSON解析复核",
+ "阶段": stage_label(stage),
+ "步骤": feedback_step_label(stage),
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, endpoint_key),
+ "提示": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
+ "候选数量": candidate_count,
+ "响应片段": compact(text, 3000),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("JSON_PARSE_REVIEW " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["json_review"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
+
+
+SAVE_ENTITY_LABELS = {
+ "save_design": "受众目标",
+ "save_design_account": "受众目标账号",
+ "save_pathway": "关键传播节点",
+ "save_relation": "受众-传播节点关系",
+ "save_design_vuln": "受众脆弱点",
+ "save_pathway_vuln": "传播节点脆弱点",
+ "save_strategy": "策略",
+ "save_channel": "策略平台与人设",
+ "save_narrate": "策略信息主题",
+ "save_message": "策略消息配置",
+ "save_strategy_relation": "策略关联关系",
+}
+
+
+def saved_entity_name(endpoint_key: str, payload: dict[str, Any]) -> str:
+ """Return the most user-readable name for an inserted row."""
+ candidates = [
+ payload.get("targetAudience"),
+ payload.get("accountName"),
+ payload.get("vulnerabilities"),
+ payload.get("topic"),
+ payload.get("narrate"),
+ payload.get("platform"),
+ payload.get("strategyId"),
+ payload.get("designId"),
+ payload.get("pathwayId"),
+ ]
+ for value in candidates:
+ if value is not None and str(value).strip():
+ return str(value).strip()
+ return endpoint_key
+
+
+def emit_save_feedback(
+ state: dict[str, Any],
+ stage: str,
+ endpoint_key: str,
+ payload: dict[str, Any],
+ row_id: int,
+ *,
+ dry_run: bool,
+) -> None:
+ # Emit only after an id is known. Downstream steps use these ids and should
+ # fail loudly rather than inventing relations against missing rows.
+ label = SAVE_ENTITY_LABELS.get(endpoint_key, endpoint_key)
+ name = saved_entity_name(endpoint_key, payload)
+ feedback = {
+ "日志类型": "入库反馈",
+ "步骤": feedback_step(stage),
+ "阶段": stage,
+ "入库对象类型": label,
+ "入库对象名称": name,
+ "返回ID": row_id,
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, label),
+ "接口路径": ENDPOINTS.get(endpoint_key, ""),
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
+ }
+ print(LOG_PREFIXES["save"] + " " + json.dumps(feedback, ensure_ascii=False), flush=True)
+ record_event(
+ state,
+ stage,
+ f"入库{label}成功:{name}",
+ {"id": row_id, "endpoint": endpoint_key, "dryRun": dry_run},
+ )
def record_error(
@@ -385,6 +628,8 @@ def record_error(
response: Any | None = None,
exception: BaseException | None = None,
) -> None:
+ # Persist full-enough error context for post-run diagnosis, then immediately
+ # emit a short line the agent can show to the user.
item = {
"time": utc_now(),
"stage": stage,
@@ -430,65 +675,25 @@ def url_for_path(ctx: FlowContext, path: str) -> str:
return ctx.api_base.rstrip("/") + "/" + path.lstrip("/")
-def parse_headers(header_values: list[str], auth_token: str | None, authorization: str | None) -> dict[str, str]:
+def parse_headers(header_values: list[str]) -> dict[str, str]:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
- if auth_token:
- headers["Authorization"] = auth_token if auth_token.lower().startswith("bearer ") else f"Bearer {auth_token}"
- if authorization:
- headers["Authorization"] = authorization
for raw in header_values:
if ":" not in raw:
raise FlowError(f"header 格式错误,应为 Key: Value,实际:{raw}")
key, value = raw.split(":", 1)
- headers[key.strip()] = value.strip()
+ normalized = key.strip()
+ if normalized.lower() == "authorization":
+ continue
+ headers[normalized] = value.strip()
return headers
-def has_authorization(headers: dict[str, str]) -> bool:
- return any(key.lower() == "authorization" and str(value).strip() for key, value in headers.items())
-
-
def ssl_context(ctx: FlowContext) -> ssl.SSLContext | None:
if ctx.verify_ssl:
return None
return ssl._create_unverified_context()
-def auth_header_value(token: str) -> str:
- token = token.strip()
- return token if token.lower().startswith("bearer ") else f"Bearer {token}"
-
-
-def extract_access_token(data: Any) -> str | None:
- if isinstance(data, dict):
- for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
- value = data.get(key)
- if isinstance(value, str) and value.strip():
- return value.strip()
- for value in data.values():
- nested = extract_access_token(value)
- if nested:
- return nested
- elif isinstance(data, list):
- for value in data:
- nested = extract_access_token(value)
- if nested:
- return nested
- return None
-
-
-def redact_url(url: str) -> str:
- return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
-
-
-def build_login_url(ctx: FlowContext, path: str) -> str:
- base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else url_for_path(ctx, path)
- if ctx.login_password:
- separator = "&" if "?" in base else "?"
- base = f"{base}{separator}password={urllib.parse.quote(ctx.login_password, safe='')}"
- return base
-
-
def login_candidate_paths(ctx: FlowContext) -> list[str]:
paths = [
ctx.login_path or "/api/v1/auth/login/username",
@@ -506,6 +711,7 @@ def login_candidate_paths(ctx: FlowContext) -> list[str]:
def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force: bool = False) -> bool:
+ """Fetch a fresh token through the local auth package before business calls."""
if not ctx.auto_login:
return False
if ctx.dry_run:
@@ -515,7 +721,7 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
record_error(state, stage, "自动登录失败:缺少 login_username")
return False
if not force and ctx.login_attempt_count > 0:
- return has_authorization(ctx.headers)
+ return any(key.lower() == "authorization" and str(value).strip() for key, value in ctx.headers.items())
if force and ctx.login_attempt_count >= 2:
return False
@@ -525,8 +731,7 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
last_response = ""
last_exc: BaseException | None = None
for attempt, path in enumerate(login_candidate_paths(ctx), start=1):
- url = build_login_url(ctx, path)
- display_url = redact_url(url)
+ display_url = xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, path, ctx.login_password))
emit_operation_feedback(
state,
stage,
@@ -537,63 +742,39 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
attempt=attempt,
)
try:
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- headers = {
- "Content-Type": "application/json",
- "Accept": "application/json",
- }
- req = urllib.request.Request(url, data=data, headers=headers, method="POST")
- with urllib.request.urlopen(req, timeout=ctx.timeout, context=ssl_context(ctx)) as resp:
- raw = resp.read().decode("utf-8", errors="replace")
- parsed = parse_json_lenient(raw)
- token = extract_access_token(parsed)
- if not token:
- last_status = getattr(resp, "status", None)
- last_response = raw
- record_event(
- state,
- stage,
- "登录接口已响应但未找到 access_token",
- {"url": display_url, "status": last_status, "response": compact(raw, 1000)},
- )
- continue
- ctx.headers["Authorization"] = auth_header_value(token)
- record_event(
- state,
- stage,
- "自动登录成功,已写入 Authorization Bearer token",
- {
- "url": display_url,
- "status": getattr(resp, "status", None),
- "username": ctx.login_username,
- },
- )
- return True
- except urllib.error.HTTPError as exc:
- last_status = exc.code
- last_response = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
- last_exc = exc
- record_event(
- state,
- stage,
- "登录接口返回错误,准备尝试下一个候选登录路径",
- {"url": display_url, "status": exc.code, "response": compact(last_response, 1000)},
+ result = xdfx_auth.fetch_access_token(
+ api_base=ctx.api_base,
+ username=ctx.login_username,
+ password=ctx.login_password,
+ login_path=path,
+ timeout=ctx.timeout,
+ verify_ssl=ctx.verify_ssl,
)
- except Exception as exc: # noqa: BLE001 - auto-login must report every failure.
- last_exc = exc
- last_response = traceback.format_exc()
+ ctx.headers["Authorization"] = xdfx_auth.auth_header_value(result.token)
+ state["_tokenSource"] = "xdfx_auth"
record_event(
state,
stage,
- "登录接口调用异常,准备尝试下一个候选登录路径",
- {"url": display_url, "exceptionType": type(exc).__name__, "exception": str(exc)},
+ "启动阶段已通过 xdfx_auth 包获取 token,并写入 Authorization",
+ {"url": result.url, "status": result.status, "username": ctx.login_username},
+ )
+ return True
+ except xdfx_auth.AuthError as exc:
+ last_status = exc.status
+ last_response = exc.response
+ last_exc = exc
+ record_event(
+ state,
+ stage,
+ "xdfx_auth 获取 token 失败,准备尝试下一个候选登录路径",
+ {"url": exc.url, "status": exc.status, "response": compact(exc.response, 1000), "exception": str(exc)},
)
record_error(
state,
stage,
- "自动登录失败,未能获取 access_token",
- url=redact_url(build_login_url(ctx, ctx.login_path or "/api/v1/auth/login/username")),
+ "自动登录失败,未能通过 xdfx_auth 获取 access_token",
+ url=xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, ctx.login_path or "/api/v1/auth/login/username", ctx.login_password)),
status=last_status,
request=payload,
response=last_response,
@@ -612,6 +793,7 @@ def http_request_json_url(
endpoint_key: str | None = None,
allow_auto_login: bool = True,
) -> tuple[Any | None, str]:
+ """Call an HTTP JSON endpoint with logging, retries, and 401 recovery."""
method = method.upper()
if ctx.dry_run:
emit_operation_feedback(state, stage, method, url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
@@ -640,6 +822,7 @@ def http_request_json_url(
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
if exc.code == 401 and allow_auto_login and ctx.auto_login:
+ # Refresh once on 401, then replay the original request exactly.
refreshed = try_auto_login(ctx, state, f"{stage}/auto-login", force=True)
if refreshed:
record_event(
@@ -709,6 +892,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
emit_operation_feedback(state, stage, "POST", url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
record_event(state, stage, f"DRY RUN POST {url}", {"payload": payload})
if endpoint_key.startswith("save_"):
+ # Synthetic ids let downstream dry-run code exercise relation wiring.
synthetic_id = abs(hash(stage + stable_json(payload))) % 100000000
return {"id": synthetic_id, "dryRun": True}, json.dumps({"id": synthetic_id, "dryRun": True})
return {"dryRun": True}, json.dumps({"dryRun": True})
@@ -716,6 +900,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
def parse_json_lenient(text: str) -> Any | None:
+ """Parse normal JSON plus adjacent/SSE-like JSON objects."""
stripped = text.strip()
if not stripped:
return None
@@ -730,6 +915,7 @@ def parse_json_lenient(text: str) -> Any | None:
def parse_concatenated_json_objects(text: str) -> list[Any]:
+ """Read streams such as {"answer": "..."}{"answer": "..."} or data: lines."""
decoder = json.JSONDecoder()
objects: list[Any] = []
i = 0
@@ -766,6 +952,7 @@ def parse_concatenated_json_objects(text: str) -> list[Any]:
def model_text_from_response(parsed: Any, raw: str) -> tuple[str, list[Any]]:
+ """Flatten streamed model chunks into one text buffer."""
objects: list[Any]
if isinstance(parsed, list):
objects = parsed
@@ -824,6 +1011,7 @@ def extract_json_from_text(text: str) -> Any | None:
def extract_json_candidates_from_text(text: str) -> list[Any]:
+ """Collect every plausible JSON object/array from messy model text."""
cleaned = strip_think(text)
candidates: list[Any] = []
seen: set[str] = set()
@@ -860,6 +1048,7 @@ def extract_json_candidates_from_text(text: str) -> list[Any]:
def select_json_candidate(candidates: list[Any], endpoint_key: str, stage: str) -> Any | None:
+ """Pick the candidate whose keys best match the current step."""
if not candidates:
return None
scored = [(json_candidate_score(candidate, endpoint_key, stage), idx, candidate) for idx, candidate in enumerate(candidates)]
@@ -1010,6 +1199,7 @@ def extract_first_balanced(text: str, open_ch: str, close_ch: str) -> str | None
def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[str, list[Any], Any | None]:
+ """Call a model endpoint and retry only known empty/timeout/no-response cases."""
max_attempts = max(ctx.model_retries, 0) + 1
last_text = ""
last_objects: list[Any] = []
@@ -1048,6 +1238,11 @@ def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str,
def call_model_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[dict[str, Any], str, list[Any]]:
+ """Return the best structured JSON for a model step.
+
+ If the first parse fails, the second attempt includes an explicit repair
+ instruction so weak models can re-emit only the required JSON.
+ """
last_text = ""
last_objects: list[Any] = []
last_candidate_count = 0
@@ -1202,23 +1397,85 @@ def profile_info(account: dict[str, Any]) -> str:
def parse_index_list(value: Any) -> list[str]:
- if value is None:
- return []
- if isinstance(value, list):
- return [str(v).strip() for v in value if str(v).strip()]
- text = str(value)
- return [part.strip().strip("<>").strip() for part in re.split(r"[,,;;、\s]+", text) if part.strip()]
+ refs: list[str] = []
+
+ def add(raw: Any) -> None:
+ if raw is None:
+ return
+ if isinstance(raw, dict):
+ keys = (
+ "编号id",
+ "编号ID",
+ "用户编号id",
+ "用户编号ID",
+ "用户编号",
+ "账号编号",
+ "引用序号",
+ "索引",
+ "index",
+ "id",
+ "ids",
+ "accountIndex",
+ "accountIndexes",
+ "userIndex",
+ "userIndexes",
+ )
+ for key in keys:
+ if key in raw:
+ add(raw.get(key))
+ return
+ if isinstance(raw, (list, tuple, set)):
+ for item in raw:
+ add(item)
+ return
+ text = str(raw).strip()
+ if not text:
+ return
+ angle_refs = re.findall(r"<<\s*([^<>]+?)\s*>>", text)
+ if angle_refs:
+ for token in angle_refs:
+ add(token)
+ return
+ for part in re.split(r"[,,;;、\s]+", text):
+ token = part.strip().strip("<>").strip()
+ if token:
+ refs.append(token)
+
+ add(value)
+ deduped: list[str] = []
+ seen: set[str] = set()
+ for ref in refs:
+ if ref in seen:
+ continue
+ seen.add(ref)
+ deduped.append(ref)
+ return deduped
+
+
+def account_ref_numbers(account: dict[str, Any]) -> set[int]:
+ numbers: set[int] = set()
+ meta = account.get("metadata") if isinstance(account.get("metadata"), dict) else {}
+ for key in ("编号id", "编号ID", "用户编号id", "用户编号ID", "用户编号", "账号编号", "引用序号", "索引", "index", "accountIndex"):
+ for container in (account, meta):
+ number = as_int(container.get(key)) if isinstance(container, dict) else None
+ if number is not None:
+ numbers.add(number)
+ return numbers
def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, Any] | None:
+ """Resolve model references like <<3>>, index numbers, names, or account ids."""
if not accounts:
return None
text = str(ref).strip().strip("<>").strip()
number = as_int(text)
if number is not None:
for account in accounts:
- if any(as_int(account.get(key)) == number for key in ("引用序号", "索引", "index")):
+ if number in account_ref_numbers(account):
return account
+ # Step1 group extraction returns 编号/id values that refer to the
+ # previous zhanghao array index. Prefer exact index fields above, then
+ # fall back to zero-based and one-based array positions.
if 0 <= number < len(accounts):
return accounts[number]
if 1 <= number <= len(accounts):
@@ -1232,11 +1489,25 @@ def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, A
def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Normalize audience JSON whether the model used numbered fields or items[]."""
items: list[dict[str, Any]] = []
for i in range(1, 21):
group = first_present(data.get(f"群体{i}"), data.get(f"受众{i}"), data.get(f"targetAudience{i}"))
reason = first_present(data.get(f"理由{i}"), data.get(f"推荐理由{i}"), data.get(f"recReason{i}"))
- users = first_present(data.get(f"用户编号列表{i}"), data.get(f"用户列表{i}"), data.get(f"账号编号列表{i}"))
+ users = first_present(
+ data.get(f"用户编号列表{i}"),
+ data.get(f"编号id列表{i}"),
+ data.get(f"编号ID列表{i}"),
+ data.get(f"用户编号id列表{i}"),
+ data.get(f"用户编号ID列表{i}"),
+ data.get(f"用户列表{i}"),
+ data.get(f"账号编号列表{i}"),
+ data.get(f"索引列表{i}"),
+ data.get(f"编号{i}"),
+ data.get(f"id{i}"),
+ data.get(f"ids{i}"),
+ data.get(f"索引{i}"),
+ )
if group:
items.append(
{
@@ -1255,7 +1526,27 @@ def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
{
"targetAudience": str(group).strip(),
"recReason": str(first_present(item.get("理由"), item.get("recReason"), item.get("reason")) or "").strip(),
- "userIndexes": parse_index_list(first_present(item.get("用户编号列表"), item.get("users"), item.get("accounts"))),
+ "userIndexes": parse_index_list(
+ first_present(
+ item.get("用户编号列表"),
+ item.get("编号id列表"),
+ item.get("编号ID列表"),
+ item.get("用户编号id列表"),
+ item.get("用户编号ID列表"),
+ item.get("编号id"),
+ item.get("编号ID"),
+ item.get("用户编号id"),
+ item.get("用户编号ID"),
+ item.get("编号"),
+ item.get("索引"),
+ item.get("id"),
+ item.get("ids"),
+ item.get("users"),
+ item.get("accounts"),
+ item.get("userIndexes"),
+ item.get("accountIndexes"),
+ )
+ ),
"rank": i,
}
)
@@ -1272,6 +1563,7 @@ def audience_score(item: dict[str, Any]) -> tuple[int, int]:
def select_top_audiences(items: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
+ """Deduplicate audience names and keep the strongest candidates."""
unique: dict[str, dict[str, Any]] = {}
for item in items:
key = item.get("targetAudience", "")
@@ -1301,6 +1593,7 @@ def pathway_score(item: dict[str, Any]) -> tuple[int, int, int]:
def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Merge the same account found from different audiences before saving top 3."""
merged: dict[str, dict[str, Any]] = {}
for item in candidates:
account = item.get("account") or {}
@@ -1325,6 +1618,7 @@ def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str,
def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
+ """Normalize weak-point model output into a capped list."""
vulns: list[dict[str, str]] = []
for i in range(1, 21):
vuln = first_present(data.get(f"脆弱点{i}"), data.get(f"vulnerability{i}"), data.get(f"vuln{i}"))
@@ -1339,6 +1633,7 @@ def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
def extract_strategies(data: dict[str, Any], limit: int) -> list[dict[str, Any]]:
+ """Normalize strategy output and preserve the modal text for later mapping."""
strategies: list[dict[str, Any]] = []
for i in range(1, 21):
title = first_present(data.get(f"信息标题{i}"), data.get(f"主题{i}"), data.get(f"topic{i}"))
@@ -1395,11 +1690,29 @@ def response_id(response: Any) -> int | None:
return None
+def response_rows(response: Any) -> list[Any]:
+ """Extract list rows from common CRUD response shapes."""
+ if isinstance(response, list):
+ return response
+ if not isinstance(response, dict):
+ return []
+ for key in ("data", "rows", "items", "records", "list"):
+ value = response.get(key)
+ if isinstance(value, list):
+ return value
+ rows = response_rows(value)
+ if rows:
+ return rows
+ return []
+
+
def save_with_id(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[int | None, Any | None]:
response, _ = http_post_json(ctx, state, endpoint_key, payload, stage)
rid = response_id(response)
if rid is None:
- record_error(state, stage, "保存接口未返回 id,视为失败", request=payload, response=response)
+ record_error(state, stage, "保存接口未返回 ID,视为失败", request=payload, response=response)
+ else:
+ emit_save_feedback(state, stage, endpoint_key, payload, rid, dry_run=ctx.dry_run)
return rid, response
@@ -1429,6 +1742,7 @@ def parse_step_flags(raw: Any) -> dict[str, Any]:
def mark_step_status(ctx: FlowContext, state: dict[str, Any], links: list[str], stage: str) -> None:
+ """Update the frontend step bar without overwriting unrelated step flags."""
if not ctx.sync_step_status:
record_event(state, stage, "跳过 task-action.step 状态同步")
return
@@ -1477,12 +1791,126 @@ def strategy_platform(value: Any) -> Any:
return platform_to_number(value) or 1
-def strategy_model_type(value: Any) -> str:
- # Frontend defaults to text model type "1" when no modal type is selected.
- raw = first_present(value, "1")
- if isinstance(raw, list):
- return ",".join(str(v) for v in raw if str(v).strip()) or "1"
- return str(raw or "1")
+def choice_tokens(value: Any) -> list[Any]:
+ """Split frontend choice values from arrays, JSON strings, and mixed delimiters."""
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return choice_tokens(
+ first_present(value.get("value"), value.get("dictValue"), value.get("label"), value.get("dictLabel"))
+ )
+ if isinstance(value, (list, tuple, set)):
+ tokens: list[Any] = []
+ for item in value:
+ tokens.extend(choice_tokens(item))
+ return tokens
+ text = str(value).strip()
+ if not text:
+ return []
+ if text[0] in "[{":
+ try:
+ parsed = json.loads(text)
+ if parsed != value:
+ return choice_tokens(parsed)
+ except Exception:
+ pass
+ return [part.strip() for part in re.split(r"[,,、;/|]+", text) if part.strip()]
+
+
+def dict_choices(
+ ctx: FlowContext,
+ state: dict[str, Any],
+ dict_type: str,
+ stage: str,
+) -> list[dict[str, Any]]:
+ """Fetch and cache frontend dictionary choices such as modelType."""
+ cache = state.setdefault("_dictChoiceCache", {})
+ if dict_type in cache:
+ cached = cache[dict_type]
+ return cached if isinstance(cached, list) else []
+ if ctx.dry_run:
+ cache[dict_type] = []
+ return []
+
+ query = urllib.parse.urlencode({"filter": f"dictType||$eq||{dict_type}", "limit": 999, "page": 1})
+ url = f"{url_for(ctx, 'dict_data')}?{query}"
+ response, _ = http_request_json_url(ctx, state, "GET", url, None, stage, endpoint_key="dict_data")
+ choices: list[dict[str, Any]] = []
+ for row in response_rows(response):
+ if not isinstance(row, dict):
+ continue
+ label = str(first_present(row.get("dictLabel"), row.get("label"), "") or "").strip()
+ raw_value = first_present(row.get("dictValue"), row.get("value"))
+ if not label or raw_value is None:
+ continue
+ number = as_int(raw_value)
+ choices.append({"label": label, "value": number if number is not None else str(raw_value).strip()})
+ for alias in choice_tokens(row.get("alias")):
+ alias_label = str(alias).strip()
+ if alias_label:
+ choices.append({"label": alias_label, "value": number if number is not None else str(raw_value).strip()})
+ cache[dict_type] = choices
+ return choices
+
+
+def choice_value_by_label(choices: list[dict[str, Any]] | None, label: str) -> Any | None:
+ needle = re.sub(r"\s+", "", str(label or "").lower())
+ if not needle:
+ return None
+ for item in choices or []:
+ item_label = re.sub(r"\s+", "", str(item.get("label") or "").lower())
+ if item_label and (needle == item_label or needle in item_label or item_label in needle):
+ return item.get("value")
+ return None
+
+
+def model_type_token_value(token: Any, choices: list[dict[str, Any]] | None = None) -> Any:
+ """Convert modal labels like 图片/视频 into frontend dictionary values."""
+ number = as_int(token)
+ if number is not None:
+ return number
+
+ text = str(token or "").strip()
+ lower = text.lower()
+ exact = choice_value_by_label(choices, text)
+ if exact is not None:
+ return exact
+
+ label = ""
+ fallback_value = 2
+ if "视" in text or "video" in lower:
+ label, fallback_value = "视频", 3
+ elif "图" in text or "image" in lower or "picture" in lower or "photo" in lower:
+ label, fallback_value = "图片", 2
+ elif "音" in text or "audio" in lower or "sound" in lower:
+ label, fallback_value = "音频", 4
+ elif "发文" in text or "文本" in text or "文字" in text or "text" in lower:
+ label, fallback_value = "文本", 1
+
+ if label:
+ matched = choice_value_by_label(choices, label)
+ if matched is not None:
+ return matched
+ return fallback_value
+
+ # Mirrors the frontend helper's unknown-token default.
+ return 2
+
+
+def strategy_model_type(value: Any, choices: list[dict[str, Any]] | None = None) -> str:
+ """Return the comma-separated value string expected by task-action-message."""
+ tokens = choice_tokens(value)
+ if not tokens:
+ # Frontend save paths fall back to "1" when no modal type is selected.
+ return "1"
+ values: list[str] = []
+ seen: set[str] = set()
+ for token in tokens:
+ normalized = str(model_type_token_value(token, choices)).strip()
+ if normalized and normalized not in seen:
+ values.append(normalized)
+ seen.add(normalized)
+ return ",".join(values) or "1"
def strategy_language(value: Any) -> int:
@@ -1501,11 +1929,28 @@ def save_strategy_details(
*,
audience: bool,
) -> None:
- platform = strategy_platform(first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})))
+ """Save all frontend-readable strategy child rows.
+
+ The strategy table and arrangement page do not read only the main strategy
+ row; they also expect channel, narrate, message, and relation rows.
+ """
+ platform = strategy_platform(
+ first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
+ )
+ account_type = str(
+ first_present(
+ strategy.get("accountType"),
+ item.get("accountType"),
+ item.get("type"),
+ "目标受众账号" if audience else "关键传播节点账号",
+ )
+ or ""
+ ).strip()
persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
title = str(strategy.get("title") or "").strip()
+ model_type_choices = dict_choices(ctx, state, "modelType", stage + "/dict-modelType")
channel_id, _ = save_with_id(
ctx,
@@ -1516,6 +1961,7 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"platform": platform,
+ "accountType": account_type,
"personaJson": json.dumps([{"persona": persona, "size": persona_size}], ensure_ascii=False),
},
stage + "/save-channel",
@@ -1531,7 +1977,10 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"channelId": channel_id,
- "modelType": strategy_model_type(first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType"))),
+ "modelType": strategy_model_type(
+ first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType")),
+ model_type_choices,
+ ),
"language": strategy_language(first_present(strategy.get("language"), strategy.get("语种"))),
"respondingCount": responding_count,
"messageCount": responding_count,
@@ -1564,7 +2013,39 @@ def object_for_audience(audience: dict[str, Any]) -> dict[str, Any]:
}
+def downstream_limit(ctx: FlowContext, *, audience: bool) -> int:
+ """Limit expensive downstream analysis while keeping 3 upstream rows stored."""
+ raw = ctx.downstream_audiences if audience else ctx.downstream_pathways
+ return max(1, raw)
+
+
+def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, audience: bool) -> list[dict[str, Any]]:
+ """Select the 1-2 best stored rows for vulnerability/strategy generation."""
+ source_key = "topAudiences" if audience else "topPathways"
+ id_key = "designId" if audience else "pathwayAccountId"
+ selected_key = "selectedDownstreamAudiences" if audience else "selectedDownstreamPathways"
+ items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)]
+ limit = downstream_limit(ctx, audience=audience)
+ selected = items[:limit]
+ state[selected_key] = [
+ {
+ "id": item.get(id_key),
+ "name": item.get("targetAudience") if audience else item.get("accountName"),
+ }
+ for item in selected
+ ]
+ if items:
+ record_event(
+ state,
+ stage,
+ f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}",
+ {"limit": limit, "selected": state[selected_key]},
+ )
+ return selected
+
+
def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze audiences, save top 3, and attach any referenced accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step1-audience"
if state.get("topAudiences") and not ctx.dry_run:
@@ -1582,12 +2063,15 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
state["audienceRawText"] = text
+ # The first model call may return raw text plus embedded account objects.
accounts = accounts_from_response(parsed, objects, text)
state["audienceAccounts"] = accounts
+ # The second model call converts the long audience analysis into numbered
+ # groups that can be saved as task-action-design rows.
group_payload = {
"mubiao": state["mubiao"],
- "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
"zhanghao": accounts,
"history": [],
"stream": True,
@@ -1602,6 +2086,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
top = select_top_audiences(candidates, ctx.max_audiences)
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the audience first; account rows and downstream analyses depend
+ # on the returned designId.
design_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
@@ -1614,6 +2100,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
continue
item = {**item, "designId": design_id, "saveResponse": resp, "accounts": [], "savedAccountIds": []}
for ref in item.get("userIndexes") or []:
+ # Model references are intentionally fuzzy, so resolve them against
+ # both explicit indexes and account names/ids.
account = resolve_account_ref(ref, accounts)
if account is None:
record_error(state, stage + "/save-design-account", f"用户编号 {ref} 未匹配到候选账号")
@@ -1645,6 +2133,7 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze key pathway accounts for saved audiences and store top 3."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step2-pathway"
audiences = [a for a in state.get("topAudiences") or [] if a.get("designId")]
@@ -1656,6 +2145,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
return
all_candidates: list[dict[str, Any]] = []
for audience in audiences:
+ # Analyze pathway candidates in the context of each saved audience.
payload = {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。",
"otherinfo": {
@@ -1673,6 +2163,8 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
accounts = accounts_from_response(parsed, objects, text)
+ # Ask the model to point back to candidate accounts, then resolve those
+ # references to concrete account objects.
spread_payload = {
"mubiao": state["mubiao"],
"user_message": "分析其中关键信息。请从候选账号中抽取核心传播节点,字段必须是:账号1、理由1、账号2、理由2 ...,最多输出5个。账号字段优先返回候选数组的引用编号或索引,只返回JSON。",
@@ -1707,6 +2199,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways]
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the account row before writing the design-pathway relation.
account = item["account"]
aid = account_id(account)
name = account_name(account)
@@ -1757,12 +2250,14 @@ def run_step4(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate and save weak points for selected audiences or pathway accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step3-audience-vulns" if audience else "step4-pathway-vulns"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
output_key = "audienceVulns" if audience else "pathwayVulns"
state.setdefault(output_key, {})
for item in items:
+ # owner_id is designId for audiences and pathwayAccountId for accounts.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
if not owner_id:
continue
@@ -1789,6 +2284,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) ->
vulns = extract_vulns(data, ctx.max_vulns)
saved: list[dict[str, Any]] = []
for vuln in vulns:
+ # The two weak-point tables have nearly identical payloads but
+ # different foreign-key names.
if audience:
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
@@ -1835,14 +2332,16 @@ def run_step6(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate strategies from saved weak points and persist frontend child rows."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step5-audience-strategy" if audience else "step6-pathway-strategy"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {}
output_key = "audienceStrategies" if audience else "pathwayStrategies"
state.setdefault(output_key, [])
existing_topics = {s.get("topic") for s in state[output_key] if isinstance(s, dict)}
for item in items:
+ # Strategies are only meaningful after the matching weak points exist.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
vulns = vulns_by_owner.get(str(owner_id)) or []
vuln_texts = [v.get("vulnerabilities") for v in vulns if v.get("vulnerabilities")]
@@ -1876,16 +2375,20 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
continue
if topic in existing_topics:
continue
+ # Save the main row first; channel/narrate/message/relation rows all
+ # require the returned strategy id.
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
"targetAudience": target,
"accountType": account_type,
"topic": topic,
- "strategyTitle": "",
+ "strategyTitle": str(strategy.get("title") or "").strip(),
}
strategy_id, resp = save_with_id(ctx, state, "save_strategy", save_payload, stage + "/save")
if strategy_id is not None:
+ # Frontend strategy pages read these child tables, so a strategy
+ # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience)
existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp})
@@ -1914,13 +2417,14 @@ STEP_LABELS = {
def state_counts(state: dict[str, Any]) -> dict[str, int]:
+ """Summarize stored objects for step feedback."""
return {
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceVulns": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
- "pathwayVulns": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "受众脆弱点数量": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
+ "传播节点脆弱点数量": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
}
@@ -1932,23 +2436,26 @@ def emit_step_feedback(
status: str | None = None,
note: str = "",
) -> None:
+ # One feedback line per step keeps the single-skill and split-skill versions
+ # equally transparent to the user.
total_errors = len(state.get("errors") or [])
new_errors = max(0, total_errors - before_error_count)
final_status = status or ("partial" if new_errors else "ok")
payload = {
- "type": "STEP_FEEDBACK",
- "step": step,
- "label": STEP_LABELS.get(step, step),
- "status": final_status,
- "note": note,
- "newErrors": new_errors,
- "totalErrors": total_errors,
- "counts": state_counts(state),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "updatedAt": state.get("updatedAt"),
+ "日志类型": "步骤反馈",
+ "步骤": STEP_NUMBER_LABELS.get(step, step),
+ "步骤名称": STEP_LABELS.get(step, step),
+ "状态": STATUS_LABELS.get(final_status, final_status),
+ "执行状态": status_label(final_status),
+ "备注": note,
+ "本步骤新增错误数": new_errors,
+ "累计错误数": total_errors,
+ "累计入库数量": state_counts(state),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "更新时间": state.get("updatedAt"),
}
- print("STEP_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["step"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def finish_step(
@@ -1960,6 +2467,8 @@ def finish_step(
status: str | None = None,
note: str = "",
) -> None:
+ # Always persist before emitting step feedback so the user can inspect files
+ # immediately after a reported step completes.
state["updatedAt"] = utc_now()
save_json_file(ctx.state_path, state)
if ctx.summary_path:
@@ -1980,6 +2489,11 @@ def skip_step_with_feedback(ctx: FlowContext, state: dict[str, Any], step: str,
def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Legacy full runner.
+
+ The wrapper script is preferred for real-time logs, but this remains useful
+ for dry-runs and unattended local troubleshooting.
+ """
run_step_with_feedback(ctx, state, "step1")
run_step_with_feedback(ctx, state, "step2")
run_step_with_feedback(ctx, state, "step3")
@@ -1995,6 +2509,7 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
def write_summary(path: Path, state: dict[str, Any]) -> None:
+ """Write a human-readable checkpoint beside the machine state JSON."""
audience_vuln_count = sum(len(v) for v in (state.get("audienceVulns") or {}).values())
pathway_vuln_count = sum(len(v) for v in (state.get("pathwayVulns") or {}).values())
lines = [
@@ -2005,6 +2520,8 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
f"- actionId:{state.get('actionId')}",
f"- 受众入库:{len(state.get('topAudiences') or [])}",
f"- 传播节点入库:{len(state.get('topPathways') or [])}",
+ f"- 下游受众选择:{len(state.get('selectedDownstreamAudiences') or [])}",
+ f"- 下游传播节点选择:{len(state.get('selectedDownstreamPathways') or [])}",
f"- 受众脆弱点入库:{audience_vuln_count}",
f"- 传播节点脆弱点入库:{pathway_vuln_count}",
f"- 受众策略入库:{len(state.get('audienceStrategies') or [])}",
@@ -2021,9 +2538,9 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
if state.get("errors"):
lines += ["", "## 失败明细"]
for err in state["errors"]:
- lines.append(f"- [{err.get('stage')}] {err.get('message')} status={err.get('status')} url={err.get('url')}")
+ lines.append(f"- [{stage_label(err.get('stage'))}] {err.get('message')} 状态码={err.get('status')} 接口={err.get('url')}")
if err.get("response"):
- lines.append(f" - response: {err.get('response')}")
+ lines.append(f" - 响应摘要:{err.get('response')}")
text = "\n".join(lines) + "\n"
try:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -2034,34 +2551,34 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
def build_arg_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(description="Run xdfx analysis/storage flow.")
+ parser = argparse.ArgumentParser(description="运行 xdfx 分析与入库流程。")
parser.add_argument("--mode", choices=["full", *STEP_FUNCS.keys()], default="full")
- parser.add_argument("--input", help="JSON config file. Keys: api_base, taskId, actionId, mubiao, direction, platform, headers.")
- parser.add_argument("--state", default="xdfx-flow-state.json", help="State JSON path for resume and step handoff.")
- parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown summary output path.")
+ parser.add_argument("--input", help="JSON 配置文件。常用字段:api_base、taskId、actionId、mubiao、direction、platform、headers。")
+ parser.add_argument("--state", default="xdfx-flow-state.json", help="用于断点续跑和步骤交接的状态 JSON 文件路径。")
+ parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown 执行摘要输出路径。")
parser.add_argument("--api-base")
parser.add_argument("--task-id")
parser.add_argument("--action-id")
parser.add_argument("--mubiao")
parser.add_argument("--direction", default=None)
parser.add_argument("--platform", default=None)
- parser.add_argument("--auth-token", default=os.getenv("XDFX_AUTH_TOKEN"))
- parser.add_argument("--authorization", default=os.getenv("AUTHORIZATION"))
- parser.add_argument("--header", action="append", default=[], help="Extra HTTP header, format 'Key: Value'.")
- parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="Username for automatic login when Authorization is missing or stale.")
- parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="Password query parameter for automatic username login.")
- parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="Login endpoint path or absolute URL.")
- parser.add_argument("--no-auto-login", action="store_true", help="Disable automatic username login and 401 token refresh.")
- parser.add_argument("--verify-ssl", action="store_true", help="Verify HTTPS certificates. Default is disabled for internal/UAT certificates.")
+ parser.add_argument("--header", action="append", default=[], help="附加非鉴权 HTTP 请求头,格式为 'Key: Value';Authorization 会被忽略。")
+ parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="xdfx_auth 启动阶段自动登录使用的用户名。")
+ parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="用户名登录接口的 password 查询参数。")
+ parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="登录接口路径或完整 URL。")
+ parser.add_argument("--no-auto-login", action="store_true", help="关闭 xdfx_auth 自动登录和 401 刷新。")
+ parser.add_argument("--verify-ssl", action="store_true", help="校验 HTTPS 证书。默认不校验,以兼容内网/UAT 自签证书。")
parser.add_argument("--timeout", type=float, default=90)
parser.add_argument("--retries", type=int, default=2)
parser.add_argument("--dry-run", action="store_true")
- parser.add_argument("--no-step-status", action="store_true", help="Do not update task/task-action.step after completed steps.")
- parser.add_argument("--model-retries", type=int, default=1, help="Retry model/stream endpoints when the first response is empty, timeout-like, or says the model did not respond.")
+ parser.add_argument("--no-step-status", action="store_true", help="步骤完成后不更新 task/task-action.step。")
+ parser.add_argument("--model-retries", type=int, default=1, help="模型/流式接口首次空响应、超时或提示模型无响应时的重试次数。")
parser.add_argument("--max-audiences", type=int, default=3)
parser.add_argument("--max-pathways", type=int, default=3)
parser.add_argument("--max-vulns", type=int, default=3)
parser.add_argument("--max-strategies", type=int, default=3)
+ parser.add_argument("--downstream-audiences", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库受众数量,默认 2。")
+ parser.add_argument("--downstream-pathways", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库传播节点数量,默认 2。")
return parser
@@ -2070,6 +2587,8 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
state_path = Path(args.state)
state = default_state()
+ # Load previous state first, then overlay input JSON and finally CLI flags.
+ # That ordering makes resume safe while still allowing explicit overrides.
if state_path.exists():
loaded = load_json_file(state_path)
if isinstance(loaded, dict):
@@ -2081,9 +2600,7 @@ def main(argv: list[str] | None = None) -> int:
merge_config_into_state(state, config)
input_headers = config.get("headers")
if isinstance(input_headers, dict):
- args.header.extend(f"{k}: {v}" for k, v in input_headers.items())
- args.auth_token = args.auth_token or config.get("auth_token")
- args.authorization = args.authorization or config.get("authorization")
+ args.header.extend(f"{k}: {v}" for k, v in input_headers.items() if str(k).lower() != "authorization")
if config.get("login_username") is not None:
args.login_username = str(config.get("login_username") or "")
if config.get("login_password") is not None:
@@ -2094,6 +2611,10 @@ def main(argv: list[str] | None = None) -> int:
args.no_auto_login = True
if config.get("verify_ssl") is not None:
args.verify_ssl = bool(config.get("verify_ssl"))
+ if config.get("downstream_audiences") is not None:
+ args.downstream_audiences = as_int(config.get("downstream_audiences")) or args.downstream_audiences
+ if config.get("downstream_pathways") is not None:
+ args.downstream_pathways = as_int(config.get("downstream_pathways")) or args.downstream_pathways
cli_config = {
"api_base": args.api_base,
@@ -2105,7 +2626,8 @@ def main(argv: list[str] | None = None) -> int:
}
merge_config_into_state(state, {k: v for k, v in cli_config.items() if v is not None})
api_base = state.get("api_base") or "https://ch1.b.uat.4.cn"
- headers = parse_headers(args.header, args.auth_token, args.authorization)
+ headers = parse_headers(args.header)
+ # ctx owns runtime knobs; state owns business data and resumable ids.
ctx = FlowContext(
api_base=api_base,
headers=headers,
@@ -2118,6 +2640,8 @@ def main(argv: list[str] | None = None) -> int:
max_pathways=max(args.max_pathways, 1),
max_vulns=max(args.max_vulns, 1),
max_strategies=max(args.max_strategies, 1),
+ downstream_audiences=max(args.downstream_audiences, 1),
+ downstream_pathways=max(args.downstream_pathways, 1),
sync_step_status=not args.no_step_status,
model_retries=max(args.model_retries, 0),
verify_ssl=bool(args.verify_ssl),
@@ -2131,11 +2655,17 @@ def main(argv: list[str] | None = None) -> int:
state["_summaryPath"] = str(ctx.summary_path) if ctx.summary_path else ""
state["_autoLogin"] = ctx.auto_login
state["_verifySsl"] = ctx.verify_ssl
+ state["_downstreamLimits"] = {
+ "audiences": ctx.downstream_audiences,
+ "pathways": ctx.downstream_pathways,
+ }
record_event(state, "runner", f"start mode={args.mode} dry_run={ctx.dry_run}")
try:
require_context(state, "taskId", "actionId", "mubiao")
- if ctx.auto_login and not has_authorization(ctx.headers):
- try_auto_login(ctx, state, "runner/auto-login")
+ if ctx.auto_login and not ctx.dry_run:
+ # Always acquire a token through the imported auth package before the first business call.
+ if not try_auto_login(ctx, state, "runner/initial-login", force=True):
+ raise FlowError("启动阶段获取 token 失败,已停止执行,避免无 token 调用业务接口")
if args.mode == "full":
run_full(ctx, state)
else:
@@ -2145,13 +2675,14 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(f"ERROR: {exc}", file=sys.stderr)
+ print(f"错误:{exc}", file=sys.stderr)
return 2
except Exception as exc: # noqa: BLE001
record_error(state, "runner", "未捕获异常", response=traceback.format_exc(), exception=exc)
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
+ print("未捕获异常堆栈:", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
return 1
@@ -2159,20 +2690,23 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(json.dumps(
- {
- "ok": True,
- "mode": args.mode,
- "state": str(state_path),
- "summary": str(ctx.summary_path) if ctx.summary_path else "",
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
- "errors": len(state.get("errors") or []),
- },
- ensure_ascii=False,
- ))
+ # Final JSON is intentionally compact; detailed per-step status has already
+ # been emitted as Chinese step feedback.
+ final_payload = {
+ "日志类型": "执行完成",
+ "是否成功": "是",
+ "执行模式": mode_label(args.mode),
+ "状态文件": str(state_path),
+ "摘要文件": str(ctx.summary_path) if ctx.summary_path else "",
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "已选择受众数": len(state.get("selectedDownstreamAudiences") or []),
+ "已选择传播节点数": len(state.get("selectedDownstreamPathways") or []),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
+ "错误数": len(state.get("errors") or []),
+ }
+ print(LOG_PREFIXES["done"] + " " + json.dumps(final_payload, ensure_ascii=False))
return 0
diff --git a/skills-data/xdfx-step5-audience-strategy/SKILL.md b/skills-data/xdfx-step5-audience-strategy/SKILL.md
index b0f8de85..9bba2a5a 100644
--- a/skills-data/xdfx-step5-audience-strategy/SKILL.md
+++ b/skills-data/xdfx-step5-audience-strategy/SKILL.md
@@ -13,27 +13,39 @@ description: xdfx 拆分流程 step5,基于受众目标与受众脆弱点生
python scripts/xdfx_flow.py --mode step5 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
-鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
+鉴权和证书由脚本兜底处理:脚本启动时先 `import scripts/xdfx_auth.py`,自动用 `admin / ch@user` 获取 `access_token`,并在第一次业务接口调用前写入内部 Authorization;不要通过命令行传 token。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
-- 按受众读取已入库脆弱点。
+- 从已入库受众中默认选择前 2 个继续生成策略;可用 `--downstream-audiences 1` 调成只处理 1 个。
+- 对每个被选中的受众读取已入库脆弱点并默认生成最多 3 个策略。
- 调用策略生成模型接口。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 自动解析 `发布人设N / 账号类型N / 信息标题N` 等字段。
- 调用策略保存接口。
- 同步写入 `task-action-channel`、`base-narrate`、`task-action-message`、`task-action-design-strategy-relation`。
+- 保存 `task-action-message.modelType` 前会读取前端 `modelType` 字典并把“图片,视频/文本,图片”等模态文字转成 value 逗号串,避免策略表格回显为空。
- 同步前端步骤状态 `step-analysis-strategy` 与 `step-analysis-strategy-gantt`。
- 保存成功后写入 `audienceStrategies`。
-如果某个受众没有可用脆弱点,脚本会跳过并在 `errors` 中记录。
+如果某个受众没有可用脆弱点,脚本会记录错误;智能体必须先回到 Step3 或重跑 Step5 修复当前策略环节,不能直接跳过。
+
+## 失败重试规则
+
+- 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前 Step5 命令。
+- 如果失败原因是缺少受众脆弱点,先重跑 Step3 生成脆弱点,再重跑 Step5;重试前必须告诉用户原因和重试动作。
+- Step5 未重试成功前,不允许继续 Step6 或做最终汇总;不要因为 Step5 失败就跳过受众策略分析。
+- Step5 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
## 步骤反馈
-命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step5 的完成状态、当前受众策略数量和新增错误数,再决定是否继续下一步。
+命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step5 的完成状态、当前受众策略数量和新增错误数,再决定是否继续下一步。
-执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
+执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。
-如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的策略、策略子表和返回 ID。
+
+如果出现 `错误反馈 {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+
+如果出现 `JSON解析复核 {...}`,必须查看 `响应片段`,判断是否能识别受众策略 JSON;无法确认时重跑 Step5。
-如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别受众策略 JSON;无法确认时重跑 Step5。
diff --git a/skills-data/xdfx-step5-audience-strategy/scripts/xdfx_auth.py b/skills-data/xdfx-step5-audience-strategy/scripts/xdfx_auth.py
new file mode 100644
index 00000000..fe46f809
--- /dev/null
+++ b/skills-data/xdfx-step5-audience-strategy/scripts/xdfx_auth.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""Authentication helper for xdfx skill scripts.
+
+The caller should not pass tokens through command-line arguments. This module
+owns the fixed admin login flow and returns a Bearer token before business
+interfaces are called.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import ssl
+import urllib.error
+import urllib.parse
+import urllib.request
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass
+class AuthResult:
+ token: str
+ url: str
+ status: int | None
+ raw: str
+
+
+class AuthError(RuntimeError):
+ def __init__(
+ self,
+ message: str,
+ *,
+ url: str,
+ status: int | None = None,
+ response: str = "",
+ exception: BaseException | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.url = url
+ self.status = status
+ self.response = response
+ self.exception = exception
+
+
+def auth_header_value(token: str) -> str:
+ token = token.strip()
+ return token if token.lower().startswith("bearer ") else f"Bearer {token}"
+
+
+def redact_url(url: str) -> str:
+ return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
+
+
+def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
+ if verify_ssl:
+ return None
+ return ssl._create_unverified_context()
+
+
+def build_login_url(api_base: str, path: str, password: str) -> str:
+ base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
+ if password:
+ separator = "&" if "?" in base else "?"
+ base = f"{base}{separator}password={urllib.parse.quote(password, safe='')}"
+ return base
+
+
+def extract_access_token(data: Any) -> str | None:
+ if isinstance(data, dict):
+ for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
+ value = data.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ for value in data.values():
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ elif isinstance(data, list):
+ for value in data:
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ return None
+
+
+def parse_json_loose(text: str) -> Any:
+ try:
+ return json.loads(text)
+ except Exception:
+ pass
+ match = re.search(r"[\[{]", text or "")
+ if not match:
+ return {}
+ start = match.start()
+ for end in range(len(text), start, -1):
+ try:
+ return json.loads(text[start:end])
+ except Exception:
+ continue
+ return {}
+
+
+def fetch_access_token(
+ *,
+ api_base: str,
+ username: str = "admin",
+ password: str = "ch@user",
+ login_path: str = "/api/v1/auth/login/username",
+ timeout: float = 90,
+ verify_ssl: bool = False,
+) -> AuthResult:
+ """Login once and return the extracted access token."""
+ url = build_login_url(api_base, login_path, password)
+ payload = {"username": username}
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ parsed = parse_json_loose(raw)
+ token = extract_access_token(parsed)
+ if not token:
+ raise AuthError(
+ "登录接口已响应但未找到 access_token",
+ url=redact_url(url),
+ status=getattr(resp, "status", None),
+ response=raw,
+ )
+ return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
+ raise AuthError(
+ "登录接口返回错误",
+ url=redact_url(url),
+ status=exc.code,
+ response=body,
+ exception=exc,
+ ) from exc
+ except AuthError:
+ raise
+ except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
+ raise AuthError(
+ "登录接口调用异常",
+ url=redact_url(url),
+ response=str(exc),
+ exception=exc,
+ ) from exc
diff --git a/skills-data/xdfx-step5-audience-strategy/scripts/xdfx_flow.py b/skills-data/xdfx-step5-audience-strategy/scripts/xdfx_flow.py
index 52880e82..5f405cc8 100644
--- a/skills-data/xdfx-step5-audience-strategy/scripts/xdfx_flow.py
+++ b/skills-data/xdfx-step5-audience-strategy/scripts/xdfx_flow.py
@@ -26,7 +26,10 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
+import xdfx_auth
+
+# Keep every backend path in one table so endpoint drift is easy to audit.
ENDPOINTS = {
"aiuser": "/gpt/chat/chat_aiuser",
"group": "/gpt/chat/chat_qianqian_group_chat",
@@ -44,10 +47,12 @@ ENDPOINTS = {
"save_narrate": "/api/base/base-narrate",
"save_message": "/api/task/task-action-message",
"save_strategy_relation": "/api/task/task-action-design-strategy-relation",
+ "dict_data": "/api/sys/sys-dict-data",
}
+# Human-readable names are used only in the Chinese feedback logs.
ENDPOINT_LABELS = {
- "login": "登录获取访问 token",
+ "login": "登录获取访问令牌",
"aiuser": "调用候选账号/受众分析模型接口",
"group": "调用受众目标结构化抽取接口",
"spread": "调用关键传播节点结构化抽取接口",
@@ -64,8 +69,86 @@ ENDPOINT_LABELS = {
"save_narrate": "保存策略信息主题",
"save_message": "保存策略消息配置",
"save_strategy_relation": "保存策略与受众/传播节点关系",
+ "dict_data": "读取前端字典数据",
}
+LOG_PREFIXES = {
+ "operation": "操作反馈",
+ "error": "错误反馈",
+ "json_review": "JSON解析复核",
+ "save": "入库反馈",
+ "step": "步骤反馈",
+ "done": "执行完成",
+}
+
+STATUS_LABELS = {
+ "ok": "成功",
+ "partial": "部分完成",
+ "skipped": "已跳过",
+}
+
+STEP_NUMBER_LABELS = {
+ "step1": "第一步",
+ "step2": "第二步",
+ "step3": "第三步",
+ "step4": "第四步",
+ "step5": "第五步",
+ "step6": "第六步",
+}
+
+HTTP_METHOD_LABELS = {
+ "GET": "读取(GET)",
+ "POST": "提交(POST)",
+ "PUT": "更新(PUT)",
+ "PATCH": "局部更新(PATCH)",
+ "DELETE": "删除(DELETE)",
+}
+
+STAGE_SEGMENT_LABELS = {
+ "analyze": "模型分析",
+ "extract": "结构化抽取",
+ "save": "保存数据",
+ "read": "读取数据",
+ "status": "同步状态",
+}
+
+REQUEST_FIELD_LABELS = {
+ "history": "历史消息",
+ "max_tokens": "最大生成令牌数",
+ "model_name": "模型名称",
+ "otherinfo": "其他信息",
+ "prompt_name": "提示词名称",
+ "stream": "是否流式",
+ "temperature": "温度",
+ "usermessage": "用户消息",
+ "user_message": "用户消息",
+ "mubiao": "行动目标",
+ "zhanghao": "账号列表",
+ "taskId": "任务ID",
+ "actionId": "行动ID",
+ "taskActionId": "行动ID",
+ "designId": "受众ID",
+ "pathwayId": "传播节点ID",
+ "strategyId": "策略ID",
+ "accountId": "账号ID",
+ "accountName": "账号名称",
+ "accountType": "账号类型",
+ "targetAudience": "受众目标",
+ "vulnerabilities": "脆弱点",
+ "topic": "信息标题",
+ "narrate": "叙事主题",
+ "platform": "平台",
+ "persona": "发布人设",
+ "modelType": "信息形式",
+ "quantity": "数量",
+ "publishTime": "发布时间段",
+ "reason": "理由",
+ "ids": "ID列表",
+ "id": "ID",
+ "step": "步骤状态",
+}
+
+# Platform values mirror the frontend dictionaries used by xdfx strategy pages.
PLATFORM_MAP = {
"facebook": 1,
"fb": 1,
@@ -78,6 +161,8 @@ PLATFORM_MAP = {
"yt": 4,
}
+# The model service sometimes returns a successful HTTP response with these
+# strings instead of useful content. Treat them like transient failures.
MODEL_RETRY_MARKERS = (
"y模型没有响应",
"模型没有响应",
@@ -118,6 +203,12 @@ class FlowError(RuntimeError):
@dataclass
class FlowContext:
+ """Runtime options shared by every step.
+
+ Keep this object boring and serializable-ish: it is passed through every
+ helper, while durable progress belongs in the state JSON file.
+ """
+
api_base: str
headers: dict[str, str]
timeout: float
@@ -129,6 +220,8 @@ class FlowContext:
max_pathways: int = 3
max_vulns: int = 3
max_strategies: int = 3
+ downstream_audiences: int = 2
+ downstream_pathways: int = 2
sync_step_status: bool = True
model_retries: int = 1
verify_ssl: bool = False
@@ -153,7 +246,7 @@ def compact(value: Any, limit: int = 1400) -> str:
text = repr(value)
if len(text) <= limit:
return text
- return text[:limit] + f"... "
+ return text[:limit] + f"... <已截断 {len(text) - limit} 个字符>"
def stable_json(value: Any) -> str:
@@ -223,6 +316,11 @@ def save_json_file(path: str | Path, value: Any) -> None:
def default_state() -> dict[str, Any]:
+ """Create a resumable state container.
+
+ The script can be run as six independent commands. IDs returned by one step
+ are persisted here so later steps can resume without redoing earlier saves.
+ """
return {
"api_base": "",
"taskId": "",
@@ -247,6 +345,7 @@ def default_state() -> dict[str, Any]:
def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> None:
+ # Accept both frontend-style camelCase and CLI-friendly snake_case inputs.
aliases = {
"task_id": "taskId",
"action_id": "actionId",
@@ -260,16 +359,12 @@ def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> No
continue
if dest in {"taskId", "actionId"}:
state[dest] = str(value)
- elif dest in state or dest in {
- "api_base",
- "headers",
- "auth_token",
- "authorization",
- }:
+ elif dest in state or dest in {"api_base", "headers"}:
state[dest] = value
def record_event(state: dict[str, Any], stage: str, message: str, data: Any | None = None) -> None:
+ """Append a compact diagnostic event to the state file."""
state.setdefault("events", []).append(
{
"time": utc_now(),
@@ -286,9 +381,13 @@ def feedback_step(stage: str) -> str:
return match.group(1) if match else ""
+def feedback_step_label(stage: str) -> str:
+ return STEP_NUMBER_LABELS.get(feedback_step(stage), "")
+
+
def request_keys(payload: Any) -> list[str]:
if isinstance(payload, dict):
- return sorted(str(key) for key in payload.keys())
+ return sorted(REQUEST_FIELD_LABELS.get(str(key), str(key)) for key in payload.keys())
return []
@@ -306,6 +405,77 @@ def endpoint_label(endpoint_key: str | None, method: str, url: str) -> str:
return f"调用接口 {method}"
+def method_label(method: str) -> str:
+ return HTTP_METHOD_LABELS.get(str(method).upper(), str(method))
+
+
+def yes_no(value: bool) -> str:
+ return "是" if value else "否"
+
+
+def stage_label(stage: Any) -> str:
+ text = str(stage or "")
+ if not text:
+ return ""
+ if text in STEP_LABELS:
+ return STEP_LABELS[text]
+ if text == "runner":
+ return "流程执行器"
+ parts = text.split("/")
+ base = parts[0]
+ match = re.search(r"(step[1-6])", base)
+ labels: list[str] = []
+ if match:
+ labels.append(STEP_LABELS.get(match.group(1), match.group(1)))
+ elif base == "runner":
+ labels.append("流程执行器")
+ else:
+ labels.append(base)
+ for segment in parts[1:]:
+ normalized = segment.replace("-", "_")
+ attempt = re.fullmatch(r"model-call-attempt-(\d+)", segment)
+ json_attempt = re.fullmatch(r"model-json-attempt-(\d+)", segment)
+ if attempt:
+ labels.append(f"模型接口第 {attempt.group(1)} 次尝试")
+ elif json_attempt:
+ labels.append(f"JSON解析第 {json_attempt.group(1)} 次尝试")
+ elif segment == "auto-login":
+ labels.append("自动登录")
+ elif segment in STAGE_SEGMENT_LABELS:
+ labels.append(STAGE_SEGMENT_LABELS[segment])
+ elif normalized in ENDPOINT_LABELS:
+ labels.append(ENDPOINT_LABELS[normalized])
+ else:
+ labels.append(segment)
+ return " / ".join(labels)
+
+
+def status_label(status: Any) -> str:
+ if status is None:
+ return ""
+ return STATUS_LABELS.get(str(status), str(status))
+
+
+def mode_label(mode: str) -> str:
+ if mode == "full":
+ return "全流程"
+ return STEP_LABELS.get(mode, mode)
+
+
+def exception_type_label(exception_type: Any) -> str:
+ name = str(exception_type or "")
+ labels = {
+ "HTTPError": "HTTP 调用异常",
+ "URLError": "网络地址异常",
+ "TimeoutError": "请求超时异常",
+ "FlowError": "流程异常",
+ "ValueError": "数据格式异常",
+ "KeyError": "字段缺失异常",
+ "RuntimeError": "运行异常",
+ }
+ return labels.get(name, "程序异常" if name else "")
+
+
def emit_operation_feedback(
state: dict[str, Any],
stage: str,
@@ -317,39 +487,41 @@ def emit_operation_feedback(
attempt: int = 1,
dry_run: bool = False,
) -> None:
+ # Feedback lines are a mini protocol consumed by the calling agent. They
+ # must stay single-line JSON so weak models/tools can relay them reliably.
payload_obj = {
- "type": "OP_FEEDBACK",
- "step": feedback_step(stage),
- "stage": stage,
- "operation": endpoint_label(endpoint_key, method, url),
- "method": method,
- "url": url,
- "endpoint": endpoint_key or "",
- "attempt": attempt,
- "dryRun": dry_run,
- "requestKeys": request_keys(payload),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "操作反馈",
+ "步骤": feedback_step_label(stage),
+ "阶段": stage_label(stage),
+ "操作": endpoint_label(endpoint_key, method, url),
+ "请求方式": method_label(method),
+ "接口地址": url,
+ "接口名称": endpoint_label(endpoint_key, method, url),
+ "第几次尝试": attempt,
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("OP_FEEDBACK " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["operation"] + " " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
def emit_error_feedback(error: dict[str, Any]) -> None:
payload = {
- "type": "ERROR_FEEDBACK",
- "stage": error.get("stage"),
- "step": feedback_step(str(error.get("stage") or "")),
- "message": error.get("message"),
- "url": error.get("url"),
- "status": error.get("status"),
- "request": error.get("request"),
- "response": error.get("response"),
- "exceptionType": error.get("exceptionType"),
- "exception": error.get("exception"),
- "time": error.get("time"),
+ "日志类型": "错误反馈",
+ "阶段": stage_label(error.get("stage")),
+ "步骤": feedback_step_label(str(error.get("stage") or "")),
+ "错误信息": error.get("message"),
+ "接口地址": error.get("url"),
+ "状态码": error.get("status"),
+ "请求摘要": error.get("request"),
+ "响应摘要": error.get("response"),
+ "异常类型": exception_type_label(error.get("exceptionType")),
+ "异常信息": error.get("exception"),
+ "时间": error.get("time"),
}
- print("ERROR_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["error"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def emit_json_parse_review(
@@ -360,18 +532,89 @@ def emit_json_parse_review(
candidate_count: int,
) -> None:
payload = {
- "type": "JSON_PARSE_REVIEW",
- "stage": stage,
- "step": feedback_step(stage),
- "endpoint": endpoint_key,
- "message": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
- "candidateCount": candidate_count,
- "responseExcerpt": compact(text, 3000),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "JSON解析复核",
+ "阶段": stage_label(stage),
+ "步骤": feedback_step_label(stage),
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, endpoint_key),
+ "提示": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
+ "候选数量": candidate_count,
+ "响应片段": compact(text, 3000),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("JSON_PARSE_REVIEW " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["json_review"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
+
+
+SAVE_ENTITY_LABELS = {
+ "save_design": "受众目标",
+ "save_design_account": "受众目标账号",
+ "save_pathway": "关键传播节点",
+ "save_relation": "受众-传播节点关系",
+ "save_design_vuln": "受众脆弱点",
+ "save_pathway_vuln": "传播节点脆弱点",
+ "save_strategy": "策略",
+ "save_channel": "策略平台与人设",
+ "save_narrate": "策略信息主题",
+ "save_message": "策略消息配置",
+ "save_strategy_relation": "策略关联关系",
+}
+
+
+def saved_entity_name(endpoint_key: str, payload: dict[str, Any]) -> str:
+ """Return the most user-readable name for an inserted row."""
+ candidates = [
+ payload.get("targetAudience"),
+ payload.get("accountName"),
+ payload.get("vulnerabilities"),
+ payload.get("topic"),
+ payload.get("narrate"),
+ payload.get("platform"),
+ payload.get("strategyId"),
+ payload.get("designId"),
+ payload.get("pathwayId"),
+ ]
+ for value in candidates:
+ if value is not None and str(value).strip():
+ return str(value).strip()
+ return endpoint_key
+
+
+def emit_save_feedback(
+ state: dict[str, Any],
+ stage: str,
+ endpoint_key: str,
+ payload: dict[str, Any],
+ row_id: int,
+ *,
+ dry_run: bool,
+) -> None:
+ # Emit only after an id is known. Downstream steps use these ids and should
+ # fail loudly rather than inventing relations against missing rows.
+ label = SAVE_ENTITY_LABELS.get(endpoint_key, endpoint_key)
+ name = saved_entity_name(endpoint_key, payload)
+ feedback = {
+ "日志类型": "入库反馈",
+ "步骤": feedback_step(stage),
+ "阶段": stage,
+ "入库对象类型": label,
+ "入库对象名称": name,
+ "返回ID": row_id,
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, label),
+ "接口路径": ENDPOINTS.get(endpoint_key, ""),
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
+ }
+ print(LOG_PREFIXES["save"] + " " + json.dumps(feedback, ensure_ascii=False), flush=True)
+ record_event(
+ state,
+ stage,
+ f"入库{label}成功:{name}",
+ {"id": row_id, "endpoint": endpoint_key, "dryRun": dry_run},
+ )
def record_error(
@@ -385,6 +628,8 @@ def record_error(
response: Any | None = None,
exception: BaseException | None = None,
) -> None:
+ # Persist full-enough error context for post-run diagnosis, then immediately
+ # emit a short line the agent can show to the user.
item = {
"time": utc_now(),
"stage": stage,
@@ -430,65 +675,25 @@ def url_for_path(ctx: FlowContext, path: str) -> str:
return ctx.api_base.rstrip("/") + "/" + path.lstrip("/")
-def parse_headers(header_values: list[str], auth_token: str | None, authorization: str | None) -> dict[str, str]:
+def parse_headers(header_values: list[str]) -> dict[str, str]:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
- if auth_token:
- headers["Authorization"] = auth_token if auth_token.lower().startswith("bearer ") else f"Bearer {auth_token}"
- if authorization:
- headers["Authorization"] = authorization
for raw in header_values:
if ":" not in raw:
raise FlowError(f"header 格式错误,应为 Key: Value,实际:{raw}")
key, value = raw.split(":", 1)
- headers[key.strip()] = value.strip()
+ normalized = key.strip()
+ if normalized.lower() == "authorization":
+ continue
+ headers[normalized] = value.strip()
return headers
-def has_authorization(headers: dict[str, str]) -> bool:
- return any(key.lower() == "authorization" and str(value).strip() for key, value in headers.items())
-
-
def ssl_context(ctx: FlowContext) -> ssl.SSLContext | None:
if ctx.verify_ssl:
return None
return ssl._create_unverified_context()
-def auth_header_value(token: str) -> str:
- token = token.strip()
- return token if token.lower().startswith("bearer ") else f"Bearer {token}"
-
-
-def extract_access_token(data: Any) -> str | None:
- if isinstance(data, dict):
- for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
- value = data.get(key)
- if isinstance(value, str) and value.strip():
- return value.strip()
- for value in data.values():
- nested = extract_access_token(value)
- if nested:
- return nested
- elif isinstance(data, list):
- for value in data:
- nested = extract_access_token(value)
- if nested:
- return nested
- return None
-
-
-def redact_url(url: str) -> str:
- return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
-
-
-def build_login_url(ctx: FlowContext, path: str) -> str:
- base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else url_for_path(ctx, path)
- if ctx.login_password:
- separator = "&" if "?" in base else "?"
- base = f"{base}{separator}password={urllib.parse.quote(ctx.login_password, safe='')}"
- return base
-
-
def login_candidate_paths(ctx: FlowContext) -> list[str]:
paths = [
ctx.login_path or "/api/v1/auth/login/username",
@@ -506,6 +711,7 @@ def login_candidate_paths(ctx: FlowContext) -> list[str]:
def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force: bool = False) -> bool:
+ """Fetch a fresh token through the local auth package before business calls."""
if not ctx.auto_login:
return False
if ctx.dry_run:
@@ -515,7 +721,7 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
record_error(state, stage, "自动登录失败:缺少 login_username")
return False
if not force and ctx.login_attempt_count > 0:
- return has_authorization(ctx.headers)
+ return any(key.lower() == "authorization" and str(value).strip() for key, value in ctx.headers.items())
if force and ctx.login_attempt_count >= 2:
return False
@@ -525,8 +731,7 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
last_response = ""
last_exc: BaseException | None = None
for attempt, path in enumerate(login_candidate_paths(ctx), start=1):
- url = build_login_url(ctx, path)
- display_url = redact_url(url)
+ display_url = xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, path, ctx.login_password))
emit_operation_feedback(
state,
stage,
@@ -537,63 +742,39 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
attempt=attempt,
)
try:
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- headers = {
- "Content-Type": "application/json",
- "Accept": "application/json",
- }
- req = urllib.request.Request(url, data=data, headers=headers, method="POST")
- with urllib.request.urlopen(req, timeout=ctx.timeout, context=ssl_context(ctx)) as resp:
- raw = resp.read().decode("utf-8", errors="replace")
- parsed = parse_json_lenient(raw)
- token = extract_access_token(parsed)
- if not token:
- last_status = getattr(resp, "status", None)
- last_response = raw
- record_event(
- state,
- stage,
- "登录接口已响应但未找到 access_token",
- {"url": display_url, "status": last_status, "response": compact(raw, 1000)},
- )
- continue
- ctx.headers["Authorization"] = auth_header_value(token)
- record_event(
- state,
- stage,
- "自动登录成功,已写入 Authorization Bearer token",
- {
- "url": display_url,
- "status": getattr(resp, "status", None),
- "username": ctx.login_username,
- },
- )
- return True
- except urllib.error.HTTPError as exc:
- last_status = exc.code
- last_response = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
- last_exc = exc
- record_event(
- state,
- stage,
- "登录接口返回错误,准备尝试下一个候选登录路径",
- {"url": display_url, "status": exc.code, "response": compact(last_response, 1000)},
+ result = xdfx_auth.fetch_access_token(
+ api_base=ctx.api_base,
+ username=ctx.login_username,
+ password=ctx.login_password,
+ login_path=path,
+ timeout=ctx.timeout,
+ verify_ssl=ctx.verify_ssl,
)
- except Exception as exc: # noqa: BLE001 - auto-login must report every failure.
- last_exc = exc
- last_response = traceback.format_exc()
+ ctx.headers["Authorization"] = xdfx_auth.auth_header_value(result.token)
+ state["_tokenSource"] = "xdfx_auth"
record_event(
state,
stage,
- "登录接口调用异常,准备尝试下一个候选登录路径",
- {"url": display_url, "exceptionType": type(exc).__name__, "exception": str(exc)},
+ "启动阶段已通过 xdfx_auth 包获取 token,并写入 Authorization",
+ {"url": result.url, "status": result.status, "username": ctx.login_username},
+ )
+ return True
+ except xdfx_auth.AuthError as exc:
+ last_status = exc.status
+ last_response = exc.response
+ last_exc = exc
+ record_event(
+ state,
+ stage,
+ "xdfx_auth 获取 token 失败,准备尝试下一个候选登录路径",
+ {"url": exc.url, "status": exc.status, "response": compact(exc.response, 1000), "exception": str(exc)},
)
record_error(
state,
stage,
- "自动登录失败,未能获取 access_token",
- url=redact_url(build_login_url(ctx, ctx.login_path or "/api/v1/auth/login/username")),
+ "自动登录失败,未能通过 xdfx_auth 获取 access_token",
+ url=xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, ctx.login_path or "/api/v1/auth/login/username", ctx.login_password)),
status=last_status,
request=payload,
response=last_response,
@@ -612,6 +793,7 @@ def http_request_json_url(
endpoint_key: str | None = None,
allow_auto_login: bool = True,
) -> tuple[Any | None, str]:
+ """Call an HTTP JSON endpoint with logging, retries, and 401 recovery."""
method = method.upper()
if ctx.dry_run:
emit_operation_feedback(state, stage, method, url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
@@ -640,6 +822,7 @@ def http_request_json_url(
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
if exc.code == 401 and allow_auto_login and ctx.auto_login:
+ # Refresh once on 401, then replay the original request exactly.
refreshed = try_auto_login(ctx, state, f"{stage}/auto-login", force=True)
if refreshed:
record_event(
@@ -709,6 +892,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
emit_operation_feedback(state, stage, "POST", url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
record_event(state, stage, f"DRY RUN POST {url}", {"payload": payload})
if endpoint_key.startswith("save_"):
+ # Synthetic ids let downstream dry-run code exercise relation wiring.
synthetic_id = abs(hash(stage + stable_json(payload))) % 100000000
return {"id": synthetic_id, "dryRun": True}, json.dumps({"id": synthetic_id, "dryRun": True})
return {"dryRun": True}, json.dumps({"dryRun": True})
@@ -716,6 +900,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
def parse_json_lenient(text: str) -> Any | None:
+ """Parse normal JSON plus adjacent/SSE-like JSON objects."""
stripped = text.strip()
if not stripped:
return None
@@ -730,6 +915,7 @@ def parse_json_lenient(text: str) -> Any | None:
def parse_concatenated_json_objects(text: str) -> list[Any]:
+ """Read streams such as {"answer": "..."}{"answer": "..."} or data: lines."""
decoder = json.JSONDecoder()
objects: list[Any] = []
i = 0
@@ -766,6 +952,7 @@ def parse_concatenated_json_objects(text: str) -> list[Any]:
def model_text_from_response(parsed: Any, raw: str) -> tuple[str, list[Any]]:
+ """Flatten streamed model chunks into one text buffer."""
objects: list[Any]
if isinstance(parsed, list):
objects = parsed
@@ -824,6 +1011,7 @@ def extract_json_from_text(text: str) -> Any | None:
def extract_json_candidates_from_text(text: str) -> list[Any]:
+ """Collect every plausible JSON object/array from messy model text."""
cleaned = strip_think(text)
candidates: list[Any] = []
seen: set[str] = set()
@@ -860,6 +1048,7 @@ def extract_json_candidates_from_text(text: str) -> list[Any]:
def select_json_candidate(candidates: list[Any], endpoint_key: str, stage: str) -> Any | None:
+ """Pick the candidate whose keys best match the current step."""
if not candidates:
return None
scored = [(json_candidate_score(candidate, endpoint_key, stage), idx, candidate) for idx, candidate in enumerate(candidates)]
@@ -1010,6 +1199,7 @@ def extract_first_balanced(text: str, open_ch: str, close_ch: str) -> str | None
def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[str, list[Any], Any | None]:
+ """Call a model endpoint and retry only known empty/timeout/no-response cases."""
max_attempts = max(ctx.model_retries, 0) + 1
last_text = ""
last_objects: list[Any] = []
@@ -1048,6 +1238,11 @@ def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str,
def call_model_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[dict[str, Any], str, list[Any]]:
+ """Return the best structured JSON for a model step.
+
+ If the first parse fails, the second attempt includes an explicit repair
+ instruction so weak models can re-emit only the required JSON.
+ """
last_text = ""
last_objects: list[Any] = []
last_candidate_count = 0
@@ -1202,23 +1397,85 @@ def profile_info(account: dict[str, Any]) -> str:
def parse_index_list(value: Any) -> list[str]:
- if value is None:
- return []
- if isinstance(value, list):
- return [str(v).strip() for v in value if str(v).strip()]
- text = str(value)
- return [part.strip().strip("<>").strip() for part in re.split(r"[,,;;、\s]+", text) if part.strip()]
+ refs: list[str] = []
+
+ def add(raw: Any) -> None:
+ if raw is None:
+ return
+ if isinstance(raw, dict):
+ keys = (
+ "编号id",
+ "编号ID",
+ "用户编号id",
+ "用户编号ID",
+ "用户编号",
+ "账号编号",
+ "引用序号",
+ "索引",
+ "index",
+ "id",
+ "ids",
+ "accountIndex",
+ "accountIndexes",
+ "userIndex",
+ "userIndexes",
+ )
+ for key in keys:
+ if key in raw:
+ add(raw.get(key))
+ return
+ if isinstance(raw, (list, tuple, set)):
+ for item in raw:
+ add(item)
+ return
+ text = str(raw).strip()
+ if not text:
+ return
+ angle_refs = re.findall(r"<<\s*([^<>]+?)\s*>>", text)
+ if angle_refs:
+ for token in angle_refs:
+ add(token)
+ return
+ for part in re.split(r"[,,;;、\s]+", text):
+ token = part.strip().strip("<>").strip()
+ if token:
+ refs.append(token)
+
+ add(value)
+ deduped: list[str] = []
+ seen: set[str] = set()
+ for ref in refs:
+ if ref in seen:
+ continue
+ seen.add(ref)
+ deduped.append(ref)
+ return deduped
+
+
+def account_ref_numbers(account: dict[str, Any]) -> set[int]:
+ numbers: set[int] = set()
+ meta = account.get("metadata") if isinstance(account.get("metadata"), dict) else {}
+ for key in ("编号id", "编号ID", "用户编号id", "用户编号ID", "用户编号", "账号编号", "引用序号", "索引", "index", "accountIndex"):
+ for container in (account, meta):
+ number = as_int(container.get(key)) if isinstance(container, dict) else None
+ if number is not None:
+ numbers.add(number)
+ return numbers
def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, Any] | None:
+ """Resolve model references like <<3>>, index numbers, names, or account ids."""
if not accounts:
return None
text = str(ref).strip().strip("<>").strip()
number = as_int(text)
if number is not None:
for account in accounts:
- if any(as_int(account.get(key)) == number for key in ("引用序号", "索引", "index")):
+ if number in account_ref_numbers(account):
return account
+ # Step1 group extraction returns 编号/id values that refer to the
+ # previous zhanghao array index. Prefer exact index fields above, then
+ # fall back to zero-based and one-based array positions.
if 0 <= number < len(accounts):
return accounts[number]
if 1 <= number <= len(accounts):
@@ -1232,11 +1489,25 @@ def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, A
def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Normalize audience JSON whether the model used numbered fields or items[]."""
items: list[dict[str, Any]] = []
for i in range(1, 21):
group = first_present(data.get(f"群体{i}"), data.get(f"受众{i}"), data.get(f"targetAudience{i}"))
reason = first_present(data.get(f"理由{i}"), data.get(f"推荐理由{i}"), data.get(f"recReason{i}"))
- users = first_present(data.get(f"用户编号列表{i}"), data.get(f"用户列表{i}"), data.get(f"账号编号列表{i}"))
+ users = first_present(
+ data.get(f"用户编号列表{i}"),
+ data.get(f"编号id列表{i}"),
+ data.get(f"编号ID列表{i}"),
+ data.get(f"用户编号id列表{i}"),
+ data.get(f"用户编号ID列表{i}"),
+ data.get(f"用户列表{i}"),
+ data.get(f"账号编号列表{i}"),
+ data.get(f"索引列表{i}"),
+ data.get(f"编号{i}"),
+ data.get(f"id{i}"),
+ data.get(f"ids{i}"),
+ data.get(f"索引{i}"),
+ )
if group:
items.append(
{
@@ -1255,7 +1526,27 @@ def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
{
"targetAudience": str(group).strip(),
"recReason": str(first_present(item.get("理由"), item.get("recReason"), item.get("reason")) or "").strip(),
- "userIndexes": parse_index_list(first_present(item.get("用户编号列表"), item.get("users"), item.get("accounts"))),
+ "userIndexes": parse_index_list(
+ first_present(
+ item.get("用户编号列表"),
+ item.get("编号id列表"),
+ item.get("编号ID列表"),
+ item.get("用户编号id列表"),
+ item.get("用户编号ID列表"),
+ item.get("编号id"),
+ item.get("编号ID"),
+ item.get("用户编号id"),
+ item.get("用户编号ID"),
+ item.get("编号"),
+ item.get("索引"),
+ item.get("id"),
+ item.get("ids"),
+ item.get("users"),
+ item.get("accounts"),
+ item.get("userIndexes"),
+ item.get("accountIndexes"),
+ )
+ ),
"rank": i,
}
)
@@ -1272,6 +1563,7 @@ def audience_score(item: dict[str, Any]) -> tuple[int, int]:
def select_top_audiences(items: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
+ """Deduplicate audience names and keep the strongest candidates."""
unique: dict[str, dict[str, Any]] = {}
for item in items:
key = item.get("targetAudience", "")
@@ -1301,6 +1593,7 @@ def pathway_score(item: dict[str, Any]) -> tuple[int, int, int]:
def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Merge the same account found from different audiences before saving top 3."""
merged: dict[str, dict[str, Any]] = {}
for item in candidates:
account = item.get("account") or {}
@@ -1325,6 +1618,7 @@ def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str,
def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
+ """Normalize weak-point model output into a capped list."""
vulns: list[dict[str, str]] = []
for i in range(1, 21):
vuln = first_present(data.get(f"脆弱点{i}"), data.get(f"vulnerability{i}"), data.get(f"vuln{i}"))
@@ -1339,6 +1633,7 @@ def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
def extract_strategies(data: dict[str, Any], limit: int) -> list[dict[str, Any]]:
+ """Normalize strategy output and preserve the modal text for later mapping."""
strategies: list[dict[str, Any]] = []
for i in range(1, 21):
title = first_present(data.get(f"信息标题{i}"), data.get(f"主题{i}"), data.get(f"topic{i}"))
@@ -1395,11 +1690,29 @@ def response_id(response: Any) -> int | None:
return None
+def response_rows(response: Any) -> list[Any]:
+ """Extract list rows from common CRUD response shapes."""
+ if isinstance(response, list):
+ return response
+ if not isinstance(response, dict):
+ return []
+ for key in ("data", "rows", "items", "records", "list"):
+ value = response.get(key)
+ if isinstance(value, list):
+ return value
+ rows = response_rows(value)
+ if rows:
+ return rows
+ return []
+
+
def save_with_id(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[int | None, Any | None]:
response, _ = http_post_json(ctx, state, endpoint_key, payload, stage)
rid = response_id(response)
if rid is None:
- record_error(state, stage, "保存接口未返回 id,视为失败", request=payload, response=response)
+ record_error(state, stage, "保存接口未返回 ID,视为失败", request=payload, response=response)
+ else:
+ emit_save_feedback(state, stage, endpoint_key, payload, rid, dry_run=ctx.dry_run)
return rid, response
@@ -1429,6 +1742,7 @@ def parse_step_flags(raw: Any) -> dict[str, Any]:
def mark_step_status(ctx: FlowContext, state: dict[str, Any], links: list[str], stage: str) -> None:
+ """Update the frontend step bar without overwriting unrelated step flags."""
if not ctx.sync_step_status:
record_event(state, stage, "跳过 task-action.step 状态同步")
return
@@ -1477,12 +1791,126 @@ def strategy_platform(value: Any) -> Any:
return platform_to_number(value) or 1
-def strategy_model_type(value: Any) -> str:
- # Frontend defaults to text model type "1" when no modal type is selected.
- raw = first_present(value, "1")
- if isinstance(raw, list):
- return ",".join(str(v) for v in raw if str(v).strip()) or "1"
- return str(raw or "1")
+def choice_tokens(value: Any) -> list[Any]:
+ """Split frontend choice values from arrays, JSON strings, and mixed delimiters."""
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return choice_tokens(
+ first_present(value.get("value"), value.get("dictValue"), value.get("label"), value.get("dictLabel"))
+ )
+ if isinstance(value, (list, tuple, set)):
+ tokens: list[Any] = []
+ for item in value:
+ tokens.extend(choice_tokens(item))
+ return tokens
+ text = str(value).strip()
+ if not text:
+ return []
+ if text[0] in "[{":
+ try:
+ parsed = json.loads(text)
+ if parsed != value:
+ return choice_tokens(parsed)
+ except Exception:
+ pass
+ return [part.strip() for part in re.split(r"[,,、;/|]+", text) if part.strip()]
+
+
+def dict_choices(
+ ctx: FlowContext,
+ state: dict[str, Any],
+ dict_type: str,
+ stage: str,
+) -> list[dict[str, Any]]:
+ """Fetch and cache frontend dictionary choices such as modelType."""
+ cache = state.setdefault("_dictChoiceCache", {})
+ if dict_type in cache:
+ cached = cache[dict_type]
+ return cached if isinstance(cached, list) else []
+ if ctx.dry_run:
+ cache[dict_type] = []
+ return []
+
+ query = urllib.parse.urlencode({"filter": f"dictType||$eq||{dict_type}", "limit": 999, "page": 1})
+ url = f"{url_for(ctx, 'dict_data')}?{query}"
+ response, _ = http_request_json_url(ctx, state, "GET", url, None, stage, endpoint_key="dict_data")
+ choices: list[dict[str, Any]] = []
+ for row in response_rows(response):
+ if not isinstance(row, dict):
+ continue
+ label = str(first_present(row.get("dictLabel"), row.get("label"), "") or "").strip()
+ raw_value = first_present(row.get("dictValue"), row.get("value"))
+ if not label or raw_value is None:
+ continue
+ number = as_int(raw_value)
+ choices.append({"label": label, "value": number if number is not None else str(raw_value).strip()})
+ for alias in choice_tokens(row.get("alias")):
+ alias_label = str(alias).strip()
+ if alias_label:
+ choices.append({"label": alias_label, "value": number if number is not None else str(raw_value).strip()})
+ cache[dict_type] = choices
+ return choices
+
+
+def choice_value_by_label(choices: list[dict[str, Any]] | None, label: str) -> Any | None:
+ needle = re.sub(r"\s+", "", str(label or "").lower())
+ if not needle:
+ return None
+ for item in choices or []:
+ item_label = re.sub(r"\s+", "", str(item.get("label") or "").lower())
+ if item_label and (needle == item_label or needle in item_label or item_label in needle):
+ return item.get("value")
+ return None
+
+
+def model_type_token_value(token: Any, choices: list[dict[str, Any]] | None = None) -> Any:
+ """Convert modal labels like 图片/视频 into frontend dictionary values."""
+ number = as_int(token)
+ if number is not None:
+ return number
+
+ text = str(token or "").strip()
+ lower = text.lower()
+ exact = choice_value_by_label(choices, text)
+ if exact is not None:
+ return exact
+
+ label = ""
+ fallback_value = 2
+ if "视" in text or "video" in lower:
+ label, fallback_value = "视频", 3
+ elif "图" in text or "image" in lower or "picture" in lower or "photo" in lower:
+ label, fallback_value = "图片", 2
+ elif "音" in text or "audio" in lower or "sound" in lower:
+ label, fallback_value = "音频", 4
+ elif "发文" in text or "文本" in text or "文字" in text or "text" in lower:
+ label, fallback_value = "文本", 1
+
+ if label:
+ matched = choice_value_by_label(choices, label)
+ if matched is not None:
+ return matched
+ return fallback_value
+
+ # Mirrors the frontend helper's unknown-token default.
+ return 2
+
+
+def strategy_model_type(value: Any, choices: list[dict[str, Any]] | None = None) -> str:
+ """Return the comma-separated value string expected by task-action-message."""
+ tokens = choice_tokens(value)
+ if not tokens:
+ # Frontend save paths fall back to "1" when no modal type is selected.
+ return "1"
+ values: list[str] = []
+ seen: set[str] = set()
+ for token in tokens:
+ normalized = str(model_type_token_value(token, choices)).strip()
+ if normalized and normalized not in seen:
+ values.append(normalized)
+ seen.add(normalized)
+ return ",".join(values) or "1"
def strategy_language(value: Any) -> int:
@@ -1501,11 +1929,28 @@ def save_strategy_details(
*,
audience: bool,
) -> None:
- platform = strategy_platform(first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})))
+ """Save all frontend-readable strategy child rows.
+
+ The strategy table and arrangement page do not read only the main strategy
+ row; they also expect channel, narrate, message, and relation rows.
+ """
+ platform = strategy_platform(
+ first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
+ )
+ account_type = str(
+ first_present(
+ strategy.get("accountType"),
+ item.get("accountType"),
+ item.get("type"),
+ "目标受众账号" if audience else "关键传播节点账号",
+ )
+ or ""
+ ).strip()
persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
title = str(strategy.get("title") or "").strip()
+ model_type_choices = dict_choices(ctx, state, "modelType", stage + "/dict-modelType")
channel_id, _ = save_with_id(
ctx,
@@ -1516,6 +1961,7 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"platform": platform,
+ "accountType": account_type,
"personaJson": json.dumps([{"persona": persona, "size": persona_size}], ensure_ascii=False),
},
stage + "/save-channel",
@@ -1531,7 +1977,10 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"channelId": channel_id,
- "modelType": strategy_model_type(first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType"))),
+ "modelType": strategy_model_type(
+ first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType")),
+ model_type_choices,
+ ),
"language": strategy_language(first_present(strategy.get("language"), strategy.get("语种"))),
"respondingCount": responding_count,
"messageCount": responding_count,
@@ -1564,7 +2013,39 @@ def object_for_audience(audience: dict[str, Any]) -> dict[str, Any]:
}
+def downstream_limit(ctx: FlowContext, *, audience: bool) -> int:
+ """Limit expensive downstream analysis while keeping 3 upstream rows stored."""
+ raw = ctx.downstream_audiences if audience else ctx.downstream_pathways
+ return max(1, raw)
+
+
+def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, audience: bool) -> list[dict[str, Any]]:
+ """Select the 1-2 best stored rows for vulnerability/strategy generation."""
+ source_key = "topAudiences" if audience else "topPathways"
+ id_key = "designId" if audience else "pathwayAccountId"
+ selected_key = "selectedDownstreamAudiences" if audience else "selectedDownstreamPathways"
+ items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)]
+ limit = downstream_limit(ctx, audience=audience)
+ selected = items[:limit]
+ state[selected_key] = [
+ {
+ "id": item.get(id_key),
+ "name": item.get("targetAudience") if audience else item.get("accountName"),
+ }
+ for item in selected
+ ]
+ if items:
+ record_event(
+ state,
+ stage,
+ f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}",
+ {"limit": limit, "selected": state[selected_key]},
+ )
+ return selected
+
+
def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze audiences, save top 3, and attach any referenced accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step1-audience"
if state.get("topAudiences") and not ctx.dry_run:
@@ -1582,12 +2063,15 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
state["audienceRawText"] = text
+ # The first model call may return raw text plus embedded account objects.
accounts = accounts_from_response(parsed, objects, text)
state["audienceAccounts"] = accounts
+ # The second model call converts the long audience analysis into numbered
+ # groups that can be saved as task-action-design rows.
group_payload = {
"mubiao": state["mubiao"],
- "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
"zhanghao": accounts,
"history": [],
"stream": True,
@@ -1602,6 +2086,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
top = select_top_audiences(candidates, ctx.max_audiences)
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the audience first; account rows and downstream analyses depend
+ # on the returned designId.
design_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
@@ -1614,6 +2100,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
continue
item = {**item, "designId": design_id, "saveResponse": resp, "accounts": [], "savedAccountIds": []}
for ref in item.get("userIndexes") or []:
+ # Model references are intentionally fuzzy, so resolve them against
+ # both explicit indexes and account names/ids.
account = resolve_account_ref(ref, accounts)
if account is None:
record_error(state, stage + "/save-design-account", f"用户编号 {ref} 未匹配到候选账号")
@@ -1645,6 +2133,7 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze key pathway accounts for saved audiences and store top 3."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step2-pathway"
audiences = [a for a in state.get("topAudiences") or [] if a.get("designId")]
@@ -1656,6 +2145,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
return
all_candidates: list[dict[str, Any]] = []
for audience in audiences:
+ # Analyze pathway candidates in the context of each saved audience.
payload = {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。",
"otherinfo": {
@@ -1673,6 +2163,8 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
accounts = accounts_from_response(parsed, objects, text)
+ # Ask the model to point back to candidate accounts, then resolve those
+ # references to concrete account objects.
spread_payload = {
"mubiao": state["mubiao"],
"user_message": "分析其中关键信息。请从候选账号中抽取核心传播节点,字段必须是:账号1、理由1、账号2、理由2 ...,最多输出5个。账号字段优先返回候选数组的引用编号或索引,只返回JSON。",
@@ -1707,6 +2199,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways]
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the account row before writing the design-pathway relation.
account = item["account"]
aid = account_id(account)
name = account_name(account)
@@ -1757,12 +2250,14 @@ def run_step4(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate and save weak points for selected audiences or pathway accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step3-audience-vulns" if audience else "step4-pathway-vulns"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
output_key = "audienceVulns" if audience else "pathwayVulns"
state.setdefault(output_key, {})
for item in items:
+ # owner_id is designId for audiences and pathwayAccountId for accounts.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
if not owner_id:
continue
@@ -1789,6 +2284,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) ->
vulns = extract_vulns(data, ctx.max_vulns)
saved: list[dict[str, Any]] = []
for vuln in vulns:
+ # The two weak-point tables have nearly identical payloads but
+ # different foreign-key names.
if audience:
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
@@ -1835,14 +2332,16 @@ def run_step6(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate strategies from saved weak points and persist frontend child rows."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step5-audience-strategy" if audience else "step6-pathway-strategy"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {}
output_key = "audienceStrategies" if audience else "pathwayStrategies"
state.setdefault(output_key, [])
existing_topics = {s.get("topic") for s in state[output_key] if isinstance(s, dict)}
for item in items:
+ # Strategies are only meaningful after the matching weak points exist.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
vulns = vulns_by_owner.get(str(owner_id)) or []
vuln_texts = [v.get("vulnerabilities") for v in vulns if v.get("vulnerabilities")]
@@ -1876,16 +2375,20 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
continue
if topic in existing_topics:
continue
+ # Save the main row first; channel/narrate/message/relation rows all
+ # require the returned strategy id.
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
"targetAudience": target,
"accountType": account_type,
"topic": topic,
- "strategyTitle": "",
+ "strategyTitle": str(strategy.get("title") or "").strip(),
}
strategy_id, resp = save_with_id(ctx, state, "save_strategy", save_payload, stage + "/save")
if strategy_id is not None:
+ # Frontend strategy pages read these child tables, so a strategy
+ # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience)
existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp})
@@ -1914,13 +2417,14 @@ STEP_LABELS = {
def state_counts(state: dict[str, Any]) -> dict[str, int]:
+ """Summarize stored objects for step feedback."""
return {
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceVulns": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
- "pathwayVulns": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "受众脆弱点数量": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
+ "传播节点脆弱点数量": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
}
@@ -1932,23 +2436,26 @@ def emit_step_feedback(
status: str | None = None,
note: str = "",
) -> None:
+ # One feedback line per step keeps the single-skill and split-skill versions
+ # equally transparent to the user.
total_errors = len(state.get("errors") or [])
new_errors = max(0, total_errors - before_error_count)
final_status = status or ("partial" if new_errors else "ok")
payload = {
- "type": "STEP_FEEDBACK",
- "step": step,
- "label": STEP_LABELS.get(step, step),
- "status": final_status,
- "note": note,
- "newErrors": new_errors,
- "totalErrors": total_errors,
- "counts": state_counts(state),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "updatedAt": state.get("updatedAt"),
+ "日志类型": "步骤反馈",
+ "步骤": STEP_NUMBER_LABELS.get(step, step),
+ "步骤名称": STEP_LABELS.get(step, step),
+ "状态": STATUS_LABELS.get(final_status, final_status),
+ "执行状态": status_label(final_status),
+ "备注": note,
+ "本步骤新增错误数": new_errors,
+ "累计错误数": total_errors,
+ "累计入库数量": state_counts(state),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "更新时间": state.get("updatedAt"),
}
- print("STEP_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["step"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def finish_step(
@@ -1960,6 +2467,8 @@ def finish_step(
status: str | None = None,
note: str = "",
) -> None:
+ # Always persist before emitting step feedback so the user can inspect files
+ # immediately after a reported step completes.
state["updatedAt"] = utc_now()
save_json_file(ctx.state_path, state)
if ctx.summary_path:
@@ -1980,6 +2489,11 @@ def skip_step_with_feedback(ctx: FlowContext, state: dict[str, Any], step: str,
def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Legacy full runner.
+
+ The wrapper script is preferred for real-time logs, but this remains useful
+ for dry-runs and unattended local troubleshooting.
+ """
run_step_with_feedback(ctx, state, "step1")
run_step_with_feedback(ctx, state, "step2")
run_step_with_feedback(ctx, state, "step3")
@@ -1995,6 +2509,7 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
def write_summary(path: Path, state: dict[str, Any]) -> None:
+ """Write a human-readable checkpoint beside the machine state JSON."""
audience_vuln_count = sum(len(v) for v in (state.get("audienceVulns") or {}).values())
pathway_vuln_count = sum(len(v) for v in (state.get("pathwayVulns") or {}).values())
lines = [
@@ -2005,6 +2520,8 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
f"- actionId:{state.get('actionId')}",
f"- 受众入库:{len(state.get('topAudiences') or [])}",
f"- 传播节点入库:{len(state.get('topPathways') or [])}",
+ f"- 下游受众选择:{len(state.get('selectedDownstreamAudiences') or [])}",
+ f"- 下游传播节点选择:{len(state.get('selectedDownstreamPathways') or [])}",
f"- 受众脆弱点入库:{audience_vuln_count}",
f"- 传播节点脆弱点入库:{pathway_vuln_count}",
f"- 受众策略入库:{len(state.get('audienceStrategies') or [])}",
@@ -2021,9 +2538,9 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
if state.get("errors"):
lines += ["", "## 失败明细"]
for err in state["errors"]:
- lines.append(f"- [{err.get('stage')}] {err.get('message')} status={err.get('status')} url={err.get('url')}")
+ lines.append(f"- [{stage_label(err.get('stage'))}] {err.get('message')} 状态码={err.get('status')} 接口={err.get('url')}")
if err.get("response"):
- lines.append(f" - response: {err.get('response')}")
+ lines.append(f" - 响应摘要:{err.get('response')}")
text = "\n".join(lines) + "\n"
try:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -2034,34 +2551,34 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
def build_arg_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(description="Run xdfx analysis/storage flow.")
+ parser = argparse.ArgumentParser(description="运行 xdfx 分析与入库流程。")
parser.add_argument("--mode", choices=["full", *STEP_FUNCS.keys()], default="full")
- parser.add_argument("--input", help="JSON config file. Keys: api_base, taskId, actionId, mubiao, direction, platform, headers.")
- parser.add_argument("--state", default="xdfx-flow-state.json", help="State JSON path for resume and step handoff.")
- parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown summary output path.")
+ parser.add_argument("--input", help="JSON 配置文件。常用字段:api_base、taskId、actionId、mubiao、direction、platform、headers。")
+ parser.add_argument("--state", default="xdfx-flow-state.json", help="用于断点续跑和步骤交接的状态 JSON 文件路径。")
+ parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown 执行摘要输出路径。")
parser.add_argument("--api-base")
parser.add_argument("--task-id")
parser.add_argument("--action-id")
parser.add_argument("--mubiao")
parser.add_argument("--direction", default=None)
parser.add_argument("--platform", default=None)
- parser.add_argument("--auth-token", default=os.getenv("XDFX_AUTH_TOKEN"))
- parser.add_argument("--authorization", default=os.getenv("AUTHORIZATION"))
- parser.add_argument("--header", action="append", default=[], help="Extra HTTP header, format 'Key: Value'.")
- parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="Username for automatic login when Authorization is missing or stale.")
- parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="Password query parameter for automatic username login.")
- parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="Login endpoint path or absolute URL.")
- parser.add_argument("--no-auto-login", action="store_true", help="Disable automatic username login and 401 token refresh.")
- parser.add_argument("--verify-ssl", action="store_true", help="Verify HTTPS certificates. Default is disabled for internal/UAT certificates.")
+ parser.add_argument("--header", action="append", default=[], help="附加非鉴权 HTTP 请求头,格式为 'Key: Value';Authorization 会被忽略。")
+ parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="xdfx_auth 启动阶段自动登录使用的用户名。")
+ parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="用户名登录接口的 password 查询参数。")
+ parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="登录接口路径或完整 URL。")
+ parser.add_argument("--no-auto-login", action="store_true", help="关闭 xdfx_auth 自动登录和 401 刷新。")
+ parser.add_argument("--verify-ssl", action="store_true", help="校验 HTTPS 证书。默认不校验,以兼容内网/UAT 自签证书。")
parser.add_argument("--timeout", type=float, default=90)
parser.add_argument("--retries", type=int, default=2)
parser.add_argument("--dry-run", action="store_true")
- parser.add_argument("--no-step-status", action="store_true", help="Do not update task/task-action.step after completed steps.")
- parser.add_argument("--model-retries", type=int, default=1, help="Retry model/stream endpoints when the first response is empty, timeout-like, or says the model did not respond.")
+ parser.add_argument("--no-step-status", action="store_true", help="步骤完成后不更新 task/task-action.step。")
+ parser.add_argument("--model-retries", type=int, default=1, help="模型/流式接口首次空响应、超时或提示模型无响应时的重试次数。")
parser.add_argument("--max-audiences", type=int, default=3)
parser.add_argument("--max-pathways", type=int, default=3)
parser.add_argument("--max-vulns", type=int, default=3)
parser.add_argument("--max-strategies", type=int, default=3)
+ parser.add_argument("--downstream-audiences", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库受众数量,默认 2。")
+ parser.add_argument("--downstream-pathways", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库传播节点数量,默认 2。")
return parser
@@ -2070,6 +2587,8 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
state_path = Path(args.state)
state = default_state()
+ # Load previous state first, then overlay input JSON and finally CLI flags.
+ # That ordering makes resume safe while still allowing explicit overrides.
if state_path.exists():
loaded = load_json_file(state_path)
if isinstance(loaded, dict):
@@ -2081,9 +2600,7 @@ def main(argv: list[str] | None = None) -> int:
merge_config_into_state(state, config)
input_headers = config.get("headers")
if isinstance(input_headers, dict):
- args.header.extend(f"{k}: {v}" for k, v in input_headers.items())
- args.auth_token = args.auth_token or config.get("auth_token")
- args.authorization = args.authorization or config.get("authorization")
+ args.header.extend(f"{k}: {v}" for k, v in input_headers.items() if str(k).lower() != "authorization")
if config.get("login_username") is not None:
args.login_username = str(config.get("login_username") or "")
if config.get("login_password") is not None:
@@ -2094,6 +2611,10 @@ def main(argv: list[str] | None = None) -> int:
args.no_auto_login = True
if config.get("verify_ssl") is not None:
args.verify_ssl = bool(config.get("verify_ssl"))
+ if config.get("downstream_audiences") is not None:
+ args.downstream_audiences = as_int(config.get("downstream_audiences")) or args.downstream_audiences
+ if config.get("downstream_pathways") is not None:
+ args.downstream_pathways = as_int(config.get("downstream_pathways")) or args.downstream_pathways
cli_config = {
"api_base": args.api_base,
@@ -2105,7 +2626,8 @@ def main(argv: list[str] | None = None) -> int:
}
merge_config_into_state(state, {k: v for k, v in cli_config.items() if v is not None})
api_base = state.get("api_base") or "https://ch1.b.uat.4.cn"
- headers = parse_headers(args.header, args.auth_token, args.authorization)
+ headers = parse_headers(args.header)
+ # ctx owns runtime knobs; state owns business data and resumable ids.
ctx = FlowContext(
api_base=api_base,
headers=headers,
@@ -2118,6 +2640,8 @@ def main(argv: list[str] | None = None) -> int:
max_pathways=max(args.max_pathways, 1),
max_vulns=max(args.max_vulns, 1),
max_strategies=max(args.max_strategies, 1),
+ downstream_audiences=max(args.downstream_audiences, 1),
+ downstream_pathways=max(args.downstream_pathways, 1),
sync_step_status=not args.no_step_status,
model_retries=max(args.model_retries, 0),
verify_ssl=bool(args.verify_ssl),
@@ -2131,11 +2655,17 @@ def main(argv: list[str] | None = None) -> int:
state["_summaryPath"] = str(ctx.summary_path) if ctx.summary_path else ""
state["_autoLogin"] = ctx.auto_login
state["_verifySsl"] = ctx.verify_ssl
+ state["_downstreamLimits"] = {
+ "audiences": ctx.downstream_audiences,
+ "pathways": ctx.downstream_pathways,
+ }
record_event(state, "runner", f"start mode={args.mode} dry_run={ctx.dry_run}")
try:
require_context(state, "taskId", "actionId", "mubiao")
- if ctx.auto_login and not has_authorization(ctx.headers):
- try_auto_login(ctx, state, "runner/auto-login")
+ if ctx.auto_login and not ctx.dry_run:
+ # Always acquire a token through the imported auth package before the first business call.
+ if not try_auto_login(ctx, state, "runner/initial-login", force=True):
+ raise FlowError("启动阶段获取 token 失败,已停止执行,避免无 token 调用业务接口")
if args.mode == "full":
run_full(ctx, state)
else:
@@ -2145,13 +2675,14 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(f"ERROR: {exc}", file=sys.stderr)
+ print(f"错误:{exc}", file=sys.stderr)
return 2
except Exception as exc: # noqa: BLE001
record_error(state, "runner", "未捕获异常", response=traceback.format_exc(), exception=exc)
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
+ print("未捕获异常堆栈:", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
return 1
@@ -2159,20 +2690,23 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(json.dumps(
- {
- "ok": True,
- "mode": args.mode,
- "state": str(state_path),
- "summary": str(ctx.summary_path) if ctx.summary_path else "",
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
- "errors": len(state.get("errors") or []),
- },
- ensure_ascii=False,
- ))
+ # Final JSON is intentionally compact; detailed per-step status has already
+ # been emitted as Chinese step feedback.
+ final_payload = {
+ "日志类型": "执行完成",
+ "是否成功": "是",
+ "执行模式": mode_label(args.mode),
+ "状态文件": str(state_path),
+ "摘要文件": str(ctx.summary_path) if ctx.summary_path else "",
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "已选择受众数": len(state.get("selectedDownstreamAudiences") or []),
+ "已选择传播节点数": len(state.get("selectedDownstreamPathways") or []),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
+ "错误数": len(state.get("errors") or []),
+ }
+ print(LOG_PREFIXES["done"] + " " + json.dumps(final_payload, ensure_ascii=False))
return 0
diff --git a/skills-data/xdfx-step6-pathway-strategy/SKILL.md b/skills-data/xdfx-step6-pathway-strategy/SKILL.md
index a185e631..54492394 100644
--- a/skills-data/xdfx-step6-pathway-strategy/SKILL.md
+++ b/skills-data/xdfx-step6-pathway-strategy/SKILL.md
@@ -13,27 +13,39 @@ description: xdfx 拆分流程 step6,基于关键传播节点与节点脆弱
python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
-鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
+鉴权和证书由脚本兜底处理:脚本启动时先 `import scripts/xdfx_auth.py`,自动用 `admin / ch@user` 获取 `access_token`,并在第一次业务接口调用前写入内部 Authorization;不要通过命令行传 token。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
-- 按关键传播节点读取已入库脆弱点。
+- 从已入库关键传播节点中默认选择前 2 个继续生成策略;可用 `--downstream-pathways 1` 调成只处理 1 个。
+- 对每个被选中的关键传播节点读取已入库脆弱点并默认生成最多 3 个策略。
- 调用策略生成模型接口。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 自动解析 `发布人设N / 账号类型N / 信息标题N` 等字段。
- 调用策略保存接口。
- 同步写入 `task-action-channel`、`base-narrate`、`task-action-message`、`task-action-design-strategy-relation`。
+- 保存 `task-action-message.modelType` 前会读取前端 `modelType` 字典并把“图片,视频/文本,图片”等模态文字转成 value 逗号串,避免策略表格回显为空。
- 同步前端步骤状态 `step-analysis-strategy` 与 `step-analysis-strategy-gantt`。
- 保存成功后写入 `pathwayStrategies`。
-如果某个传播节点没有可用脆弱点,脚本会跳过并在 `errors` 中记录。
+如果某个传播节点没有可用脆弱点,脚本会记录错误;智能体必须先回到 Step4 或重跑 Step6 修复当前策略环节,不能直接跳过。
+
+## 失败重试规则
+
+- 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前 Step6 命令。
+- 如果失败原因是缺少传播节点脆弱点,先重跑 Step4 生成脆弱点,再重跑 Step6;重试前必须告诉用户原因和重试动作。
+- Step6 未重试成功前,不允许做最终成功汇总;不要因为 Step6 失败就跳过传播节点策略分析。
+- Step6 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
## 步骤反馈
-命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step6 的完成状态、当前传播节点策略数量和新增错误数,再做最终汇总。
+命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step6 的完成状态、当前传播节点策略数量和新增错误数,再做最终汇总。
-执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
+执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。
-如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的策略、策略子表和返回 ID。
+
+如果出现 `错误反馈 {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+
+如果出现 `JSON解析复核 {...}`,必须查看 `响应片段`,判断是否能识别传播节点策略 JSON;无法确认时重跑 Step6。
-如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别传播节点策略 JSON;无法确认时重跑 Step6。
diff --git a/skills-data/xdfx-step6-pathway-strategy/scripts/xdfx_auth.py b/skills-data/xdfx-step6-pathway-strategy/scripts/xdfx_auth.py
new file mode 100644
index 00000000..fe46f809
--- /dev/null
+++ b/skills-data/xdfx-step6-pathway-strategy/scripts/xdfx_auth.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""Authentication helper for xdfx skill scripts.
+
+The caller should not pass tokens through command-line arguments. This module
+owns the fixed admin login flow and returns a Bearer token before business
+interfaces are called.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import ssl
+import urllib.error
+import urllib.parse
+import urllib.request
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass
+class AuthResult:
+ token: str
+ url: str
+ status: int | None
+ raw: str
+
+
+class AuthError(RuntimeError):
+ def __init__(
+ self,
+ message: str,
+ *,
+ url: str,
+ status: int | None = None,
+ response: str = "",
+ exception: BaseException | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.url = url
+ self.status = status
+ self.response = response
+ self.exception = exception
+
+
+def auth_header_value(token: str) -> str:
+ token = token.strip()
+ return token if token.lower().startswith("bearer ") else f"Bearer {token}"
+
+
+def redact_url(url: str) -> str:
+ return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
+
+
+def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
+ if verify_ssl:
+ return None
+ return ssl._create_unverified_context()
+
+
+def build_login_url(api_base: str, path: str, password: str) -> str:
+ base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
+ if password:
+ separator = "&" if "?" in base else "?"
+ base = f"{base}{separator}password={urllib.parse.quote(password, safe='')}"
+ return base
+
+
+def extract_access_token(data: Any) -> str | None:
+ if isinstance(data, dict):
+ for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
+ value = data.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ for value in data.values():
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ elif isinstance(data, list):
+ for value in data:
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ return None
+
+
+def parse_json_loose(text: str) -> Any:
+ try:
+ return json.loads(text)
+ except Exception:
+ pass
+ match = re.search(r"[\[{]", text or "")
+ if not match:
+ return {}
+ start = match.start()
+ for end in range(len(text), start, -1):
+ try:
+ return json.loads(text[start:end])
+ except Exception:
+ continue
+ return {}
+
+
+def fetch_access_token(
+ *,
+ api_base: str,
+ username: str = "admin",
+ password: str = "ch@user",
+ login_path: str = "/api/v1/auth/login/username",
+ timeout: float = 90,
+ verify_ssl: bool = False,
+) -> AuthResult:
+ """Login once and return the extracted access token."""
+ url = build_login_url(api_base, login_path, password)
+ payload = {"username": username}
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ parsed = parse_json_loose(raw)
+ token = extract_access_token(parsed)
+ if not token:
+ raise AuthError(
+ "登录接口已响应但未找到 access_token",
+ url=redact_url(url),
+ status=getattr(resp, "status", None),
+ response=raw,
+ )
+ return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
+ raise AuthError(
+ "登录接口返回错误",
+ url=redact_url(url),
+ status=exc.code,
+ response=body,
+ exception=exc,
+ ) from exc
+ except AuthError:
+ raise
+ except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
+ raise AuthError(
+ "登录接口调用异常",
+ url=redact_url(url),
+ response=str(exc),
+ exception=exc,
+ ) from exc
diff --git a/skills-data/xdfx-step6-pathway-strategy/scripts/xdfx_flow.py b/skills-data/xdfx-step6-pathway-strategy/scripts/xdfx_flow.py
index 52880e82..5f405cc8 100644
--- a/skills-data/xdfx-step6-pathway-strategy/scripts/xdfx_flow.py
+++ b/skills-data/xdfx-step6-pathway-strategy/scripts/xdfx_flow.py
@@ -26,7 +26,10 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
+import xdfx_auth
+
+# Keep every backend path in one table so endpoint drift is easy to audit.
ENDPOINTS = {
"aiuser": "/gpt/chat/chat_aiuser",
"group": "/gpt/chat/chat_qianqian_group_chat",
@@ -44,10 +47,12 @@ ENDPOINTS = {
"save_narrate": "/api/base/base-narrate",
"save_message": "/api/task/task-action-message",
"save_strategy_relation": "/api/task/task-action-design-strategy-relation",
+ "dict_data": "/api/sys/sys-dict-data",
}
+# Human-readable names are used only in the Chinese feedback logs.
ENDPOINT_LABELS = {
- "login": "登录获取访问 token",
+ "login": "登录获取访问令牌",
"aiuser": "调用候选账号/受众分析模型接口",
"group": "调用受众目标结构化抽取接口",
"spread": "调用关键传播节点结构化抽取接口",
@@ -64,8 +69,86 @@ ENDPOINT_LABELS = {
"save_narrate": "保存策略信息主题",
"save_message": "保存策略消息配置",
"save_strategy_relation": "保存策略与受众/传播节点关系",
+ "dict_data": "读取前端字典数据",
}
+LOG_PREFIXES = {
+ "operation": "操作反馈",
+ "error": "错误反馈",
+ "json_review": "JSON解析复核",
+ "save": "入库反馈",
+ "step": "步骤反馈",
+ "done": "执行完成",
+}
+
+STATUS_LABELS = {
+ "ok": "成功",
+ "partial": "部分完成",
+ "skipped": "已跳过",
+}
+
+STEP_NUMBER_LABELS = {
+ "step1": "第一步",
+ "step2": "第二步",
+ "step3": "第三步",
+ "step4": "第四步",
+ "step5": "第五步",
+ "step6": "第六步",
+}
+
+HTTP_METHOD_LABELS = {
+ "GET": "读取(GET)",
+ "POST": "提交(POST)",
+ "PUT": "更新(PUT)",
+ "PATCH": "局部更新(PATCH)",
+ "DELETE": "删除(DELETE)",
+}
+
+STAGE_SEGMENT_LABELS = {
+ "analyze": "模型分析",
+ "extract": "结构化抽取",
+ "save": "保存数据",
+ "read": "读取数据",
+ "status": "同步状态",
+}
+
+REQUEST_FIELD_LABELS = {
+ "history": "历史消息",
+ "max_tokens": "最大生成令牌数",
+ "model_name": "模型名称",
+ "otherinfo": "其他信息",
+ "prompt_name": "提示词名称",
+ "stream": "是否流式",
+ "temperature": "温度",
+ "usermessage": "用户消息",
+ "user_message": "用户消息",
+ "mubiao": "行动目标",
+ "zhanghao": "账号列表",
+ "taskId": "任务ID",
+ "actionId": "行动ID",
+ "taskActionId": "行动ID",
+ "designId": "受众ID",
+ "pathwayId": "传播节点ID",
+ "strategyId": "策略ID",
+ "accountId": "账号ID",
+ "accountName": "账号名称",
+ "accountType": "账号类型",
+ "targetAudience": "受众目标",
+ "vulnerabilities": "脆弱点",
+ "topic": "信息标题",
+ "narrate": "叙事主题",
+ "platform": "平台",
+ "persona": "发布人设",
+ "modelType": "信息形式",
+ "quantity": "数量",
+ "publishTime": "发布时间段",
+ "reason": "理由",
+ "ids": "ID列表",
+ "id": "ID",
+ "step": "步骤状态",
+}
+
+# Platform values mirror the frontend dictionaries used by xdfx strategy pages.
PLATFORM_MAP = {
"facebook": 1,
"fb": 1,
@@ -78,6 +161,8 @@ PLATFORM_MAP = {
"yt": 4,
}
+# The model service sometimes returns a successful HTTP response with these
+# strings instead of useful content. Treat them like transient failures.
MODEL_RETRY_MARKERS = (
"y模型没有响应",
"模型没有响应",
@@ -118,6 +203,12 @@ class FlowError(RuntimeError):
@dataclass
class FlowContext:
+ """Runtime options shared by every step.
+
+ Keep this object boring and serializable-ish: it is passed through every
+ helper, while durable progress belongs in the state JSON file.
+ """
+
api_base: str
headers: dict[str, str]
timeout: float
@@ -129,6 +220,8 @@ class FlowContext:
max_pathways: int = 3
max_vulns: int = 3
max_strategies: int = 3
+ downstream_audiences: int = 2
+ downstream_pathways: int = 2
sync_step_status: bool = True
model_retries: int = 1
verify_ssl: bool = False
@@ -153,7 +246,7 @@ def compact(value: Any, limit: int = 1400) -> str:
text = repr(value)
if len(text) <= limit:
return text
- return text[:limit] + f"... "
+ return text[:limit] + f"... <已截断 {len(text) - limit} 个字符>"
def stable_json(value: Any) -> str:
@@ -223,6 +316,11 @@ def save_json_file(path: str | Path, value: Any) -> None:
def default_state() -> dict[str, Any]:
+ """Create a resumable state container.
+
+ The script can be run as six independent commands. IDs returned by one step
+ are persisted here so later steps can resume without redoing earlier saves.
+ """
return {
"api_base": "",
"taskId": "",
@@ -247,6 +345,7 @@ def default_state() -> dict[str, Any]:
def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> None:
+ # Accept both frontend-style camelCase and CLI-friendly snake_case inputs.
aliases = {
"task_id": "taskId",
"action_id": "actionId",
@@ -260,16 +359,12 @@ def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> No
continue
if dest in {"taskId", "actionId"}:
state[dest] = str(value)
- elif dest in state or dest in {
- "api_base",
- "headers",
- "auth_token",
- "authorization",
- }:
+ elif dest in state or dest in {"api_base", "headers"}:
state[dest] = value
def record_event(state: dict[str, Any], stage: str, message: str, data: Any | None = None) -> None:
+ """Append a compact diagnostic event to the state file."""
state.setdefault("events", []).append(
{
"time": utc_now(),
@@ -286,9 +381,13 @@ def feedback_step(stage: str) -> str:
return match.group(1) if match else ""
+def feedback_step_label(stage: str) -> str:
+ return STEP_NUMBER_LABELS.get(feedback_step(stage), "")
+
+
def request_keys(payload: Any) -> list[str]:
if isinstance(payload, dict):
- return sorted(str(key) for key in payload.keys())
+ return sorted(REQUEST_FIELD_LABELS.get(str(key), str(key)) for key in payload.keys())
return []
@@ -306,6 +405,77 @@ def endpoint_label(endpoint_key: str | None, method: str, url: str) -> str:
return f"调用接口 {method}"
+def method_label(method: str) -> str:
+ return HTTP_METHOD_LABELS.get(str(method).upper(), str(method))
+
+
+def yes_no(value: bool) -> str:
+ return "是" if value else "否"
+
+
+def stage_label(stage: Any) -> str:
+ text = str(stage or "")
+ if not text:
+ return ""
+ if text in STEP_LABELS:
+ return STEP_LABELS[text]
+ if text == "runner":
+ return "流程执行器"
+ parts = text.split("/")
+ base = parts[0]
+ match = re.search(r"(step[1-6])", base)
+ labels: list[str] = []
+ if match:
+ labels.append(STEP_LABELS.get(match.group(1), match.group(1)))
+ elif base == "runner":
+ labels.append("流程执行器")
+ else:
+ labels.append(base)
+ for segment in parts[1:]:
+ normalized = segment.replace("-", "_")
+ attempt = re.fullmatch(r"model-call-attempt-(\d+)", segment)
+ json_attempt = re.fullmatch(r"model-json-attempt-(\d+)", segment)
+ if attempt:
+ labels.append(f"模型接口第 {attempt.group(1)} 次尝试")
+ elif json_attempt:
+ labels.append(f"JSON解析第 {json_attempt.group(1)} 次尝试")
+ elif segment == "auto-login":
+ labels.append("自动登录")
+ elif segment in STAGE_SEGMENT_LABELS:
+ labels.append(STAGE_SEGMENT_LABELS[segment])
+ elif normalized in ENDPOINT_LABELS:
+ labels.append(ENDPOINT_LABELS[normalized])
+ else:
+ labels.append(segment)
+ return " / ".join(labels)
+
+
+def status_label(status: Any) -> str:
+ if status is None:
+ return ""
+ return STATUS_LABELS.get(str(status), str(status))
+
+
+def mode_label(mode: str) -> str:
+ if mode == "full":
+ return "全流程"
+ return STEP_LABELS.get(mode, mode)
+
+
+def exception_type_label(exception_type: Any) -> str:
+ name = str(exception_type or "")
+ labels = {
+ "HTTPError": "HTTP 调用异常",
+ "URLError": "网络地址异常",
+ "TimeoutError": "请求超时异常",
+ "FlowError": "流程异常",
+ "ValueError": "数据格式异常",
+ "KeyError": "字段缺失异常",
+ "RuntimeError": "运行异常",
+ }
+ return labels.get(name, "程序异常" if name else "")
+
+
def emit_operation_feedback(
state: dict[str, Any],
stage: str,
@@ -317,39 +487,41 @@ def emit_operation_feedback(
attempt: int = 1,
dry_run: bool = False,
) -> None:
+ # Feedback lines are a mini protocol consumed by the calling agent. They
+ # must stay single-line JSON so weak models/tools can relay them reliably.
payload_obj = {
- "type": "OP_FEEDBACK",
- "step": feedback_step(stage),
- "stage": stage,
- "operation": endpoint_label(endpoint_key, method, url),
- "method": method,
- "url": url,
- "endpoint": endpoint_key or "",
- "attempt": attempt,
- "dryRun": dry_run,
- "requestKeys": request_keys(payload),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "操作反馈",
+ "步骤": feedback_step_label(stage),
+ "阶段": stage_label(stage),
+ "操作": endpoint_label(endpoint_key, method, url),
+ "请求方式": method_label(method),
+ "接口地址": url,
+ "接口名称": endpoint_label(endpoint_key, method, url),
+ "第几次尝试": attempt,
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("OP_FEEDBACK " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["operation"] + " " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
def emit_error_feedback(error: dict[str, Any]) -> None:
payload = {
- "type": "ERROR_FEEDBACK",
- "stage": error.get("stage"),
- "step": feedback_step(str(error.get("stage") or "")),
- "message": error.get("message"),
- "url": error.get("url"),
- "status": error.get("status"),
- "request": error.get("request"),
- "response": error.get("response"),
- "exceptionType": error.get("exceptionType"),
- "exception": error.get("exception"),
- "time": error.get("time"),
+ "日志类型": "错误反馈",
+ "阶段": stage_label(error.get("stage")),
+ "步骤": feedback_step_label(str(error.get("stage") or "")),
+ "错误信息": error.get("message"),
+ "接口地址": error.get("url"),
+ "状态码": error.get("status"),
+ "请求摘要": error.get("request"),
+ "响应摘要": error.get("response"),
+ "异常类型": exception_type_label(error.get("exceptionType")),
+ "异常信息": error.get("exception"),
+ "时间": error.get("time"),
}
- print("ERROR_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["error"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def emit_json_parse_review(
@@ -360,18 +532,89 @@ def emit_json_parse_review(
candidate_count: int,
) -> None:
payload = {
- "type": "JSON_PARSE_REVIEW",
- "stage": stage,
- "step": feedback_step(stage),
- "endpoint": endpoint_key,
- "message": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
- "candidateCount": candidate_count,
- "responseExcerpt": compact(text, 3000),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "JSON解析复核",
+ "阶段": stage_label(stage),
+ "步骤": feedback_step_label(stage),
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, endpoint_key),
+ "提示": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
+ "候选数量": candidate_count,
+ "响应片段": compact(text, 3000),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("JSON_PARSE_REVIEW " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["json_review"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
+
+
+SAVE_ENTITY_LABELS = {
+ "save_design": "受众目标",
+ "save_design_account": "受众目标账号",
+ "save_pathway": "关键传播节点",
+ "save_relation": "受众-传播节点关系",
+ "save_design_vuln": "受众脆弱点",
+ "save_pathway_vuln": "传播节点脆弱点",
+ "save_strategy": "策略",
+ "save_channel": "策略平台与人设",
+ "save_narrate": "策略信息主题",
+ "save_message": "策略消息配置",
+ "save_strategy_relation": "策略关联关系",
+}
+
+
+def saved_entity_name(endpoint_key: str, payload: dict[str, Any]) -> str:
+ """Return the most user-readable name for an inserted row."""
+ candidates = [
+ payload.get("targetAudience"),
+ payload.get("accountName"),
+ payload.get("vulnerabilities"),
+ payload.get("topic"),
+ payload.get("narrate"),
+ payload.get("platform"),
+ payload.get("strategyId"),
+ payload.get("designId"),
+ payload.get("pathwayId"),
+ ]
+ for value in candidates:
+ if value is not None and str(value).strip():
+ return str(value).strip()
+ return endpoint_key
+
+
+def emit_save_feedback(
+ state: dict[str, Any],
+ stage: str,
+ endpoint_key: str,
+ payload: dict[str, Any],
+ row_id: int,
+ *,
+ dry_run: bool,
+) -> None:
+ # Emit only after an id is known. Downstream steps use these ids and should
+ # fail loudly rather than inventing relations against missing rows.
+ label = SAVE_ENTITY_LABELS.get(endpoint_key, endpoint_key)
+ name = saved_entity_name(endpoint_key, payload)
+ feedback = {
+ "日志类型": "入库反馈",
+ "步骤": feedback_step(stage),
+ "阶段": stage,
+ "入库对象类型": label,
+ "入库对象名称": name,
+ "返回ID": row_id,
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, label),
+ "接口路径": ENDPOINTS.get(endpoint_key, ""),
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
+ }
+ print(LOG_PREFIXES["save"] + " " + json.dumps(feedback, ensure_ascii=False), flush=True)
+ record_event(
+ state,
+ stage,
+ f"入库{label}成功:{name}",
+ {"id": row_id, "endpoint": endpoint_key, "dryRun": dry_run},
+ )
def record_error(
@@ -385,6 +628,8 @@ def record_error(
response: Any | None = None,
exception: BaseException | None = None,
) -> None:
+ # Persist full-enough error context for post-run diagnosis, then immediately
+ # emit a short line the agent can show to the user.
item = {
"time": utc_now(),
"stage": stage,
@@ -430,65 +675,25 @@ def url_for_path(ctx: FlowContext, path: str) -> str:
return ctx.api_base.rstrip("/") + "/" + path.lstrip("/")
-def parse_headers(header_values: list[str], auth_token: str | None, authorization: str | None) -> dict[str, str]:
+def parse_headers(header_values: list[str]) -> dict[str, str]:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
- if auth_token:
- headers["Authorization"] = auth_token if auth_token.lower().startswith("bearer ") else f"Bearer {auth_token}"
- if authorization:
- headers["Authorization"] = authorization
for raw in header_values:
if ":" not in raw:
raise FlowError(f"header 格式错误,应为 Key: Value,实际:{raw}")
key, value = raw.split(":", 1)
- headers[key.strip()] = value.strip()
+ normalized = key.strip()
+ if normalized.lower() == "authorization":
+ continue
+ headers[normalized] = value.strip()
return headers
-def has_authorization(headers: dict[str, str]) -> bool:
- return any(key.lower() == "authorization" and str(value).strip() for key, value in headers.items())
-
-
def ssl_context(ctx: FlowContext) -> ssl.SSLContext | None:
if ctx.verify_ssl:
return None
return ssl._create_unverified_context()
-def auth_header_value(token: str) -> str:
- token = token.strip()
- return token if token.lower().startswith("bearer ") else f"Bearer {token}"
-
-
-def extract_access_token(data: Any) -> str | None:
- if isinstance(data, dict):
- for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
- value = data.get(key)
- if isinstance(value, str) and value.strip():
- return value.strip()
- for value in data.values():
- nested = extract_access_token(value)
- if nested:
- return nested
- elif isinstance(data, list):
- for value in data:
- nested = extract_access_token(value)
- if nested:
- return nested
- return None
-
-
-def redact_url(url: str) -> str:
- return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
-
-
-def build_login_url(ctx: FlowContext, path: str) -> str:
- base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else url_for_path(ctx, path)
- if ctx.login_password:
- separator = "&" if "?" in base else "?"
- base = f"{base}{separator}password={urllib.parse.quote(ctx.login_password, safe='')}"
- return base
-
-
def login_candidate_paths(ctx: FlowContext) -> list[str]:
paths = [
ctx.login_path or "/api/v1/auth/login/username",
@@ -506,6 +711,7 @@ def login_candidate_paths(ctx: FlowContext) -> list[str]:
def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force: bool = False) -> bool:
+ """Fetch a fresh token through the local auth package before business calls."""
if not ctx.auto_login:
return False
if ctx.dry_run:
@@ -515,7 +721,7 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
record_error(state, stage, "自动登录失败:缺少 login_username")
return False
if not force and ctx.login_attempt_count > 0:
- return has_authorization(ctx.headers)
+ return any(key.lower() == "authorization" and str(value).strip() for key, value in ctx.headers.items())
if force and ctx.login_attempt_count >= 2:
return False
@@ -525,8 +731,7 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
last_response = ""
last_exc: BaseException | None = None
for attempt, path in enumerate(login_candidate_paths(ctx), start=1):
- url = build_login_url(ctx, path)
- display_url = redact_url(url)
+ display_url = xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, path, ctx.login_password))
emit_operation_feedback(
state,
stage,
@@ -537,63 +742,39 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
attempt=attempt,
)
try:
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- headers = {
- "Content-Type": "application/json",
- "Accept": "application/json",
- }
- req = urllib.request.Request(url, data=data, headers=headers, method="POST")
- with urllib.request.urlopen(req, timeout=ctx.timeout, context=ssl_context(ctx)) as resp:
- raw = resp.read().decode("utf-8", errors="replace")
- parsed = parse_json_lenient(raw)
- token = extract_access_token(parsed)
- if not token:
- last_status = getattr(resp, "status", None)
- last_response = raw
- record_event(
- state,
- stage,
- "登录接口已响应但未找到 access_token",
- {"url": display_url, "status": last_status, "response": compact(raw, 1000)},
- )
- continue
- ctx.headers["Authorization"] = auth_header_value(token)
- record_event(
- state,
- stage,
- "自动登录成功,已写入 Authorization Bearer token",
- {
- "url": display_url,
- "status": getattr(resp, "status", None),
- "username": ctx.login_username,
- },
- )
- return True
- except urllib.error.HTTPError as exc:
- last_status = exc.code
- last_response = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
- last_exc = exc
- record_event(
- state,
- stage,
- "登录接口返回错误,准备尝试下一个候选登录路径",
- {"url": display_url, "status": exc.code, "response": compact(last_response, 1000)},
+ result = xdfx_auth.fetch_access_token(
+ api_base=ctx.api_base,
+ username=ctx.login_username,
+ password=ctx.login_password,
+ login_path=path,
+ timeout=ctx.timeout,
+ verify_ssl=ctx.verify_ssl,
)
- except Exception as exc: # noqa: BLE001 - auto-login must report every failure.
- last_exc = exc
- last_response = traceback.format_exc()
+ ctx.headers["Authorization"] = xdfx_auth.auth_header_value(result.token)
+ state["_tokenSource"] = "xdfx_auth"
record_event(
state,
stage,
- "登录接口调用异常,准备尝试下一个候选登录路径",
- {"url": display_url, "exceptionType": type(exc).__name__, "exception": str(exc)},
+ "启动阶段已通过 xdfx_auth 包获取 token,并写入 Authorization",
+ {"url": result.url, "status": result.status, "username": ctx.login_username},
+ )
+ return True
+ except xdfx_auth.AuthError as exc:
+ last_status = exc.status
+ last_response = exc.response
+ last_exc = exc
+ record_event(
+ state,
+ stage,
+ "xdfx_auth 获取 token 失败,准备尝试下一个候选登录路径",
+ {"url": exc.url, "status": exc.status, "response": compact(exc.response, 1000), "exception": str(exc)},
)
record_error(
state,
stage,
- "自动登录失败,未能获取 access_token",
- url=redact_url(build_login_url(ctx, ctx.login_path or "/api/v1/auth/login/username")),
+ "自动登录失败,未能通过 xdfx_auth 获取 access_token",
+ url=xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, ctx.login_path or "/api/v1/auth/login/username", ctx.login_password)),
status=last_status,
request=payload,
response=last_response,
@@ -612,6 +793,7 @@ def http_request_json_url(
endpoint_key: str | None = None,
allow_auto_login: bool = True,
) -> tuple[Any | None, str]:
+ """Call an HTTP JSON endpoint with logging, retries, and 401 recovery."""
method = method.upper()
if ctx.dry_run:
emit_operation_feedback(state, stage, method, url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
@@ -640,6 +822,7 @@ def http_request_json_url(
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
if exc.code == 401 and allow_auto_login and ctx.auto_login:
+ # Refresh once on 401, then replay the original request exactly.
refreshed = try_auto_login(ctx, state, f"{stage}/auto-login", force=True)
if refreshed:
record_event(
@@ -709,6 +892,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
emit_operation_feedback(state, stage, "POST", url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
record_event(state, stage, f"DRY RUN POST {url}", {"payload": payload})
if endpoint_key.startswith("save_"):
+ # Synthetic ids let downstream dry-run code exercise relation wiring.
synthetic_id = abs(hash(stage + stable_json(payload))) % 100000000
return {"id": synthetic_id, "dryRun": True}, json.dumps({"id": synthetic_id, "dryRun": True})
return {"dryRun": True}, json.dumps({"dryRun": True})
@@ -716,6 +900,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
def parse_json_lenient(text: str) -> Any | None:
+ """Parse normal JSON plus adjacent/SSE-like JSON objects."""
stripped = text.strip()
if not stripped:
return None
@@ -730,6 +915,7 @@ def parse_json_lenient(text: str) -> Any | None:
def parse_concatenated_json_objects(text: str) -> list[Any]:
+ """Read streams such as {"answer": "..."}{"answer": "..."} or data: lines."""
decoder = json.JSONDecoder()
objects: list[Any] = []
i = 0
@@ -766,6 +952,7 @@ def parse_concatenated_json_objects(text: str) -> list[Any]:
def model_text_from_response(parsed: Any, raw: str) -> tuple[str, list[Any]]:
+ """Flatten streamed model chunks into one text buffer."""
objects: list[Any]
if isinstance(parsed, list):
objects = parsed
@@ -824,6 +1011,7 @@ def extract_json_from_text(text: str) -> Any | None:
def extract_json_candidates_from_text(text: str) -> list[Any]:
+ """Collect every plausible JSON object/array from messy model text."""
cleaned = strip_think(text)
candidates: list[Any] = []
seen: set[str] = set()
@@ -860,6 +1048,7 @@ def extract_json_candidates_from_text(text: str) -> list[Any]:
def select_json_candidate(candidates: list[Any], endpoint_key: str, stage: str) -> Any | None:
+ """Pick the candidate whose keys best match the current step."""
if not candidates:
return None
scored = [(json_candidate_score(candidate, endpoint_key, stage), idx, candidate) for idx, candidate in enumerate(candidates)]
@@ -1010,6 +1199,7 @@ def extract_first_balanced(text: str, open_ch: str, close_ch: str) -> str | None
def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[str, list[Any], Any | None]:
+ """Call a model endpoint and retry only known empty/timeout/no-response cases."""
max_attempts = max(ctx.model_retries, 0) + 1
last_text = ""
last_objects: list[Any] = []
@@ -1048,6 +1238,11 @@ def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str,
def call_model_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[dict[str, Any], str, list[Any]]:
+ """Return the best structured JSON for a model step.
+
+ If the first parse fails, the second attempt includes an explicit repair
+ instruction so weak models can re-emit only the required JSON.
+ """
last_text = ""
last_objects: list[Any] = []
last_candidate_count = 0
@@ -1202,23 +1397,85 @@ def profile_info(account: dict[str, Any]) -> str:
def parse_index_list(value: Any) -> list[str]:
- if value is None:
- return []
- if isinstance(value, list):
- return [str(v).strip() for v in value if str(v).strip()]
- text = str(value)
- return [part.strip().strip("<>").strip() for part in re.split(r"[,,;;、\s]+", text) if part.strip()]
+ refs: list[str] = []
+
+ def add(raw: Any) -> None:
+ if raw is None:
+ return
+ if isinstance(raw, dict):
+ keys = (
+ "编号id",
+ "编号ID",
+ "用户编号id",
+ "用户编号ID",
+ "用户编号",
+ "账号编号",
+ "引用序号",
+ "索引",
+ "index",
+ "id",
+ "ids",
+ "accountIndex",
+ "accountIndexes",
+ "userIndex",
+ "userIndexes",
+ )
+ for key in keys:
+ if key in raw:
+ add(raw.get(key))
+ return
+ if isinstance(raw, (list, tuple, set)):
+ for item in raw:
+ add(item)
+ return
+ text = str(raw).strip()
+ if not text:
+ return
+ angle_refs = re.findall(r"<<\s*([^<>]+?)\s*>>", text)
+ if angle_refs:
+ for token in angle_refs:
+ add(token)
+ return
+ for part in re.split(r"[,,;;、\s]+", text):
+ token = part.strip().strip("<>").strip()
+ if token:
+ refs.append(token)
+
+ add(value)
+ deduped: list[str] = []
+ seen: set[str] = set()
+ for ref in refs:
+ if ref in seen:
+ continue
+ seen.add(ref)
+ deduped.append(ref)
+ return deduped
+
+
+def account_ref_numbers(account: dict[str, Any]) -> set[int]:
+ numbers: set[int] = set()
+ meta = account.get("metadata") if isinstance(account.get("metadata"), dict) else {}
+ for key in ("编号id", "编号ID", "用户编号id", "用户编号ID", "用户编号", "账号编号", "引用序号", "索引", "index", "accountIndex"):
+ for container in (account, meta):
+ number = as_int(container.get(key)) if isinstance(container, dict) else None
+ if number is not None:
+ numbers.add(number)
+ return numbers
def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, Any] | None:
+ """Resolve model references like <<3>>, index numbers, names, or account ids."""
if not accounts:
return None
text = str(ref).strip().strip("<>").strip()
number = as_int(text)
if number is not None:
for account in accounts:
- if any(as_int(account.get(key)) == number for key in ("引用序号", "索引", "index")):
+ if number in account_ref_numbers(account):
return account
+ # Step1 group extraction returns 编号/id values that refer to the
+ # previous zhanghao array index. Prefer exact index fields above, then
+ # fall back to zero-based and one-based array positions.
if 0 <= number < len(accounts):
return accounts[number]
if 1 <= number <= len(accounts):
@@ -1232,11 +1489,25 @@ def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, A
def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Normalize audience JSON whether the model used numbered fields or items[]."""
items: list[dict[str, Any]] = []
for i in range(1, 21):
group = first_present(data.get(f"群体{i}"), data.get(f"受众{i}"), data.get(f"targetAudience{i}"))
reason = first_present(data.get(f"理由{i}"), data.get(f"推荐理由{i}"), data.get(f"recReason{i}"))
- users = first_present(data.get(f"用户编号列表{i}"), data.get(f"用户列表{i}"), data.get(f"账号编号列表{i}"))
+ users = first_present(
+ data.get(f"用户编号列表{i}"),
+ data.get(f"编号id列表{i}"),
+ data.get(f"编号ID列表{i}"),
+ data.get(f"用户编号id列表{i}"),
+ data.get(f"用户编号ID列表{i}"),
+ data.get(f"用户列表{i}"),
+ data.get(f"账号编号列表{i}"),
+ data.get(f"索引列表{i}"),
+ data.get(f"编号{i}"),
+ data.get(f"id{i}"),
+ data.get(f"ids{i}"),
+ data.get(f"索引{i}"),
+ )
if group:
items.append(
{
@@ -1255,7 +1526,27 @@ def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
{
"targetAudience": str(group).strip(),
"recReason": str(first_present(item.get("理由"), item.get("recReason"), item.get("reason")) or "").strip(),
- "userIndexes": parse_index_list(first_present(item.get("用户编号列表"), item.get("users"), item.get("accounts"))),
+ "userIndexes": parse_index_list(
+ first_present(
+ item.get("用户编号列表"),
+ item.get("编号id列表"),
+ item.get("编号ID列表"),
+ item.get("用户编号id列表"),
+ item.get("用户编号ID列表"),
+ item.get("编号id"),
+ item.get("编号ID"),
+ item.get("用户编号id"),
+ item.get("用户编号ID"),
+ item.get("编号"),
+ item.get("索引"),
+ item.get("id"),
+ item.get("ids"),
+ item.get("users"),
+ item.get("accounts"),
+ item.get("userIndexes"),
+ item.get("accountIndexes"),
+ )
+ ),
"rank": i,
}
)
@@ -1272,6 +1563,7 @@ def audience_score(item: dict[str, Any]) -> tuple[int, int]:
def select_top_audiences(items: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
+ """Deduplicate audience names and keep the strongest candidates."""
unique: dict[str, dict[str, Any]] = {}
for item in items:
key = item.get("targetAudience", "")
@@ -1301,6 +1593,7 @@ def pathway_score(item: dict[str, Any]) -> tuple[int, int, int]:
def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Merge the same account found from different audiences before saving top 3."""
merged: dict[str, dict[str, Any]] = {}
for item in candidates:
account = item.get("account") or {}
@@ -1325,6 +1618,7 @@ def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str,
def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
+ """Normalize weak-point model output into a capped list."""
vulns: list[dict[str, str]] = []
for i in range(1, 21):
vuln = first_present(data.get(f"脆弱点{i}"), data.get(f"vulnerability{i}"), data.get(f"vuln{i}"))
@@ -1339,6 +1633,7 @@ def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
def extract_strategies(data: dict[str, Any], limit: int) -> list[dict[str, Any]]:
+ """Normalize strategy output and preserve the modal text for later mapping."""
strategies: list[dict[str, Any]] = []
for i in range(1, 21):
title = first_present(data.get(f"信息标题{i}"), data.get(f"主题{i}"), data.get(f"topic{i}"))
@@ -1395,11 +1690,29 @@ def response_id(response: Any) -> int | None:
return None
+def response_rows(response: Any) -> list[Any]:
+ """Extract list rows from common CRUD response shapes."""
+ if isinstance(response, list):
+ return response
+ if not isinstance(response, dict):
+ return []
+ for key in ("data", "rows", "items", "records", "list"):
+ value = response.get(key)
+ if isinstance(value, list):
+ return value
+ rows = response_rows(value)
+ if rows:
+ return rows
+ return []
+
+
def save_with_id(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[int | None, Any | None]:
response, _ = http_post_json(ctx, state, endpoint_key, payload, stage)
rid = response_id(response)
if rid is None:
- record_error(state, stage, "保存接口未返回 id,视为失败", request=payload, response=response)
+ record_error(state, stage, "保存接口未返回 ID,视为失败", request=payload, response=response)
+ else:
+ emit_save_feedback(state, stage, endpoint_key, payload, rid, dry_run=ctx.dry_run)
return rid, response
@@ -1429,6 +1742,7 @@ def parse_step_flags(raw: Any) -> dict[str, Any]:
def mark_step_status(ctx: FlowContext, state: dict[str, Any], links: list[str], stage: str) -> None:
+ """Update the frontend step bar without overwriting unrelated step flags."""
if not ctx.sync_step_status:
record_event(state, stage, "跳过 task-action.step 状态同步")
return
@@ -1477,12 +1791,126 @@ def strategy_platform(value: Any) -> Any:
return platform_to_number(value) or 1
-def strategy_model_type(value: Any) -> str:
- # Frontend defaults to text model type "1" when no modal type is selected.
- raw = first_present(value, "1")
- if isinstance(raw, list):
- return ",".join(str(v) for v in raw if str(v).strip()) or "1"
- return str(raw or "1")
+def choice_tokens(value: Any) -> list[Any]:
+ """Split frontend choice values from arrays, JSON strings, and mixed delimiters."""
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return choice_tokens(
+ first_present(value.get("value"), value.get("dictValue"), value.get("label"), value.get("dictLabel"))
+ )
+ if isinstance(value, (list, tuple, set)):
+ tokens: list[Any] = []
+ for item in value:
+ tokens.extend(choice_tokens(item))
+ return tokens
+ text = str(value).strip()
+ if not text:
+ return []
+ if text[0] in "[{":
+ try:
+ parsed = json.loads(text)
+ if parsed != value:
+ return choice_tokens(parsed)
+ except Exception:
+ pass
+ return [part.strip() for part in re.split(r"[,,、;/|]+", text) if part.strip()]
+
+
+def dict_choices(
+ ctx: FlowContext,
+ state: dict[str, Any],
+ dict_type: str,
+ stage: str,
+) -> list[dict[str, Any]]:
+ """Fetch and cache frontend dictionary choices such as modelType."""
+ cache = state.setdefault("_dictChoiceCache", {})
+ if dict_type in cache:
+ cached = cache[dict_type]
+ return cached if isinstance(cached, list) else []
+ if ctx.dry_run:
+ cache[dict_type] = []
+ return []
+
+ query = urllib.parse.urlencode({"filter": f"dictType||$eq||{dict_type}", "limit": 999, "page": 1})
+ url = f"{url_for(ctx, 'dict_data')}?{query}"
+ response, _ = http_request_json_url(ctx, state, "GET", url, None, stage, endpoint_key="dict_data")
+ choices: list[dict[str, Any]] = []
+ for row in response_rows(response):
+ if not isinstance(row, dict):
+ continue
+ label = str(first_present(row.get("dictLabel"), row.get("label"), "") or "").strip()
+ raw_value = first_present(row.get("dictValue"), row.get("value"))
+ if not label or raw_value is None:
+ continue
+ number = as_int(raw_value)
+ choices.append({"label": label, "value": number if number is not None else str(raw_value).strip()})
+ for alias in choice_tokens(row.get("alias")):
+ alias_label = str(alias).strip()
+ if alias_label:
+ choices.append({"label": alias_label, "value": number if number is not None else str(raw_value).strip()})
+ cache[dict_type] = choices
+ return choices
+
+
+def choice_value_by_label(choices: list[dict[str, Any]] | None, label: str) -> Any | None:
+ needle = re.sub(r"\s+", "", str(label or "").lower())
+ if not needle:
+ return None
+ for item in choices or []:
+ item_label = re.sub(r"\s+", "", str(item.get("label") or "").lower())
+ if item_label and (needle == item_label or needle in item_label or item_label in needle):
+ return item.get("value")
+ return None
+
+
+def model_type_token_value(token: Any, choices: list[dict[str, Any]] | None = None) -> Any:
+ """Convert modal labels like 图片/视频 into frontend dictionary values."""
+ number = as_int(token)
+ if number is not None:
+ return number
+
+ text = str(token or "").strip()
+ lower = text.lower()
+ exact = choice_value_by_label(choices, text)
+ if exact is not None:
+ return exact
+
+ label = ""
+ fallback_value = 2
+ if "视" in text or "video" in lower:
+ label, fallback_value = "视频", 3
+ elif "图" in text or "image" in lower or "picture" in lower or "photo" in lower:
+ label, fallback_value = "图片", 2
+ elif "音" in text or "audio" in lower or "sound" in lower:
+ label, fallback_value = "音频", 4
+ elif "发文" in text or "文本" in text or "文字" in text or "text" in lower:
+ label, fallback_value = "文本", 1
+
+ if label:
+ matched = choice_value_by_label(choices, label)
+ if matched is not None:
+ return matched
+ return fallback_value
+
+ # Mirrors the frontend helper's unknown-token default.
+ return 2
+
+
+def strategy_model_type(value: Any, choices: list[dict[str, Any]] | None = None) -> str:
+ """Return the comma-separated value string expected by task-action-message."""
+ tokens = choice_tokens(value)
+ if not tokens:
+ # Frontend save paths fall back to "1" when no modal type is selected.
+ return "1"
+ values: list[str] = []
+ seen: set[str] = set()
+ for token in tokens:
+ normalized = str(model_type_token_value(token, choices)).strip()
+ if normalized and normalized not in seen:
+ values.append(normalized)
+ seen.add(normalized)
+ return ",".join(values) or "1"
def strategy_language(value: Any) -> int:
@@ -1501,11 +1929,28 @@ def save_strategy_details(
*,
audience: bool,
) -> None:
- platform = strategy_platform(first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})))
+ """Save all frontend-readable strategy child rows.
+
+ The strategy table and arrangement page do not read only the main strategy
+ row; they also expect channel, narrate, message, and relation rows.
+ """
+ platform = strategy_platform(
+ first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
+ )
+ account_type = str(
+ first_present(
+ strategy.get("accountType"),
+ item.get("accountType"),
+ item.get("type"),
+ "目标受众账号" if audience else "关键传播节点账号",
+ )
+ or ""
+ ).strip()
persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
title = str(strategy.get("title") or "").strip()
+ model_type_choices = dict_choices(ctx, state, "modelType", stage + "/dict-modelType")
channel_id, _ = save_with_id(
ctx,
@@ -1516,6 +1961,7 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"platform": platform,
+ "accountType": account_type,
"personaJson": json.dumps([{"persona": persona, "size": persona_size}], ensure_ascii=False),
},
stage + "/save-channel",
@@ -1531,7 +1977,10 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"channelId": channel_id,
- "modelType": strategy_model_type(first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType"))),
+ "modelType": strategy_model_type(
+ first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType")),
+ model_type_choices,
+ ),
"language": strategy_language(first_present(strategy.get("language"), strategy.get("语种"))),
"respondingCount": responding_count,
"messageCount": responding_count,
@@ -1564,7 +2013,39 @@ def object_for_audience(audience: dict[str, Any]) -> dict[str, Any]:
}
+def downstream_limit(ctx: FlowContext, *, audience: bool) -> int:
+ """Limit expensive downstream analysis while keeping 3 upstream rows stored."""
+ raw = ctx.downstream_audiences if audience else ctx.downstream_pathways
+ return max(1, raw)
+
+
+def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, audience: bool) -> list[dict[str, Any]]:
+ """Select the 1-2 best stored rows for vulnerability/strategy generation."""
+ source_key = "topAudiences" if audience else "topPathways"
+ id_key = "designId" if audience else "pathwayAccountId"
+ selected_key = "selectedDownstreamAudiences" if audience else "selectedDownstreamPathways"
+ items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)]
+ limit = downstream_limit(ctx, audience=audience)
+ selected = items[:limit]
+ state[selected_key] = [
+ {
+ "id": item.get(id_key),
+ "name": item.get("targetAudience") if audience else item.get("accountName"),
+ }
+ for item in selected
+ ]
+ if items:
+ record_event(
+ state,
+ stage,
+ f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}",
+ {"limit": limit, "selected": state[selected_key]},
+ )
+ return selected
+
+
def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze audiences, save top 3, and attach any referenced accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step1-audience"
if state.get("topAudiences") and not ctx.dry_run:
@@ -1582,12 +2063,15 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
state["audienceRawText"] = text
+ # The first model call may return raw text plus embedded account objects.
accounts = accounts_from_response(parsed, objects, text)
state["audienceAccounts"] = accounts
+ # The second model call converts the long audience analysis into numbered
+ # groups that can be saved as task-action-design rows.
group_payload = {
"mubiao": state["mubiao"],
- "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
"zhanghao": accounts,
"history": [],
"stream": True,
@@ -1602,6 +2086,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
top = select_top_audiences(candidates, ctx.max_audiences)
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the audience first; account rows and downstream analyses depend
+ # on the returned designId.
design_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
@@ -1614,6 +2100,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
continue
item = {**item, "designId": design_id, "saveResponse": resp, "accounts": [], "savedAccountIds": []}
for ref in item.get("userIndexes") or []:
+ # Model references are intentionally fuzzy, so resolve them against
+ # both explicit indexes and account names/ids.
account = resolve_account_ref(ref, accounts)
if account is None:
record_error(state, stage + "/save-design-account", f"用户编号 {ref} 未匹配到候选账号")
@@ -1645,6 +2133,7 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze key pathway accounts for saved audiences and store top 3."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step2-pathway"
audiences = [a for a in state.get("topAudiences") or [] if a.get("designId")]
@@ -1656,6 +2145,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
return
all_candidates: list[dict[str, Any]] = []
for audience in audiences:
+ # Analyze pathway candidates in the context of each saved audience.
payload = {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。",
"otherinfo": {
@@ -1673,6 +2163,8 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
accounts = accounts_from_response(parsed, objects, text)
+ # Ask the model to point back to candidate accounts, then resolve those
+ # references to concrete account objects.
spread_payload = {
"mubiao": state["mubiao"],
"user_message": "分析其中关键信息。请从候选账号中抽取核心传播节点,字段必须是:账号1、理由1、账号2、理由2 ...,最多输出5个。账号字段优先返回候选数组的引用编号或索引,只返回JSON。",
@@ -1707,6 +2199,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways]
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the account row before writing the design-pathway relation.
account = item["account"]
aid = account_id(account)
name = account_name(account)
@@ -1757,12 +2250,14 @@ def run_step4(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate and save weak points for selected audiences or pathway accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step3-audience-vulns" if audience else "step4-pathway-vulns"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
output_key = "audienceVulns" if audience else "pathwayVulns"
state.setdefault(output_key, {})
for item in items:
+ # owner_id is designId for audiences and pathwayAccountId for accounts.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
if not owner_id:
continue
@@ -1789,6 +2284,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) ->
vulns = extract_vulns(data, ctx.max_vulns)
saved: list[dict[str, Any]] = []
for vuln in vulns:
+ # The two weak-point tables have nearly identical payloads but
+ # different foreign-key names.
if audience:
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
@@ -1835,14 +2332,16 @@ def run_step6(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate strategies from saved weak points and persist frontend child rows."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step5-audience-strategy" if audience else "step6-pathway-strategy"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {}
output_key = "audienceStrategies" if audience else "pathwayStrategies"
state.setdefault(output_key, [])
existing_topics = {s.get("topic") for s in state[output_key] if isinstance(s, dict)}
for item in items:
+ # Strategies are only meaningful after the matching weak points exist.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
vulns = vulns_by_owner.get(str(owner_id)) or []
vuln_texts = [v.get("vulnerabilities") for v in vulns if v.get("vulnerabilities")]
@@ -1876,16 +2375,20 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
continue
if topic in existing_topics:
continue
+ # Save the main row first; channel/narrate/message/relation rows all
+ # require the returned strategy id.
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
"targetAudience": target,
"accountType": account_type,
"topic": topic,
- "strategyTitle": "",
+ "strategyTitle": str(strategy.get("title") or "").strip(),
}
strategy_id, resp = save_with_id(ctx, state, "save_strategy", save_payload, stage + "/save")
if strategy_id is not None:
+ # Frontend strategy pages read these child tables, so a strategy
+ # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience)
existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp})
@@ -1914,13 +2417,14 @@ STEP_LABELS = {
def state_counts(state: dict[str, Any]) -> dict[str, int]:
+ """Summarize stored objects for step feedback."""
return {
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceVulns": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
- "pathwayVulns": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "受众脆弱点数量": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
+ "传播节点脆弱点数量": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
}
@@ -1932,23 +2436,26 @@ def emit_step_feedback(
status: str | None = None,
note: str = "",
) -> None:
+ # One feedback line per step keeps the single-skill and split-skill versions
+ # equally transparent to the user.
total_errors = len(state.get("errors") or [])
new_errors = max(0, total_errors - before_error_count)
final_status = status or ("partial" if new_errors else "ok")
payload = {
- "type": "STEP_FEEDBACK",
- "step": step,
- "label": STEP_LABELS.get(step, step),
- "status": final_status,
- "note": note,
- "newErrors": new_errors,
- "totalErrors": total_errors,
- "counts": state_counts(state),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "updatedAt": state.get("updatedAt"),
+ "日志类型": "步骤反馈",
+ "步骤": STEP_NUMBER_LABELS.get(step, step),
+ "步骤名称": STEP_LABELS.get(step, step),
+ "状态": STATUS_LABELS.get(final_status, final_status),
+ "执行状态": status_label(final_status),
+ "备注": note,
+ "本步骤新增错误数": new_errors,
+ "累计错误数": total_errors,
+ "累计入库数量": state_counts(state),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "更新时间": state.get("updatedAt"),
}
- print("STEP_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["step"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def finish_step(
@@ -1960,6 +2467,8 @@ def finish_step(
status: str | None = None,
note: str = "",
) -> None:
+ # Always persist before emitting step feedback so the user can inspect files
+ # immediately after a reported step completes.
state["updatedAt"] = utc_now()
save_json_file(ctx.state_path, state)
if ctx.summary_path:
@@ -1980,6 +2489,11 @@ def skip_step_with_feedback(ctx: FlowContext, state: dict[str, Any], step: str,
def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Legacy full runner.
+
+ The wrapper script is preferred for real-time logs, but this remains useful
+ for dry-runs and unattended local troubleshooting.
+ """
run_step_with_feedback(ctx, state, "step1")
run_step_with_feedback(ctx, state, "step2")
run_step_with_feedback(ctx, state, "step3")
@@ -1995,6 +2509,7 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
def write_summary(path: Path, state: dict[str, Any]) -> None:
+ """Write a human-readable checkpoint beside the machine state JSON."""
audience_vuln_count = sum(len(v) for v in (state.get("audienceVulns") or {}).values())
pathway_vuln_count = sum(len(v) for v in (state.get("pathwayVulns") or {}).values())
lines = [
@@ -2005,6 +2520,8 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
f"- actionId:{state.get('actionId')}",
f"- 受众入库:{len(state.get('topAudiences') or [])}",
f"- 传播节点入库:{len(state.get('topPathways') or [])}",
+ f"- 下游受众选择:{len(state.get('selectedDownstreamAudiences') or [])}",
+ f"- 下游传播节点选择:{len(state.get('selectedDownstreamPathways') or [])}",
f"- 受众脆弱点入库:{audience_vuln_count}",
f"- 传播节点脆弱点入库:{pathway_vuln_count}",
f"- 受众策略入库:{len(state.get('audienceStrategies') or [])}",
@@ -2021,9 +2538,9 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
if state.get("errors"):
lines += ["", "## 失败明细"]
for err in state["errors"]:
- lines.append(f"- [{err.get('stage')}] {err.get('message')} status={err.get('status')} url={err.get('url')}")
+ lines.append(f"- [{stage_label(err.get('stage'))}] {err.get('message')} 状态码={err.get('status')} 接口={err.get('url')}")
if err.get("response"):
- lines.append(f" - response: {err.get('response')}")
+ lines.append(f" - 响应摘要:{err.get('response')}")
text = "\n".join(lines) + "\n"
try:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -2034,34 +2551,34 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
def build_arg_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(description="Run xdfx analysis/storage flow.")
+ parser = argparse.ArgumentParser(description="运行 xdfx 分析与入库流程。")
parser.add_argument("--mode", choices=["full", *STEP_FUNCS.keys()], default="full")
- parser.add_argument("--input", help="JSON config file. Keys: api_base, taskId, actionId, mubiao, direction, platform, headers.")
- parser.add_argument("--state", default="xdfx-flow-state.json", help="State JSON path for resume and step handoff.")
- parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown summary output path.")
+ parser.add_argument("--input", help="JSON 配置文件。常用字段:api_base、taskId、actionId、mubiao、direction、platform、headers。")
+ parser.add_argument("--state", default="xdfx-flow-state.json", help="用于断点续跑和步骤交接的状态 JSON 文件路径。")
+ parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown 执行摘要输出路径。")
parser.add_argument("--api-base")
parser.add_argument("--task-id")
parser.add_argument("--action-id")
parser.add_argument("--mubiao")
parser.add_argument("--direction", default=None)
parser.add_argument("--platform", default=None)
- parser.add_argument("--auth-token", default=os.getenv("XDFX_AUTH_TOKEN"))
- parser.add_argument("--authorization", default=os.getenv("AUTHORIZATION"))
- parser.add_argument("--header", action="append", default=[], help="Extra HTTP header, format 'Key: Value'.")
- parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="Username for automatic login when Authorization is missing or stale.")
- parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="Password query parameter for automatic username login.")
- parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="Login endpoint path or absolute URL.")
- parser.add_argument("--no-auto-login", action="store_true", help="Disable automatic username login and 401 token refresh.")
- parser.add_argument("--verify-ssl", action="store_true", help="Verify HTTPS certificates. Default is disabled for internal/UAT certificates.")
+ parser.add_argument("--header", action="append", default=[], help="附加非鉴权 HTTP 请求头,格式为 'Key: Value';Authorization 会被忽略。")
+ parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="xdfx_auth 启动阶段自动登录使用的用户名。")
+ parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="用户名登录接口的 password 查询参数。")
+ parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="登录接口路径或完整 URL。")
+ parser.add_argument("--no-auto-login", action="store_true", help="关闭 xdfx_auth 自动登录和 401 刷新。")
+ parser.add_argument("--verify-ssl", action="store_true", help="校验 HTTPS 证书。默认不校验,以兼容内网/UAT 自签证书。")
parser.add_argument("--timeout", type=float, default=90)
parser.add_argument("--retries", type=int, default=2)
parser.add_argument("--dry-run", action="store_true")
- parser.add_argument("--no-step-status", action="store_true", help="Do not update task/task-action.step after completed steps.")
- parser.add_argument("--model-retries", type=int, default=1, help="Retry model/stream endpoints when the first response is empty, timeout-like, or says the model did not respond.")
+ parser.add_argument("--no-step-status", action="store_true", help="步骤完成后不更新 task/task-action.step。")
+ parser.add_argument("--model-retries", type=int, default=1, help="模型/流式接口首次空响应、超时或提示模型无响应时的重试次数。")
parser.add_argument("--max-audiences", type=int, default=3)
parser.add_argument("--max-pathways", type=int, default=3)
parser.add_argument("--max-vulns", type=int, default=3)
parser.add_argument("--max-strategies", type=int, default=3)
+ parser.add_argument("--downstream-audiences", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库受众数量,默认 2。")
+ parser.add_argument("--downstream-pathways", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库传播节点数量,默认 2。")
return parser
@@ -2070,6 +2587,8 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
state_path = Path(args.state)
state = default_state()
+ # Load previous state first, then overlay input JSON and finally CLI flags.
+ # That ordering makes resume safe while still allowing explicit overrides.
if state_path.exists():
loaded = load_json_file(state_path)
if isinstance(loaded, dict):
@@ -2081,9 +2600,7 @@ def main(argv: list[str] | None = None) -> int:
merge_config_into_state(state, config)
input_headers = config.get("headers")
if isinstance(input_headers, dict):
- args.header.extend(f"{k}: {v}" for k, v in input_headers.items())
- args.auth_token = args.auth_token or config.get("auth_token")
- args.authorization = args.authorization or config.get("authorization")
+ args.header.extend(f"{k}: {v}" for k, v in input_headers.items() if str(k).lower() != "authorization")
if config.get("login_username") is not None:
args.login_username = str(config.get("login_username") or "")
if config.get("login_password") is not None:
@@ -2094,6 +2611,10 @@ def main(argv: list[str] | None = None) -> int:
args.no_auto_login = True
if config.get("verify_ssl") is not None:
args.verify_ssl = bool(config.get("verify_ssl"))
+ if config.get("downstream_audiences") is not None:
+ args.downstream_audiences = as_int(config.get("downstream_audiences")) or args.downstream_audiences
+ if config.get("downstream_pathways") is not None:
+ args.downstream_pathways = as_int(config.get("downstream_pathways")) or args.downstream_pathways
cli_config = {
"api_base": args.api_base,
@@ -2105,7 +2626,8 @@ def main(argv: list[str] | None = None) -> int:
}
merge_config_into_state(state, {k: v for k, v in cli_config.items() if v is not None})
api_base = state.get("api_base") or "https://ch1.b.uat.4.cn"
- headers = parse_headers(args.header, args.auth_token, args.authorization)
+ headers = parse_headers(args.header)
+ # ctx owns runtime knobs; state owns business data and resumable ids.
ctx = FlowContext(
api_base=api_base,
headers=headers,
@@ -2118,6 +2640,8 @@ def main(argv: list[str] | None = None) -> int:
max_pathways=max(args.max_pathways, 1),
max_vulns=max(args.max_vulns, 1),
max_strategies=max(args.max_strategies, 1),
+ downstream_audiences=max(args.downstream_audiences, 1),
+ downstream_pathways=max(args.downstream_pathways, 1),
sync_step_status=not args.no_step_status,
model_retries=max(args.model_retries, 0),
verify_ssl=bool(args.verify_ssl),
@@ -2131,11 +2655,17 @@ def main(argv: list[str] | None = None) -> int:
state["_summaryPath"] = str(ctx.summary_path) if ctx.summary_path else ""
state["_autoLogin"] = ctx.auto_login
state["_verifySsl"] = ctx.verify_ssl
+ state["_downstreamLimits"] = {
+ "audiences": ctx.downstream_audiences,
+ "pathways": ctx.downstream_pathways,
+ }
record_event(state, "runner", f"start mode={args.mode} dry_run={ctx.dry_run}")
try:
require_context(state, "taskId", "actionId", "mubiao")
- if ctx.auto_login and not has_authorization(ctx.headers):
- try_auto_login(ctx, state, "runner/auto-login")
+ if ctx.auto_login and not ctx.dry_run:
+ # Always acquire a token through the imported auth package before the first business call.
+ if not try_auto_login(ctx, state, "runner/initial-login", force=True):
+ raise FlowError("启动阶段获取 token 失败,已停止执行,避免无 token 调用业务接口")
if args.mode == "full":
run_full(ctx, state)
else:
@@ -2145,13 +2675,14 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(f"ERROR: {exc}", file=sys.stderr)
+ print(f"错误:{exc}", file=sys.stderr)
return 2
except Exception as exc: # noqa: BLE001
record_error(state, "runner", "未捕获异常", response=traceback.format_exc(), exception=exc)
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
+ print("未捕获异常堆栈:", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
return 1
@@ -2159,20 +2690,23 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(json.dumps(
- {
- "ok": True,
- "mode": args.mode,
- "state": str(state_path),
- "summary": str(ctx.summary_path) if ctx.summary_path else "",
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
- "errors": len(state.get("errors") or []),
- },
- ensure_ascii=False,
- ))
+ # Final JSON is intentionally compact; detailed per-step status has already
+ # been emitted as Chinese step feedback.
+ final_payload = {
+ "日志类型": "执行完成",
+ "是否成功": "是",
+ "执行模式": mode_label(args.mode),
+ "状态文件": str(state_path),
+ "摘要文件": str(ctx.summary_path) if ctx.summary_path else "",
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "已选择受众数": len(state.get("selectedDownstreamAudiences") or []),
+ "已选择传播节点数": len(state.get("selectedDownstreamPathways") or []),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
+ "错误数": len(state.get("errors") or []),
+ }
+ print(LOG_PREFIXES["done"] + " " + json.dumps(final_payload, ensure_ascii=False))
return 0
diff --git a/xdfx-auth-index-check-state.json b/xdfx-auth-index-check-state.json
new file mode 100644
index 00000000..98090243
--- /dev/null
+++ b/xdfx-auth-index-check-state.json
@@ -0,0 +1,80 @@
+{
+ "api_base": "https://ch1.b.uat.4.cn",
+ "taskId": "1",
+ "actionId": "2",
+ "mubiao": "索引映射验证",
+ "direction": "U方向",
+ "platform": "",
+ "audienceRawText": "{\"dryRun\": true}",
+ "audienceCandidates": [],
+ "topAudiences": [],
+ "pathwayCandidates": [],
+ "topPathways": [],
+ "audienceVulns": {},
+ "pathwayVulns": {},
+ "audienceStrategies": [],
+ "pathwayStrategies": [],
+ "errors": [],
+ "events": [
+ {
+ "time": "2026-06-29T08:49:10.660975+00:00",
+ "stage": "runner",
+ "message": "start mode=step1 dry_run=True",
+ "data": null
+ },
+ {
+ "time": "2026-06-29T08:49:10.662968+00:00",
+ "stage": "step1-audience/analyze/model-call-attempt-1",
+ "message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_aiuser",
+ "data": {
+ "payload": {
+ "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。",
+ "otherinfo": {
+ "方向": "U方向"
+ },
+ "history": [],
+ "stream": true,
+ "model_name": "OpenAI3",
+ "temperature": 0.7,
+ "max_tokens": 20000,
+ "prompt_name": "任务背景和目的:索引映射验证"
+ }
+ }
+ },
+ {
+ "time": "2026-06-29T08:49:10.668998+00:00",
+ "stage": "step1-audience/extract/model-json-attempt-1/model-call-attempt-1",
+ "message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_qianqian_group_chat",
+ "data": {
+ "payload": {
+ "mubiao": "索引映射验证",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
+ "zhanghao": [],
+ "history": [],
+ "stream": true,
+ "model_name": "OpenAI3",
+ "temperature": 0.7,
+ "max_tokens": 20000,
+ "prompt_name": "索引映射验证"
+ }
+ }
+ },
+ {
+ "time": "2026-06-29T08:49:10.668998+00:00",
+ "stage": "step1-audience",
+ "message": "完成,保存受众 0 个",
+ "data": null
+ }
+ ],
+ "startedAt": "2026-06-29T08:49:10.659972+00:00",
+ "updatedAt": "2026-06-29T08:49:10.674982+00:00",
+ "_statePath": "C:\\tmp\\xdfx-auth-index-check-state.json",
+ "_summaryPath": "C:\\tmp\\xdfx-auth-index-check-summary.md",
+ "_autoLogin": true,
+ "_verifySsl": false,
+ "_downstreamLimits": {
+ "audiences": 2,
+ "pathways": 2
+ },
+ "audienceAccounts": []
+}
\ No newline at end of file
diff --git a/xdfx-auth-index-check-summary.md b/xdfx-auth-index-check-summary.md
new file mode 100644
index 00000000..85b459cb
--- /dev/null
+++ b/xdfx-auth-index-check-summary.md
@@ -0,0 +1,18 @@
+# xdfx 分析入库执行摘要
+
+- 更新时间:2026-06-29T08:49:10.674982+00:00
+- taskId:1
+- actionId:2
+- 受众入库:0
+- 传播节点入库:0
+- 下游受众选择:0
+- 下游传播节点选择:0
+- 受众脆弱点入库:0
+- 传播节点脆弱点入库:0
+- 受众策略入库:0
+- 传播节点策略入库:0
+- 错误数:0
+
+## 受众
+
+## 传播节点
diff --git a/xdfx-log-check-state.json b/xdfx-log-check-state.json
new file mode 100644
index 00000000..1ad0e726
--- /dev/null
+++ b/xdfx-log-check-state.json
@@ -0,0 +1,88 @@
+{
+ "api_base": "https://ch1.b.uat.4.cn",
+ "taskId": "1",
+ "actionId": "2",
+ "mubiao": "日志中文化验证",
+ "direction": "U方向",
+ "platform": "",
+ "audienceRawText": "{\"dryRun\": true}",
+ "audienceCandidates": [],
+ "topAudiences": [],
+ "pathwayCandidates": [],
+ "topPathways": [],
+ "audienceVulns": {},
+ "pathwayVulns": {},
+ "audienceStrategies": [],
+ "pathwayStrategies": [],
+ "errors": [],
+ "events": [
+ {
+ "time": "2026-06-29T07:16:23.252171+00:00",
+ "stage": "runner",
+ "message": "start mode=step1 dry_run=True",
+ "data": null
+ },
+ {
+ "time": "2026-06-29T07:16:23.252171+00:00",
+ "stage": "runner/auto-login",
+ "message": "dry-run 跳过自动登录",
+ "data": {
+ "username": "admin"
+ }
+ },
+ {
+ "time": "2026-06-29T07:16:23.253180+00:00",
+ "stage": "step1-audience/analyze/model-call-attempt-1",
+ "message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_aiuser",
+ "data": {
+ "payload": {
+ "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。",
+ "otherinfo": {
+ "方向": "U方向"
+ },
+ "history": [],
+ "stream": true,
+ "model_name": "OpenAI3",
+ "temperature": 0.7,
+ "max_tokens": 20000,
+ "prompt_name": "任务背景和目的:日志中文化验证"
+ }
+ }
+ },
+ {
+ "time": "2026-06-29T07:16:23.261131+00:00",
+ "stage": "step1-audience/extract/model-json-attempt-1/model-call-attempt-1",
+ "message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_qianqian_group_chat",
+ "data": {
+ "payload": {
+ "mubiao": "日志中文化验证",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "zhanghao": [],
+ "history": [],
+ "stream": true,
+ "model_name": "OpenAI3",
+ "temperature": 0.7,
+ "max_tokens": 20000,
+ "prompt_name": "日志中文化验证"
+ }
+ }
+ },
+ {
+ "time": "2026-06-29T07:16:23.261131+00:00",
+ "stage": "step1-audience",
+ "message": "完成,保存受众 0 个",
+ "data": null
+ }
+ ],
+ "startedAt": "2026-06-29T07:16:23.252171+00:00",
+ "updatedAt": "2026-06-29T07:16:23.266115+00:00",
+ "_statePath": "C:\\tmp\\xdfx-log-check-state.json",
+ "_summaryPath": "C:\\tmp\\xdfx-log-check-summary.md",
+ "_autoLogin": true,
+ "_verifySsl": false,
+ "_downstreamLimits": {
+ "audiences": 2,
+ "pathways": 2
+ },
+ "audienceAccounts": []
+}
\ No newline at end of file
diff --git a/xdfx-log-check-summary.md b/xdfx-log-check-summary.md
new file mode 100644
index 00000000..add40f51
--- /dev/null
+++ b/xdfx-log-check-summary.md
@@ -0,0 +1,18 @@
+# xdfx 分析入库执行摘要
+
+- 更新时间:2026-06-29T07:16:23.266115+00:00
+- taskId:1
+- actionId:2
+- 受众入库:0
+- 传播节点入库:0
+- 下游受众选择:0
+- 下游传播节点选择:0
+- 受众脆弱点入库:0
+- 传播节点脆弱点入库:0
+- 受众策略入库:0
+- 传播节点策略入库:0
+- 错误数:0
+
+## 受众
+
+## 传播节点
diff --git a/xdfx-runner-dry-state.json b/xdfx-runner-dry-state.json
new file mode 100644
index 00000000..127027a9
--- /dev/null
+++ b/xdfx-runner-dry-state.json
@@ -0,0 +1,88 @@
+{
+ "api_base": "https://ch1.b.uat.4.cn",
+ "taskId": "1",
+ "actionId": "2",
+ "mubiao": "dry-run-test",
+ "direction": "U方向",
+ "platform": "fb",
+ "audienceRawText": "{\"dryRun\": true}",
+ "audienceCandidates": [],
+ "topAudiences": [],
+ "pathwayCandidates": [],
+ "topPathways": [],
+ "audienceVulns": {},
+ "pathwayVulns": {},
+ "audienceStrategies": [],
+ "pathwayStrategies": [],
+ "errors": [],
+ "events": [
+ {
+ "time": "2026-06-29T03:43:58.007253+00:00",
+ "stage": "runner",
+ "message": "start mode=step1 dry_run=True",
+ "data": null
+ },
+ {
+ "time": "2026-06-29T03:43:58.007253+00:00",
+ "stage": "runner/auto-login",
+ "message": "dry-run 跳过自动登录",
+ "data": {
+ "username": "admin"
+ }
+ },
+ {
+ "time": "2026-06-29T03:43:58.007253+00:00",
+ "stage": "step1-audience/analyze/model-call-attempt-1",
+ "message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_aiuser",
+ "data": {
+ "payload": {
+ "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。",
+ "otherinfo": {
+ "方向": "U方向"
+ },
+ "history": [],
+ "stream": true,
+ "model_name": "OpenAI3",
+ "temperature": 0.7,
+ "max_tokens": 20000,
+ "prompt_name": "任务背景和目的:dry-run-test"
+ }
+ }
+ },
+ {
+ "time": "2026-06-29T03:43:58.017225+00:00",
+ "stage": "step1-audience/extract/model-json-attempt-1/model-call-attempt-1",
+ "message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_qianqian_group_chat",
+ "data": {
+ "payload": {
+ "mubiao": "dry-run-test",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "zhanghao": [],
+ "history": [],
+ "stream": true,
+ "model_name": "OpenAI3",
+ "temperature": 0.7,
+ "max_tokens": 20000,
+ "prompt_name": "dry-run-test"
+ }
+ }
+ },
+ {
+ "time": "2026-06-29T03:43:58.018223+00:00",
+ "stage": "step1-audience",
+ "message": "完成,保存受众 0 个",
+ "data": null
+ }
+ ],
+ "startedAt": "2026-06-29T03:43:58.006257+00:00",
+ "updatedAt": "2026-06-29T03:43:58.021216+00:00",
+ "_statePath": "C:\\tmp\\xdfx-runner-dry-state.json",
+ "_summaryPath": "C:\\tmp\\xdfx-runner-dry-summary.md",
+ "_autoLogin": true,
+ "_verifySsl": false,
+ "_downstreamLimits": {
+ "audiences": 2,
+ "pathways": 2
+ },
+ "audienceAccounts": []
+}
\ No newline at end of file
diff --git a/xdfx-runner-dry-summary.md b/xdfx-runner-dry-summary.md
new file mode 100644
index 00000000..2ac9168f
--- /dev/null
+++ b/xdfx-runner-dry-summary.md
@@ -0,0 +1,18 @@
+# xdfx 分析入库执行摘要
+
+- 更新时间:2026-06-29T03:43:58.021216+00:00
+- taskId:1
+- actionId:2
+- 受众入库:0
+- 传播节点入库:0
+- 下游受众选择:0
+- 下游传播节点选择:0
+- 受众脆弱点入库:0
+- 传播节点脆弱点入库:0
+- 受众策略入库:0
+- 传播节点策略入库:0
+- 错误数:0
+
+## 受众
+
+## 传播节点