From 2215ec3c4914a7503295771e125ee91d465666cd Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Wed, 1 Jul 2026 19:21:18 +0800 Subject: [PATCH 1/3] feat: add user-assisted xdfx skill flow --- skills-data/xdfx-full-flow-skill-guide.md | 19 +- .../xdfx-single-agent-full-flow/SKILL.md | 15 + .../scripts/xdfx_flow.py | 615 +++++++++++++++--- .../scripts/xdfx_run_full_steps.py | 16 +- skills-data/xdfx-split-controller/SKILL.md | 14 + .../scripts/xdfx_flow.py | 615 +++++++++++++++--- .../scripts/xdfx_run_full_steps.py | 16 +- skills-data/xdfx-split-flow-skill-guide.md | 25 +- skills-data/xdfx-step1-audience/SKILL.md | 6 +- .../xdfx-step1-audience/scripts/xdfx_flow.py | 615 +++++++++++++++--- skills-data/xdfx-step2-pathway/SKILL.md | 6 +- .../xdfx-step2-pathway/scripts/xdfx_flow.py | 615 +++++++++++++++--- .../xdfx-step3-audience-vulns/SKILL.md | 10 +- .../scripts/xdfx_flow.py | 615 +++++++++++++++--- skills-data/xdfx-step4-pathway-vulns/SKILL.md | 8 +- .../scripts/xdfx_flow.py | 615 +++++++++++++++--- .../xdfx-step5-audience-strategy/SKILL.md | 11 +- .../scripts/xdfx_flow.py | 615 +++++++++++++++--- .../xdfx-step6-pathway-strategy/SKILL.md | 9 +- .../scripts/xdfx_flow.py | 615 +++++++++++++++--- 20 files changed, 4314 insertions(+), 761 deletions(-) diff --git a/skills-data/xdfx-full-flow-skill-guide.md b/skills-data/xdfx-full-flow-skill-guide.md index ec7ed043..024233fe 100644 --- a/skills-data/xdfx-full-flow-skill-guide.md +++ b/skills-data/xdfx-full-flow-skill-guide.md @@ -4,12 +4,12 @@ 使用 `xdfx-single-agent-full-flow` 技能,让单智能体一次性完成 xdfx 深链接模式下的完整分析与入库流程: -1. 受众目标分析,选出最优 3 个受众并入库。 -2. 关键传播节点分析,跨受众去重后选出最优 3 个节点并入库。 -3. 从已入库受众中默认选前 2 个继续,每个生成最多 3 个脆弱点并入库。 -4. 从已入库关键传播节点中默认选前 2 个继续,每个生成最多 3 个脆弱点并入库。 -5. 对上述选中的每个受众默认生成最多 3 个策略并入库。 -6. 对上述选中的每个关键传播节点默认生成最多 3 个策略并入库。 +1. 受众目标分析,输出 `用户协助` 让用户选择受众,入库选中的受众。 +2. 关键传播节点分析,跨受众去重后输出 `用户协助` 让用户选择节点,入库选中的节点。 +3. 从已关联关键传播节点的受众中生成脆弱点候选,输出 `用户协助` 让用户选择后入库。 +4. 从已入库关键传播节点中生成脆弱点候选,输出 `用户协助` 让用户选择后入库。 +5. 让用户选择一个或多个受众生成策略并入库。 +6. 让用户选择一个或多个关键传播节点生成策略并入库;受众和节点不能在同一步交叉。 7. 同步前端步骤状态,保证原 `item-aiceh` 页面步骤条可继续识别。 ## 技能位置 @@ -161,9 +161,10 @@ xdfx-flow-summary.md ## 每步反馈 -脚本会输出五类反馈行: +脚本会输出六类反馈行: - `操作反馈`:当前正在做什么、调用哪个接口。 +- `用户协助`:当前步骤需要用户选择候选;智能体必须暂停后续步骤,展示选项,等待用户选择后重跑同一个 step。 - `入库反馈`:当前已入库的对象类型、名称和返回 ID。 - `错误反馈`:当前错误的阶段、接口、状态码、请求摘要和响应摘要。 - `JSON解析复核`:模型返回内容无法稳定解析为 JSON,需要执行智能体复核是否重试。 @@ -173,7 +174,7 @@ xdfx-flow-summary.md - `步骤`:步骤编号。 - `步骤名称`:步骤名称。 -- `状态`:`成功`、`部分完成` 或 `已跳过`。 +- `状态`:`成功`、`部分完成`、`等待用户选择` 或 `已跳过`。 - `执行状态`:中文执行状态。 - `累计入库数量`:当前累计入库数量。 - `本步骤新增错误数`:本步骤新增错误数。 @@ -194,6 +195,8 @@ Step1 受众目标分析完成:当前已入库受众 X 个,本步骤新增 - 单个受众、账号、传播节点、脆弱点或策略失败,不中断整个流程。 - 保存接口必须返回 `id`,否则不会进入下游步骤。 - 受众目标默认入库 3 个,关键传播节点默认入库 3 个。 +- 默认开启用户协助选择:Step1 选择受众;Step2 选择关键传播节点;Step3/Step4 选择要入库的脆弱点;Step5 只能选择多个受众生成策略;Step6 只能选择多个关键传播节点生成策略。受众和关键传播节点不能在同一步交叉生成策略。 +- 用户选择后重跑当前 step:`--select-audiences`、`--select-pathways`、`--select-audience-vulns`、`--select-pathway-vulns`、`--select-strategy-audiences`、`--select-strategy-pathways`。 - 下游受众脆弱点/策略只能从“已在 Step2 分析出并关联关键传播节点”的受众中选择,默认取前 2 个;没有产生传播节点的受众不得继续分析脆弱点和策略。 - 下游传播节点脆弱点/策略默认只选择前 2 个已入库传播节点继续,可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。 - 每个被选中的对象默认最多生成 3 个脆弱点、3 个策略。 diff --git a/skills-data/xdfx-single-agent-full-flow/SKILL.md b/skills-data/xdfx-single-agent-full-flow/SKILL.md index b0a45773..4f8151ce 100644 --- a/skills-data/xdfx-single-agent-full-flow/SKILL.md +++ b/skills-data/xdfx-single-agent-full-flow/SKILL.md @@ -60,6 +60,19 @@ python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow- 每一步完成后都会输出一行 `步骤反馈 {...}`。智能体必须在每个 step 命令结束后给用户一次简短反馈,说明该步骤状态、已入库数量和新增错误数,然后再执行下一步。 +每一步如果生成了可选择候选,会先输出 `用户协助 {...}` 并把 `步骤反馈.状态` 置为 `等待用户选择`。智能体必须立刻暂停后续步骤,把 `用户协助.选项` 发给用户选择;用户选完后,重跑同一个 step 并带对应参数继续: + +```bash +python scripts/xdfx_flow.py --mode step1 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md --select-audiences 1,2,3 +python scripts/xdfx_flow.py --mode step2 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md --select-pathways 1,2 +python scripts/xdfx_flow.py --mode step3 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md --select-audience-vulns 1,2,4 +python scripts/xdfx_flow.py --mode step4 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md --select-pathway-vulns 1,3 +python scripts/xdfx_flow.py --mode step5 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md --select-strategy-audiences 1,2 +python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md --select-strategy-pathways 1,2 +``` + +Step5 只能选择受众生成策略;Step6 只能选择关键传播节点生成策略。不要在同一步里同时传 `--select-strategy-audiences` 和 `--select-strategy-pathways`,受众和关键传播节点不能交叉生成策略。 + 脚本每次调用接口前还会输出 `操作反馈 {...}`,包含当前操作、HTTP 方法和 URL。智能体必须用简短中文告诉用户“正在做什么、调用哪个接口”。 每次保存接口成功拿到返回 ID 后会输出 `入库反馈 {...}`,包含 `入库对象类型`、`入库对象名称`、`返回ID` 和 `接口路径`。智能体必须把它转述成“已入库 xxx 受众 / xxx 脆弱点 / xxx 策略”等明确日志。 @@ -86,6 +99,7 @@ python scripts/xdfx_flow.py --mode full --input xdfx-input.json --dry-run - 受众目标默认入库最优 3 个,可用 `--max-audiences` 调整。 - 关键传播节点全局去重后默认入库最优 3 个,可用 `--max-pathways` 调整。 +- 默认开启用户协助选择:受众、关键传播节点、受众脆弱点、传播节点脆弱点、策略生成对象都必须由用户选择后才继续。只有排障或无人值守时才追加 `--no-user-assist` 走自动排序。 - 下游受众脆弱点/策略只能从“已在 Step2 分析出并关联关键传播节点”的受众中选择,默认取前 2 个;没有产生传播节点的受众不得继续分析脆弱点和策略。 - 下游传播节点脆弱点/策略默认从已入库传播节点中选择前 2 个继续;可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。 - 每个被选中的受众/传播节点默认生成最多 3 个脆弱点,可用 `--max-vulns` 调整。 @@ -102,6 +116,7 @@ python scripts/xdfx_flow.py --mode full --input xdfx-input.json --dry-run - 模型 JSON 解析失败会自动重试 1 次。 - 单个受众、账号、节点或策略失败会被脚本记录;智能体仍必须按“失败重试规则”先重跑当前步骤,不能直接跳到下一步。 - 每完成一步都会输出 `步骤反馈`,包含 `步骤`、`步骤名称`、`状态`、`累计入库数量` 和 `本步骤新增错误数`。 +- 每次需要用户选择都会输出 `用户协助`,智能体必须暂停流程,等待用户选择后重跑当前 step。 - 每次接口调用前都会输出 `操作反馈`,说明当前操作和接口 URL。 - 每次保存成功都会输出 `入库反馈`,说明已入库对象类型、名称和返回 ID。 - 每个错误都会立即输出 `错误反馈`,说明失败阶段、接口、状态码、请求摘要和响应摘要。 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 8e62e51e..d385f95b 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 @@ -28,6 +28,10 @@ from typing import Any import xdfx_auth +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") # Keep every backend path in one table so endpoint drift is easy to audit. ENDPOINTS = { @@ -76,6 +80,7 @@ LOG_PREFIXES = { "operation": "操作反馈", "error": "错误反馈", "json_review": "JSON解析复核", + "assist": "用户协助", "save": "入库反馈", "step": "步骤反馈", "done": "执行完成", @@ -85,6 +90,7 @@ STATUS_LABELS = { "ok": "成功", "partial": "部分完成", "skipped": "已跳过", + "waiting_user": "等待用户选择", } STEP_NUMBER_LABELS = { @@ -226,6 +232,14 @@ class FlowContext: max_strategies: int = 3 downstream_audiences: int = 2 downstream_pathways: int = 2 + require_user_assist: bool = True + select_audience_refs: list[str] | None = None + select_pathway_refs: list[str] | None = None + select_audience_vuln_refs: list[str] | None = None + select_pathway_vuln_refs: list[str] | None = None + select_strategy_audience_refs: list[str] | None = None + select_strategy_pathway_refs: list[str] | None = None + strategy_target: str = "" sync_step_status: bool = True model_retries: int = 1 verify_ssl: bool = False @@ -284,6 +298,129 @@ def safe_compact(value: Any, limit: int = 1400) -> str: return compact(redact_sensitive(value), limit) +def split_selection_refs(raw: Any) -> list[str]: + """Parse user choices like '1,3' or ['1', '3'] into stable strings.""" + if raw is None: + return [] + if isinstance(raw, (list, tuple, set)): + parts: list[str] = [] + for item in raw: + parts.extend(split_selection_refs(item)) + return parts + text = str(raw).strip() + if not text: + return [] + if text.lower() in {"all", "全部"}: + return ["all"] + return [part.strip() for part in re.split(r"[,,;;\s]+", text) if part.strip()] + + +def normalized_ref(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")).lower() + + +def has_selection(refs: list[str] | None) -> bool: + return bool([ref for ref in refs or [] if str(ref).strip()]) + + +def clear_pending_assist(state: dict[str, Any]) -> None: + state.pop("_pendingUserAssist", None) + + +def has_pending_assist(state: dict[str, Any]) -> bool: + return isinstance(state.get("_pendingUserAssist"), dict) + + +def user_assist_command_hint(state: dict[str, Any], mode: str, flag_name: str, example: str) -> str: + command = f"python scripts/xdfx_flow.py --mode {mode} --state {state.get('_statePath') or 'xdfx-flow-state.json'}" + if state.get("_summaryPath"): + command += f" --summary {state.get('_summaryPath')}" + return f"{command} --{flag_name} {example}" + + +def emit_user_assist( + state: dict[str, Any], + stage: str, + *, + selection_key: str, + title: str, + prompt: str, + options: list[dict[str, Any]], + command_hint: str, + allow_multiple: bool = True, + max_count: int | None = None, + exclusive_note: str = "", +) -> None: + payload = { + "日志类型": "用户协助", + "阶段": stage_label(stage), + "步骤": feedback_step_label(stage), + "选择类型": selection_key, + "标题": title, + "提示": prompt, + "是否允许多选": yes_no(allow_multiple), + "最多选择数量": max_count, + "互斥规则": exclusive_note, + "选项": options, + "继续命令示例": command_hint, + "状态文件": state.get("_statePath", ""), + "摘要文件": state.get("_summaryPath", ""), + "时间": utc_now(), + } + state["_pendingUserAssist"] = { + "selectionKey": selection_key, + "stage": stage, + "title": title, + "commandHint": command_hint, + "optionCount": len(options), + "createdAt": payload["时间"], + } + print(LOG_PREFIXES["assist"] + " " + json.dumps(payload, ensure_ascii=False), flush=True) + record_event(state, stage, "等待用户协助选择", {"selectionKey": selection_key, "optionCount": len(options)}) + + +def ref_matches(ref: str, ordinal: int, aliases: list[Any]) -> bool: + normalized = normalized_ref(ref) + if normalized == "all": + return True + if normalized == str(ordinal): + return True + return any(normalized and normalized == normalized_ref(alias) for alias in aliases if alias is not None) + + +def select_by_refs( + items: list[Any], + refs: list[str], + alias_fn, + *, + max_count: int | None = None, +) -> tuple[list[Any], list[str]]: + if not refs: + selected = list(items) + elif any(normalized_ref(ref) in {"all", "全部"} for ref in refs): + selected = list(items) + else: + selected = [] + missing: list[str] = [] + for ref in refs: + found = None + for index, item in enumerate(items, start=1): + if ref_matches(ref, index, alias_fn(item, index)): + found = item + break + if found is None: + missing.append(ref) + continue + if found not in selected: + selected.append(found) + if max_count is not None: + return selected[:max_count], missing + return selected, missing + if max_count is not None: + selected = selected[:max_count] + return selected, [] + + def stable_json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) @@ -336,7 +473,7 @@ def load_json_file(path: str | Path) -> dict[str, Any]: p = Path(path) if not p.exists(): return {} - return json.loads(p.read_text(encoding="utf-8")) + return json.loads(p.read_text(encoding="utf-8-sig")) def save_json_file(path: str | Path, value: Any) -> None: @@ -369,6 +506,8 @@ def default_state() -> dict[str, Any]: "pathwayCandidates": [], "topPathways": [], "audiencesWithPathways": [], + "audienceVulnCandidates": {}, + "pathwayVulnCandidates": {}, "audienceVulns": {}, "pathwayVulns": {}, "audienceStrategies": [], @@ -2182,6 +2321,121 @@ def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, aud return selected +def audience_option(item: dict[str, Any], index: int) -> dict[str, Any]: + return { + "序号": index, + "受众目标": item.get("targetAudience"), + "推荐理由": compact(item.get("recReason", ""), 300), + "账号引用": item.get("userIndexes") or [], + } + + +def audience_aliases(item: dict[str, Any], index: int) -> list[Any]: + return [index, item.get("targetAudience"), item.get("rank")] + + +def pathway_option(item: dict[str, Any], index: int) -> dict[str, Any]: + return { + "序号": index, + "账号名称": item.get("accountName") or account_name(item.get("account") or {}), + "账号ID": item.get("accountId") or account_id(item.get("account") or {}), + "关联受众": item.get("relatedAudiences") or [], + "推荐理由": compact(item.get("recReason", ""), 300), + } + + +def pathway_aliases(item: dict[str, Any], index: int) -> list[Any]: + account = item.get("account") or item + return [ + index, + item.get("pathwayAccountId"), + item.get("pathwayId"), + item.get("accountId"), + item.get("accountName"), + account_id(account), + account_name(account), + ] + + +def vuln_candidate_entries( + items: list[dict[str, Any]], + candidate_store: dict[str, Any], + *, + audience: bool, +) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + seq = 1 + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + owner_name = item.get("targetAudience") if audience else item.get("accountName") + for index, vuln in enumerate(candidate_store.get(str(owner_id)) or [], start=1): + if not isinstance(vuln, dict): + continue + entries.append( + { + "seq": seq, + "ownerId": owner_id, + "ownerName": owner_name, + "localIndex": index, + "vuln": vuln, + } + ) + seq += 1 + return entries + + +def vuln_option(entry: dict[str, Any]) -> dict[str, Any]: + vuln = entry.get("vuln") or {} + return { + "序号": entry.get("seq"), + "对象ID": entry.get("ownerId"), + "对象名称": entry.get("ownerName"), + "本对象内序号": entry.get("localIndex"), + "脆弱点": vuln.get("vulnerabilities"), + "理由": compact(vuln.get("reason", ""), 260), + } + + +def vuln_aliases(entry: dict[str, Any], index: int) -> list[Any]: + vuln = entry.get("vuln") or {} + owner_id = entry.get("ownerId") + return [ + index, + entry.get("seq"), + f"{owner_id}:{entry.get('localIndex')}", + vuln.get("vulnerabilities"), + ] + + +def strategy_target_option(item: dict[str, Any], index: int, *, audience: bool) -> dict[str, Any]: + if audience: + return { + "序号": index, + "类型": "受众", + "对象ID": item.get("designId"), + "对象名称": item.get("targetAudience"), + "已入库脆弱点数量": len((item.get("_vulns") or [])), + "关联传播节点": item.get("downstreamPathways") or [], + } + return { + "序号": index, + "类型": "关键传播节点", + "对象ID": item.get("pathwayAccountId"), + "对象名称": item.get("accountName"), + "已入库脆弱点数量": len((item.get("_vulns") or [])), + "关联受众": item.get("relatedAudiences") or [], + } + + +def strategy_target_aliases(item: dict[str, Any], index: int, *, audience: bool) -> list[Any]: + return [ + index, + item.get("designId") if audience else item.get("pathwayAccountId"), + item.get("targetAudience") if audience else item.get("accountName"), + item.get("accountId") if not audience else None, + ] + + 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") @@ -2189,39 +2443,65 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None: if state.get("topAudiences") and not ctx.dry_run: record_event(state, stage, "topAudiences 已存在,跳过。如需重跑请清空 state 中该字段。") return - payload = { - "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。", - "otherinfo": {"方向": state.get("direction") or "U方向"}, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": f"任务背景和目的:{state['mubiao']}", - } - 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 + candidates = [item for item in state.get("audienceCandidates") or [] if isinstance(item, dict)] + accounts = [item for item in state.get("audienceAccounts") or [] if isinstance(item, dict)] + if not candidates: + payload = { + "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。", + "otherinfo": {"方向": state.get("direction") or "U方向"}, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": f"任务背景和目的:{state['mubiao']}", + } + 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。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。", - "zhanghao": accounts, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": state["mubiao"], - } - data, _, _ = call_model_json(ctx, state, "group", group_payload, stage + "/extract") - candidates = extract_audiences(data) - state["audienceCandidates"] = candidates - top = select_top_audiences(candidates, ctx.max_audiences) + # 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。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。", + "zhanghao": accounts, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": state["mubiao"], + } + data, _, _ = call_model_json(ctx, state, "group", group_payload, stage + "/extract") + candidates = extract_audiences(data) + state["audienceCandidates"] = candidates + if not candidates: + record_error(state, stage, "没有生成可供选择的受众候选") + return + if ctx.require_user_assist and not has_selection(ctx.select_audience_refs): + emit_user_assist( + state, + stage, + selection_key="audience", + title="请选择要入库并继续分析的受众", + prompt=f"可多选,最多建议选择 {ctx.max_audiences} 个。回复序号即可,例如:1,2,3。", + options=[audience_option(item, index) for index, item in enumerate(candidates, start=1)], + command_hint=user_assist_command_hint(state, "step1", "select-audiences", "1,2,3"), + allow_multiple=True, + max_count=ctx.max_audiences, + ) + return + selected_refs = ctx.select_audience_refs or [] + top, missing = select_by_refs(candidates, selected_refs, audience_aliases, max_count=ctx.max_audiences) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的受众未匹配:{', '.join(missing)}") + if not top: + record_error(state, stage, "没有选中任何受众,无法继续") + return saved: list[dict[str, Any]] = [] for item in top: # Save the audience first; account rows and downstream analyses depend @@ -2282,60 +2562,85 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: state["audiencesWithPathways"] = audience_pathway_summary(state) record_event(state, stage, "topPathways 已存在,跳过。如需重跑请清空 state 中该字段。") return - all_candidates: list[dict[str, Any]] = [] - for audience in audiences: - # Analyze pathway candidates in the context of each saved audience. - payload = { - "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。", - "otherinfo": { - "方向": state.get("direction") or "U方向", - "任务": "传播", - "平台": state.get("platform") or "fb", - "受众": {"名称": audience.get("targetAudience"), "理由": audience.get("recReason")}, - }, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": f"任务背景和目的:{state['mubiao']}", - } - 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。", - "zhanghao": accounts, - "history": [], - "stream": True, - "model_name": "liyou", - "temperature": 0.7, - "max_tokens": 1000, - "prompt_name": f"{audience.get('targetAudience')}:{audience.get('recReason')};", - } - data, _, _ = call_model_json(ctx, state, "spread", spread_payload, stage + "/extract") - refs = extract_pathway_refs(data) - for ref_item in refs: - account = resolve_account_ref(ref_item.get("ref"), accounts) - if account is None: - record_error(state, stage, f"传播节点引用 {ref_item.get('ref')} 未匹配账号", response=data) - continue - all_candidates.append( - { - "account": account, - "accountId": account_id(account), - "accountName": account_name(account), - "recReason": ref_item.get("recReason", ""), - "rank": ref_item.get("rank"), - "designIds": [audience["designId"]], - "relatedAudiences": [audience.get("targetAudience", "")], - } - ) - merged = merge_pathway_candidates(all_candidates) - state["pathwayCandidates"] = merged - top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways] + ranked = [item for item in state.get("pathwayCandidates") or [] if isinstance(item, dict)] + if not ranked: + all_candidates: list[dict[str, Any]] = [] + for audience in audiences: + # Analyze pathway candidates in the context of each saved audience. + payload = { + "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。", + "otherinfo": { + "方向": state.get("direction") or "U方向", + "任务": "传播", + "平台": state.get("platform") or "fb", + "受众": {"名称": audience.get("targetAudience"), "理由": audience.get("recReason")}, + }, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": f"任务背景和目的:{state['mubiao']}", + } + 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。", + "zhanghao": accounts, + "history": [], + "stream": True, + "model_name": "liyou", + "temperature": 0.7, + "max_tokens": 1000, + "prompt_name": f"{audience.get('targetAudience')}:{audience.get('recReason')};", + } + data, _, _ = call_model_json(ctx, state, "spread", spread_payload, stage + "/extract") + refs = extract_pathway_refs(data) + for ref_item in refs: + account = resolve_account_ref(ref_item.get("ref"), accounts) + if account is None: + record_error(state, stage, f"传播节点引用 {ref_item.get('ref')} 未匹配账号", response=data) + continue + all_candidates.append( + { + "account": account, + "accountId": account_id(account), + "accountName": account_name(account), + "recReason": ref_item.get("recReason", ""), + "rank": ref_item.get("rank"), + "designIds": [audience["designId"]], + "relatedAudiences": [audience.get("targetAudience", "")], + } + ) + merged = merge_pathway_candidates(all_candidates) + ranked = sorted(merged, key=pathway_score, reverse=True) + state["pathwayCandidates"] = ranked + if not ranked: + record_error(state, stage, "没有生成可供选择的关键传播节点候选") + return + if ctx.require_user_assist and not has_selection(ctx.select_pathway_refs): + emit_user_assist( + state, + stage, + selection_key="pathway", + title="请选择要入库并继续分析的关键传播节点", + prompt=f"可多选,最多建议选择 {ctx.max_pathways} 个。回复序号、账号名称或账号ID均可,例如:1,2,3。", + options=[pathway_option(item, index) for index, item in enumerate(ranked, start=1)], + command_hint=user_assist_command_hint(state, "step2", "select-pathways", "1,2,3"), + allow_multiple=True, + max_count=ctx.max_pathways, + ) + return + top, missing = select_by_refs(ranked, ctx.select_pathway_refs or [], pathway_aliases, max_count=ctx.max_pathways) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的关键传播节点未匹配:{', '.join(missing)}") + if not top: + record_error(state, stage, "没有选中任何关键传播节点,无法继续") + return saved: list[dict[str, Any]] = [] for item in top: # Save the account row before writing the design-pathway relation. @@ -2395,7 +2700,10 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> stage = "step3-audience-vulns" if audience else "step4-pathway-vulns" items = downstream_items(ctx, state, stage, audience=audience) output_key = "audienceVulns" if audience else "pathwayVulns" + candidate_key = "audienceVulnCandidates" if audience else "pathwayVulnCandidates" + select_refs = ctx.select_audience_vuln_refs if audience else ctx.select_pathway_vuln_refs state.setdefault(output_key, {}) + candidate_store = state.setdefault(candidate_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") @@ -2403,6 +2711,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> continue if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run: continue + if str(owner_id) in candidate_store and candidate_store[str(owner_id)]: + continue zhanghao = selected_accounts_for_item(item, audience=audience) prompt = ( "你是中国对台湾方向的认知战专家,你要传播行动要求的叙事主题。请基于该受众目标的画像、关注议题、社会分工定位、利益诉求与上一轮账号佐证,分析该受众目标有哪些脆弱点可以成为后续策略设计的关键要素。请输出理由、脆弱点1、理由1、脆弱点2、理由2、脆弱点3、理由3,只返回JSON。" @@ -2422,8 +2732,43 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> } data, _, _ = call_model_json(ctx, state, "cuiruo", payload, stage + "/extract") vulns = extract_vulns(data, ctx.max_vulns) + candidate_store[str(owner_id)] = vulns + entries = vuln_candidate_entries(items, candidate_store, audience=audience) + if not entries: + record_error(state, stage, "没有生成可供选择的脆弱点候选") + return + if ctx.require_user_assist and not has_selection(select_refs): + flag = "select-audience-vulns" if audience else "select-pathway-vulns" + emit_user_assist( + state, + stage, + selection_key="audience_vuln" if audience else "pathway_vuln", + title="请选择要入库的受众脆弱点" if audience else "请选择要入库的传播节点脆弱点", + prompt="可多选。回复全局序号即可,例如:1,2,4;也可用 对象ID:本对象内序号。", + options=[vuln_option(entry) for entry in entries], + command_hint=user_assist_command_hint(state, "step3" if audience else "step4", flag, "1,2,4"), + allow_multiple=True, + ) + return + selected_entries, missing = select_by_refs(entries, select_refs or [], vuln_aliases) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的脆弱点未匹配:{', '.join(missing)}") + selected_by_owner: dict[str, list[dict[str, Any]]] = {} + for entry in selected_entries: + owner_key = str(entry.get("ownerId")) + selected_by_owner.setdefault(owner_key, []).append(entry.get("vuln") or {}) + if not selected_by_owner: + record_error(state, stage, "没有选中任何脆弱点,无法继续") + return + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + if not owner_id: + continue + if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run: + continue saved: list[dict[str, Any]] = [] - for vuln in vulns: + for vuln in selected_by_owner.get(str(owner_id), []): # The two weak-point tables have nearly identical payloads but # different foreign-key names. if audience: @@ -2475,8 +2820,53 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool """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" + if audience and (has_selection(ctx.select_strategy_pathway_refs) or ctx.strategy_target == "pathway"): + clear_pending_assist(state) + record_error(state, stage, "策略生成目标类型冲突:Step5 只能选择受众,不能同时选择关键传播节点") + return + if not audience and (has_selection(ctx.select_strategy_audience_refs) or ctx.strategy_target == "audience"): + clear_pending_assist(state) + record_error(state, stage, "策略生成目标类型冲突:Step6 只能选择关键传播节点,不能同时选择受众") + return items = downstream_items(ctx, state, stage, audience=audience) vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {} + selectable_items: list[dict[str, Any]] = [] + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + vulns = vulns_by_owner.get(str(owner_id)) or [] + if vulns: + selectable_items.append({**item, "_vulns": vulns}) + select_refs = ctx.select_strategy_audience_refs if audience else ctx.select_strategy_pathway_refs + if selectable_items and ctx.require_user_assist and not has_selection(select_refs): + flag = "select-strategy-audiences" if audience else "select-strategy-pathways" + emit_user_assist( + state, + stage, + selection_key="strategy_audience" if audience else "strategy_pathway", + title="请选择要生成策略的受众" if audience else "请选择要生成策略的关键传播节点", + prompt=( + "可多选,但本步骤只能选择受众,不能混选关键传播节点。回复序号即可,例如:1,2。" + if audience + else "可多选,但本步骤只能选择关键传播节点,不能混选受众。回复序号即可,例如:1,2。" + ), + options=[strategy_target_option(item, index, audience=audience) for index, item in enumerate(selectable_items, start=1)], + command_hint=user_assist_command_hint(state, "step5" if audience else "step6", flag, "1,2"), + allow_multiple=True, + exclusive_note="受众策略和关键传播节点策略不能在同一步命令中交叉选择。", + ) + return + selected_items, missing = select_by_refs( + selectable_items, + select_refs or [], + lambda item, index: strategy_target_aliases(item, index, audience=audience), + ) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的策略生成对象未匹配:{', '.join(missing)}") + items = selected_items + if not items: + record_error(state, stage, "没有选中任何具备脆弱点的策略生成对象,无法继续") + return 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)} @@ -2583,14 +2973,15 @@ def emit_step_feedback( # 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") + final_status = status or ("waiting_user" if has_pending_assist(state) else ("partial" if new_errors else "ok")) + final_note = note or (state.get("_pendingUserAssist", {}).get("title", "") if has_pending_assist(state) else "") payload = { "日志类型": "步骤反馈", "步骤": STEP_NUMBER_LABELS.get(step, step), "步骤名称": STEP_LABELS.get(step, step), "状态": STATUS_LABELS.get(final_status, final_status), "执行状态": status_label(final_status), - "备注": note, + "备注": final_note, "本步骤新增错误数": new_errors, "累计错误数": total_errors, "累计入库数量": state_counts(state), @@ -2638,15 +3029,27 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None: for dry-runs and unattended local troubleshooting. """ run_step_with_feedback(ctx, state, "step1") + if has_pending_assist(state): + return run_step_with_feedback(ctx, state, "step2") + if has_pending_assist(state): + return run_step_with_feedback(ctx, state, "step3") + if has_pending_assist(state): + return if state.get("topPathways"): run_step_with_feedback(ctx, state, "step4") + if has_pending_assist(state): + return else: skip_step_with_feedback(ctx, state, "step4", "没有已入库关键传播节点") run_step_with_feedback(ctx, state, "step5") + if has_pending_assist(state): + return if state.get("topPathways"): run_step_with_feedback(ctx, state, "step6") + if has_pending_assist(state): + return else: skip_step_with_feedback(ctx, state, "step6", "没有已入库关键传播节点") @@ -2722,6 +3125,14 @@ def build_arg_parser() -> argparse.ArgumentParser: 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。") + parser.add_argument("--no-user-assist", action="store_true", help="关闭每步用户协助选择,使用脚本默认排序自动继续。") + parser.add_argument("--select-audiences", default="", help="Step1 用户选择的受众序号/名称,逗号分隔,例如 1,2,3。") + parser.add_argument("--select-pathways", default="", help="Step2 用户选择的关键传播节点序号/账号名/账号ID,逗号分隔。") + parser.add_argument("--select-audience-vulns", default="", help="Step3 用户选择的受众脆弱点序号,逗号分隔。") + parser.add_argument("--select-pathway-vulns", default="", help="Step4 用户选择的传播节点脆弱点序号,逗号分隔。") + parser.add_argument("--strategy-target", choices=["", "audience", "pathway"], default="", help="策略生成目标类型;Step5 只能 audience,Step6 只能 pathway。") + parser.add_argument("--select-strategy-audiences", default="", help="Step5 用户选择的受众策略生成对象序号/受众ID/名称,逗号分隔。") + parser.add_argument("--select-strategy-pathways", default="", help="Step6 用户选择的传播节点策略生成对象序号/节点ID/账号名,逗号分隔。") return parser @@ -2758,6 +3169,20 @@ def main(argv: list[str] | None = None) -> int: 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 + if config.get("require_user_assist") is False or config.get("user_assist") is False: + args.no_user_assist = True + selection_map = { + "select_audiences": "select_audiences", + "select_pathways": "select_pathways", + "select_audience_vulns": "select_audience_vulns", + "select_pathway_vulns": "select_pathway_vulns", + "select_strategy_audiences": "select_strategy_audiences", + "select_strategy_pathways": "select_strategy_pathways", + "strategy_target": "strategy_target", + } + for config_key, arg_name in selection_map.items(): + if config.get(config_key) is not None: + setattr(args, arg_name, config.get(config_key)) cli_config = { "api_base": args.api_base, @@ -2785,6 +3210,14 @@ def main(argv: list[str] | None = None) -> int: max_strategies=max(args.max_strategies, 1), downstream_audiences=max(args.downstream_audiences, 1), downstream_pathways=max(args.downstream_pathways, 1), + require_user_assist=not args.no_user_assist, + select_audience_refs=split_selection_refs(args.select_audiences), + select_pathway_refs=split_selection_refs(args.select_pathways), + select_audience_vuln_refs=split_selection_refs(args.select_audience_vulns), + select_pathway_vuln_refs=split_selection_refs(args.select_pathway_vulns), + select_strategy_audience_refs=split_selection_refs(args.select_strategy_audiences), + select_strategy_pathway_refs=split_selection_refs(args.select_strategy_pathways), + strategy_target=str(args.strategy_target or ""), sync_step_status=not args.no_step_status, model_retries=max(args.model_retries, 0), verify_ssl=bool(args.verify_ssl), @@ -2838,6 +3271,8 @@ def main(argv: list[str] | None = None) -> int: final_payload = { "日志类型": "执行完成", "是否成功": "是", + "是否等待用户选择": yes_no(has_pending_assist(state)), + "待用户选择": state.get("_pendingUserAssist"), "执行模式": mode_label(args.mode), "状态文件": str(state_path), "摘要文件": str(ctx.summary_path) if ctx.summary_path else "", 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 index d6468b51..2bce3d25 100644 --- 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 @@ -15,6 +15,10 @@ import subprocess import sys from pathlib import Path +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") STEPS = ("step1", "step2", "step3", "step4", "step5", "step6") @@ -53,7 +57,7 @@ def selected_steps(first: str, last: str) -> list[str]: return list(STEPS[start : end + 1]) -def stream_command(step: str, cmd: list[str]) -> int: +def stream_command(step: str, cmd: list[str]) -> tuple[int, bool]: """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() @@ -68,10 +72,13 @@ def stream_command(step: str, cmd: list[str]) -> int: env=env, ) assert process.stdout is not None + waiting_user = False for line in process.stdout: + if line.startswith("用户协助 "): + waiting_user = True sys.stdout.buffer.write(line.encode("utf-8", errors="replace")) sys.stdout.buffer.flush() - return process.wait() + return process.wait(), waiting_user def main(argv: list[str] | None = None) -> int: @@ -83,7 +90,10 @@ def main(argv: list[str] | None = None) -> int: # 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) + code, waiting_user = stream_command(step, cmd) + if waiting_user: + print("已发起用户协助,暂停后续步骤;请根据用户选择重跑当前步骤。", flush=True) + return code if code: exit_code = code if not args.continue_on_error: diff --git a/skills-data/xdfx-split-controller/SKILL.md b/skills-data/xdfx-split-controller/SKILL.md index 71ad1d51..e2af1e50 100644 --- a/skills-data/xdfx-split-controller/SKILL.md +++ b/skills-data/xdfx-split-controller/SKILL.md @@ -45,6 +45,19 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl 每次接口调用前会输出 `操作反馈 {...}`。总控必须告诉用户当前正在做什么、调用哪个接口。 +每一步如果生成了可选择候选,会输出 `用户协助 {...}`。总控必须暂停后续步骤,把 `选项` 发给用户,并在用户选择后重跑当前 step: + +```bash +python scripts/xdfx_flow.py --mode step1 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md --select-audiences 1,2,3 +python scripts/xdfx_flow.py --mode step2 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md --select-pathways 1,2 +python scripts/xdfx_flow.py --mode step3 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md --select-audience-vulns 1,2,4 +python scripts/xdfx_flow.py --mode step4 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md --select-pathway-vulns 1,3 +python scripts/xdfx_flow.py --mode step5 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md --select-strategy-audiences 1,2 +python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md --select-strategy-pathways 1,2 +``` + +Step5 只能选择受众生成策略;Step6 只能选择关键传播节点生成策略。总控不能在同一步混传 `--select-strategy-audiences` 和 `--select-strategy-pathways`。 + 每次保存接口成功拿到返回 ID 后会输出 `入库反馈 {...}`。总控必须告诉用户已入库的对象类型、名称和返回 ID,例如“已入库关键传播节点:xxx,pathwayAccountId=123”。 如果接口或模型处理报错,会立即输出 `错误反馈 {...}`。总控必须立刻把失败阶段、URL、状态码、请求摘要和响应摘要告诉用户。 @@ -77,6 +90,7 @@ python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow- - Step1 的受众目标抽取结果里,`用户编号列表` / `编号id` / `id` 必须对应上一轮 `zhanghao` 账号数组的索引值;脚本会按这个索引依次找到账号并保存受众账号关系。 - Step2 成功后会写入 `topPathways`。 - Step1 默认入库最优 3 个受众;Step2 默认入库最优 3 个传播节点。 +- 默认开启用户协助选择:受众、关键传播节点、受众脆弱点、传播节点脆弱点、策略生成对象都必须由用户选择后才继续。只有排障或无人值守时才追加 `--no-user-assist` 走自动排序。 - Step3/Step5 的受众下游分析只能从“已在 Step2 分析出并关联关键传播节点”的受众中选择,默认取前 2 个;没有产生传播节点的受众不得继续分析脆弱点和策略。 - Step4/Step6 的传播节点下游分析默认从已入库传播节点中选择前 2 个继续;可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。 - Step3-Step6 对上述选中的每个受众/传播节点默认生成最多 3 个脆弱点或策略。 diff --git a/skills-data/xdfx-split-controller/scripts/xdfx_flow.py b/skills-data/xdfx-split-controller/scripts/xdfx_flow.py index 8e62e51e..d385f95b 100644 --- a/skills-data/xdfx-split-controller/scripts/xdfx_flow.py +++ b/skills-data/xdfx-split-controller/scripts/xdfx_flow.py @@ -28,6 +28,10 @@ from typing import Any import xdfx_auth +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") # Keep every backend path in one table so endpoint drift is easy to audit. ENDPOINTS = { @@ -76,6 +80,7 @@ LOG_PREFIXES = { "operation": "操作反馈", "error": "错误反馈", "json_review": "JSON解析复核", + "assist": "用户协助", "save": "入库反馈", "step": "步骤反馈", "done": "执行完成", @@ -85,6 +90,7 @@ STATUS_LABELS = { "ok": "成功", "partial": "部分完成", "skipped": "已跳过", + "waiting_user": "等待用户选择", } STEP_NUMBER_LABELS = { @@ -226,6 +232,14 @@ class FlowContext: max_strategies: int = 3 downstream_audiences: int = 2 downstream_pathways: int = 2 + require_user_assist: bool = True + select_audience_refs: list[str] | None = None + select_pathway_refs: list[str] | None = None + select_audience_vuln_refs: list[str] | None = None + select_pathway_vuln_refs: list[str] | None = None + select_strategy_audience_refs: list[str] | None = None + select_strategy_pathway_refs: list[str] | None = None + strategy_target: str = "" sync_step_status: bool = True model_retries: int = 1 verify_ssl: bool = False @@ -284,6 +298,129 @@ def safe_compact(value: Any, limit: int = 1400) -> str: return compact(redact_sensitive(value), limit) +def split_selection_refs(raw: Any) -> list[str]: + """Parse user choices like '1,3' or ['1', '3'] into stable strings.""" + if raw is None: + return [] + if isinstance(raw, (list, tuple, set)): + parts: list[str] = [] + for item in raw: + parts.extend(split_selection_refs(item)) + return parts + text = str(raw).strip() + if not text: + return [] + if text.lower() in {"all", "全部"}: + return ["all"] + return [part.strip() for part in re.split(r"[,,;;\s]+", text) if part.strip()] + + +def normalized_ref(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")).lower() + + +def has_selection(refs: list[str] | None) -> bool: + return bool([ref for ref in refs or [] if str(ref).strip()]) + + +def clear_pending_assist(state: dict[str, Any]) -> None: + state.pop("_pendingUserAssist", None) + + +def has_pending_assist(state: dict[str, Any]) -> bool: + return isinstance(state.get("_pendingUserAssist"), dict) + + +def user_assist_command_hint(state: dict[str, Any], mode: str, flag_name: str, example: str) -> str: + command = f"python scripts/xdfx_flow.py --mode {mode} --state {state.get('_statePath') or 'xdfx-flow-state.json'}" + if state.get("_summaryPath"): + command += f" --summary {state.get('_summaryPath')}" + return f"{command} --{flag_name} {example}" + + +def emit_user_assist( + state: dict[str, Any], + stage: str, + *, + selection_key: str, + title: str, + prompt: str, + options: list[dict[str, Any]], + command_hint: str, + allow_multiple: bool = True, + max_count: int | None = None, + exclusive_note: str = "", +) -> None: + payload = { + "日志类型": "用户协助", + "阶段": stage_label(stage), + "步骤": feedback_step_label(stage), + "选择类型": selection_key, + "标题": title, + "提示": prompt, + "是否允许多选": yes_no(allow_multiple), + "最多选择数量": max_count, + "互斥规则": exclusive_note, + "选项": options, + "继续命令示例": command_hint, + "状态文件": state.get("_statePath", ""), + "摘要文件": state.get("_summaryPath", ""), + "时间": utc_now(), + } + state["_pendingUserAssist"] = { + "selectionKey": selection_key, + "stage": stage, + "title": title, + "commandHint": command_hint, + "optionCount": len(options), + "createdAt": payload["时间"], + } + print(LOG_PREFIXES["assist"] + " " + json.dumps(payload, ensure_ascii=False), flush=True) + record_event(state, stage, "等待用户协助选择", {"selectionKey": selection_key, "optionCount": len(options)}) + + +def ref_matches(ref: str, ordinal: int, aliases: list[Any]) -> bool: + normalized = normalized_ref(ref) + if normalized == "all": + return True + if normalized == str(ordinal): + return True + return any(normalized and normalized == normalized_ref(alias) for alias in aliases if alias is not None) + + +def select_by_refs( + items: list[Any], + refs: list[str], + alias_fn, + *, + max_count: int | None = None, +) -> tuple[list[Any], list[str]]: + if not refs: + selected = list(items) + elif any(normalized_ref(ref) in {"all", "全部"} for ref in refs): + selected = list(items) + else: + selected = [] + missing: list[str] = [] + for ref in refs: + found = None + for index, item in enumerate(items, start=1): + if ref_matches(ref, index, alias_fn(item, index)): + found = item + break + if found is None: + missing.append(ref) + continue + if found not in selected: + selected.append(found) + if max_count is not None: + return selected[:max_count], missing + return selected, missing + if max_count is not None: + selected = selected[:max_count] + return selected, [] + + def stable_json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) @@ -336,7 +473,7 @@ def load_json_file(path: str | Path) -> dict[str, Any]: p = Path(path) if not p.exists(): return {} - return json.loads(p.read_text(encoding="utf-8")) + return json.loads(p.read_text(encoding="utf-8-sig")) def save_json_file(path: str | Path, value: Any) -> None: @@ -369,6 +506,8 @@ def default_state() -> dict[str, Any]: "pathwayCandidates": [], "topPathways": [], "audiencesWithPathways": [], + "audienceVulnCandidates": {}, + "pathwayVulnCandidates": {}, "audienceVulns": {}, "pathwayVulns": {}, "audienceStrategies": [], @@ -2182,6 +2321,121 @@ def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, aud return selected +def audience_option(item: dict[str, Any], index: int) -> dict[str, Any]: + return { + "序号": index, + "受众目标": item.get("targetAudience"), + "推荐理由": compact(item.get("recReason", ""), 300), + "账号引用": item.get("userIndexes") or [], + } + + +def audience_aliases(item: dict[str, Any], index: int) -> list[Any]: + return [index, item.get("targetAudience"), item.get("rank")] + + +def pathway_option(item: dict[str, Any], index: int) -> dict[str, Any]: + return { + "序号": index, + "账号名称": item.get("accountName") or account_name(item.get("account") or {}), + "账号ID": item.get("accountId") or account_id(item.get("account") or {}), + "关联受众": item.get("relatedAudiences") or [], + "推荐理由": compact(item.get("recReason", ""), 300), + } + + +def pathway_aliases(item: dict[str, Any], index: int) -> list[Any]: + account = item.get("account") or item + return [ + index, + item.get("pathwayAccountId"), + item.get("pathwayId"), + item.get("accountId"), + item.get("accountName"), + account_id(account), + account_name(account), + ] + + +def vuln_candidate_entries( + items: list[dict[str, Any]], + candidate_store: dict[str, Any], + *, + audience: bool, +) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + seq = 1 + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + owner_name = item.get("targetAudience") if audience else item.get("accountName") + for index, vuln in enumerate(candidate_store.get(str(owner_id)) or [], start=1): + if not isinstance(vuln, dict): + continue + entries.append( + { + "seq": seq, + "ownerId": owner_id, + "ownerName": owner_name, + "localIndex": index, + "vuln": vuln, + } + ) + seq += 1 + return entries + + +def vuln_option(entry: dict[str, Any]) -> dict[str, Any]: + vuln = entry.get("vuln") or {} + return { + "序号": entry.get("seq"), + "对象ID": entry.get("ownerId"), + "对象名称": entry.get("ownerName"), + "本对象内序号": entry.get("localIndex"), + "脆弱点": vuln.get("vulnerabilities"), + "理由": compact(vuln.get("reason", ""), 260), + } + + +def vuln_aliases(entry: dict[str, Any], index: int) -> list[Any]: + vuln = entry.get("vuln") or {} + owner_id = entry.get("ownerId") + return [ + index, + entry.get("seq"), + f"{owner_id}:{entry.get('localIndex')}", + vuln.get("vulnerabilities"), + ] + + +def strategy_target_option(item: dict[str, Any], index: int, *, audience: bool) -> dict[str, Any]: + if audience: + return { + "序号": index, + "类型": "受众", + "对象ID": item.get("designId"), + "对象名称": item.get("targetAudience"), + "已入库脆弱点数量": len((item.get("_vulns") or [])), + "关联传播节点": item.get("downstreamPathways") or [], + } + return { + "序号": index, + "类型": "关键传播节点", + "对象ID": item.get("pathwayAccountId"), + "对象名称": item.get("accountName"), + "已入库脆弱点数量": len((item.get("_vulns") or [])), + "关联受众": item.get("relatedAudiences") or [], + } + + +def strategy_target_aliases(item: dict[str, Any], index: int, *, audience: bool) -> list[Any]: + return [ + index, + item.get("designId") if audience else item.get("pathwayAccountId"), + item.get("targetAudience") if audience else item.get("accountName"), + item.get("accountId") if not audience else None, + ] + + 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") @@ -2189,39 +2443,65 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None: if state.get("topAudiences") and not ctx.dry_run: record_event(state, stage, "topAudiences 已存在,跳过。如需重跑请清空 state 中该字段。") return - payload = { - "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。", - "otherinfo": {"方向": state.get("direction") or "U方向"}, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": f"任务背景和目的:{state['mubiao']}", - } - 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 + candidates = [item for item in state.get("audienceCandidates") or [] if isinstance(item, dict)] + accounts = [item for item in state.get("audienceAccounts") or [] if isinstance(item, dict)] + if not candidates: + payload = { + "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。", + "otherinfo": {"方向": state.get("direction") or "U方向"}, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": f"任务背景和目的:{state['mubiao']}", + } + 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。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。", - "zhanghao": accounts, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": state["mubiao"], - } - data, _, _ = call_model_json(ctx, state, "group", group_payload, stage + "/extract") - candidates = extract_audiences(data) - state["audienceCandidates"] = candidates - top = select_top_audiences(candidates, ctx.max_audiences) + # 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。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。", + "zhanghao": accounts, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": state["mubiao"], + } + data, _, _ = call_model_json(ctx, state, "group", group_payload, stage + "/extract") + candidates = extract_audiences(data) + state["audienceCandidates"] = candidates + if not candidates: + record_error(state, stage, "没有生成可供选择的受众候选") + return + if ctx.require_user_assist and not has_selection(ctx.select_audience_refs): + emit_user_assist( + state, + stage, + selection_key="audience", + title="请选择要入库并继续分析的受众", + prompt=f"可多选,最多建议选择 {ctx.max_audiences} 个。回复序号即可,例如:1,2,3。", + options=[audience_option(item, index) for index, item in enumerate(candidates, start=1)], + command_hint=user_assist_command_hint(state, "step1", "select-audiences", "1,2,3"), + allow_multiple=True, + max_count=ctx.max_audiences, + ) + return + selected_refs = ctx.select_audience_refs or [] + top, missing = select_by_refs(candidates, selected_refs, audience_aliases, max_count=ctx.max_audiences) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的受众未匹配:{', '.join(missing)}") + if not top: + record_error(state, stage, "没有选中任何受众,无法继续") + return saved: list[dict[str, Any]] = [] for item in top: # Save the audience first; account rows and downstream analyses depend @@ -2282,60 +2562,85 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: state["audiencesWithPathways"] = audience_pathway_summary(state) record_event(state, stage, "topPathways 已存在,跳过。如需重跑请清空 state 中该字段。") return - all_candidates: list[dict[str, Any]] = [] - for audience in audiences: - # Analyze pathway candidates in the context of each saved audience. - payload = { - "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。", - "otherinfo": { - "方向": state.get("direction") or "U方向", - "任务": "传播", - "平台": state.get("platform") or "fb", - "受众": {"名称": audience.get("targetAudience"), "理由": audience.get("recReason")}, - }, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": f"任务背景和目的:{state['mubiao']}", - } - 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。", - "zhanghao": accounts, - "history": [], - "stream": True, - "model_name": "liyou", - "temperature": 0.7, - "max_tokens": 1000, - "prompt_name": f"{audience.get('targetAudience')}:{audience.get('recReason')};", - } - data, _, _ = call_model_json(ctx, state, "spread", spread_payload, stage + "/extract") - refs = extract_pathway_refs(data) - for ref_item in refs: - account = resolve_account_ref(ref_item.get("ref"), accounts) - if account is None: - record_error(state, stage, f"传播节点引用 {ref_item.get('ref')} 未匹配账号", response=data) - continue - all_candidates.append( - { - "account": account, - "accountId": account_id(account), - "accountName": account_name(account), - "recReason": ref_item.get("recReason", ""), - "rank": ref_item.get("rank"), - "designIds": [audience["designId"]], - "relatedAudiences": [audience.get("targetAudience", "")], - } - ) - merged = merge_pathway_candidates(all_candidates) - state["pathwayCandidates"] = merged - top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways] + ranked = [item for item in state.get("pathwayCandidates") or [] if isinstance(item, dict)] + if not ranked: + all_candidates: list[dict[str, Any]] = [] + for audience in audiences: + # Analyze pathway candidates in the context of each saved audience. + payload = { + "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。", + "otherinfo": { + "方向": state.get("direction") or "U方向", + "任务": "传播", + "平台": state.get("platform") or "fb", + "受众": {"名称": audience.get("targetAudience"), "理由": audience.get("recReason")}, + }, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": f"任务背景和目的:{state['mubiao']}", + } + 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。", + "zhanghao": accounts, + "history": [], + "stream": True, + "model_name": "liyou", + "temperature": 0.7, + "max_tokens": 1000, + "prompt_name": f"{audience.get('targetAudience')}:{audience.get('recReason')};", + } + data, _, _ = call_model_json(ctx, state, "spread", spread_payload, stage + "/extract") + refs = extract_pathway_refs(data) + for ref_item in refs: + account = resolve_account_ref(ref_item.get("ref"), accounts) + if account is None: + record_error(state, stage, f"传播节点引用 {ref_item.get('ref')} 未匹配账号", response=data) + continue + all_candidates.append( + { + "account": account, + "accountId": account_id(account), + "accountName": account_name(account), + "recReason": ref_item.get("recReason", ""), + "rank": ref_item.get("rank"), + "designIds": [audience["designId"]], + "relatedAudiences": [audience.get("targetAudience", "")], + } + ) + merged = merge_pathway_candidates(all_candidates) + ranked = sorted(merged, key=pathway_score, reverse=True) + state["pathwayCandidates"] = ranked + if not ranked: + record_error(state, stage, "没有生成可供选择的关键传播节点候选") + return + if ctx.require_user_assist and not has_selection(ctx.select_pathway_refs): + emit_user_assist( + state, + stage, + selection_key="pathway", + title="请选择要入库并继续分析的关键传播节点", + prompt=f"可多选,最多建议选择 {ctx.max_pathways} 个。回复序号、账号名称或账号ID均可,例如:1,2,3。", + options=[pathway_option(item, index) for index, item in enumerate(ranked, start=1)], + command_hint=user_assist_command_hint(state, "step2", "select-pathways", "1,2,3"), + allow_multiple=True, + max_count=ctx.max_pathways, + ) + return + top, missing = select_by_refs(ranked, ctx.select_pathway_refs or [], pathway_aliases, max_count=ctx.max_pathways) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的关键传播节点未匹配:{', '.join(missing)}") + if not top: + record_error(state, stage, "没有选中任何关键传播节点,无法继续") + return saved: list[dict[str, Any]] = [] for item in top: # Save the account row before writing the design-pathway relation. @@ -2395,7 +2700,10 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> stage = "step3-audience-vulns" if audience else "step4-pathway-vulns" items = downstream_items(ctx, state, stage, audience=audience) output_key = "audienceVulns" if audience else "pathwayVulns" + candidate_key = "audienceVulnCandidates" if audience else "pathwayVulnCandidates" + select_refs = ctx.select_audience_vuln_refs if audience else ctx.select_pathway_vuln_refs state.setdefault(output_key, {}) + candidate_store = state.setdefault(candidate_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") @@ -2403,6 +2711,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> continue if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run: continue + if str(owner_id) in candidate_store and candidate_store[str(owner_id)]: + continue zhanghao = selected_accounts_for_item(item, audience=audience) prompt = ( "你是中国对台湾方向的认知战专家,你要传播行动要求的叙事主题。请基于该受众目标的画像、关注议题、社会分工定位、利益诉求与上一轮账号佐证,分析该受众目标有哪些脆弱点可以成为后续策略设计的关键要素。请输出理由、脆弱点1、理由1、脆弱点2、理由2、脆弱点3、理由3,只返回JSON。" @@ -2422,8 +2732,43 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> } data, _, _ = call_model_json(ctx, state, "cuiruo", payload, stage + "/extract") vulns = extract_vulns(data, ctx.max_vulns) + candidate_store[str(owner_id)] = vulns + entries = vuln_candidate_entries(items, candidate_store, audience=audience) + if not entries: + record_error(state, stage, "没有生成可供选择的脆弱点候选") + return + if ctx.require_user_assist and not has_selection(select_refs): + flag = "select-audience-vulns" if audience else "select-pathway-vulns" + emit_user_assist( + state, + stage, + selection_key="audience_vuln" if audience else "pathway_vuln", + title="请选择要入库的受众脆弱点" if audience else "请选择要入库的传播节点脆弱点", + prompt="可多选。回复全局序号即可,例如:1,2,4;也可用 对象ID:本对象内序号。", + options=[vuln_option(entry) for entry in entries], + command_hint=user_assist_command_hint(state, "step3" if audience else "step4", flag, "1,2,4"), + allow_multiple=True, + ) + return + selected_entries, missing = select_by_refs(entries, select_refs or [], vuln_aliases) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的脆弱点未匹配:{', '.join(missing)}") + selected_by_owner: dict[str, list[dict[str, Any]]] = {} + for entry in selected_entries: + owner_key = str(entry.get("ownerId")) + selected_by_owner.setdefault(owner_key, []).append(entry.get("vuln") or {}) + if not selected_by_owner: + record_error(state, stage, "没有选中任何脆弱点,无法继续") + return + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + if not owner_id: + continue + if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run: + continue saved: list[dict[str, Any]] = [] - for vuln in vulns: + for vuln in selected_by_owner.get(str(owner_id), []): # The two weak-point tables have nearly identical payloads but # different foreign-key names. if audience: @@ -2475,8 +2820,53 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool """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" + if audience and (has_selection(ctx.select_strategy_pathway_refs) or ctx.strategy_target == "pathway"): + clear_pending_assist(state) + record_error(state, stage, "策略生成目标类型冲突:Step5 只能选择受众,不能同时选择关键传播节点") + return + if not audience and (has_selection(ctx.select_strategy_audience_refs) or ctx.strategy_target == "audience"): + clear_pending_assist(state) + record_error(state, stage, "策略生成目标类型冲突:Step6 只能选择关键传播节点,不能同时选择受众") + return items = downstream_items(ctx, state, stage, audience=audience) vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {} + selectable_items: list[dict[str, Any]] = [] + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + vulns = vulns_by_owner.get(str(owner_id)) or [] + if vulns: + selectable_items.append({**item, "_vulns": vulns}) + select_refs = ctx.select_strategy_audience_refs if audience else ctx.select_strategy_pathway_refs + if selectable_items and ctx.require_user_assist and not has_selection(select_refs): + flag = "select-strategy-audiences" if audience else "select-strategy-pathways" + emit_user_assist( + state, + stage, + selection_key="strategy_audience" if audience else "strategy_pathway", + title="请选择要生成策略的受众" if audience else "请选择要生成策略的关键传播节点", + prompt=( + "可多选,但本步骤只能选择受众,不能混选关键传播节点。回复序号即可,例如:1,2。" + if audience + else "可多选,但本步骤只能选择关键传播节点,不能混选受众。回复序号即可,例如:1,2。" + ), + options=[strategy_target_option(item, index, audience=audience) for index, item in enumerate(selectable_items, start=1)], + command_hint=user_assist_command_hint(state, "step5" if audience else "step6", flag, "1,2"), + allow_multiple=True, + exclusive_note="受众策略和关键传播节点策略不能在同一步命令中交叉选择。", + ) + return + selected_items, missing = select_by_refs( + selectable_items, + select_refs or [], + lambda item, index: strategy_target_aliases(item, index, audience=audience), + ) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的策略生成对象未匹配:{', '.join(missing)}") + items = selected_items + if not items: + record_error(state, stage, "没有选中任何具备脆弱点的策略生成对象,无法继续") + return 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)} @@ -2583,14 +2973,15 @@ def emit_step_feedback( # 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") + final_status = status or ("waiting_user" if has_pending_assist(state) else ("partial" if new_errors else "ok")) + final_note = note or (state.get("_pendingUserAssist", {}).get("title", "") if has_pending_assist(state) else "") payload = { "日志类型": "步骤反馈", "步骤": STEP_NUMBER_LABELS.get(step, step), "步骤名称": STEP_LABELS.get(step, step), "状态": STATUS_LABELS.get(final_status, final_status), "执行状态": status_label(final_status), - "备注": note, + "备注": final_note, "本步骤新增错误数": new_errors, "累计错误数": total_errors, "累计入库数量": state_counts(state), @@ -2638,15 +3029,27 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None: for dry-runs and unattended local troubleshooting. """ run_step_with_feedback(ctx, state, "step1") + if has_pending_assist(state): + return run_step_with_feedback(ctx, state, "step2") + if has_pending_assist(state): + return run_step_with_feedback(ctx, state, "step3") + if has_pending_assist(state): + return if state.get("topPathways"): run_step_with_feedback(ctx, state, "step4") + if has_pending_assist(state): + return else: skip_step_with_feedback(ctx, state, "step4", "没有已入库关键传播节点") run_step_with_feedback(ctx, state, "step5") + if has_pending_assist(state): + return if state.get("topPathways"): run_step_with_feedback(ctx, state, "step6") + if has_pending_assist(state): + return else: skip_step_with_feedback(ctx, state, "step6", "没有已入库关键传播节点") @@ -2722,6 +3125,14 @@ def build_arg_parser() -> argparse.ArgumentParser: 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。") + parser.add_argument("--no-user-assist", action="store_true", help="关闭每步用户协助选择,使用脚本默认排序自动继续。") + parser.add_argument("--select-audiences", default="", help="Step1 用户选择的受众序号/名称,逗号分隔,例如 1,2,3。") + parser.add_argument("--select-pathways", default="", help="Step2 用户选择的关键传播节点序号/账号名/账号ID,逗号分隔。") + parser.add_argument("--select-audience-vulns", default="", help="Step3 用户选择的受众脆弱点序号,逗号分隔。") + parser.add_argument("--select-pathway-vulns", default="", help="Step4 用户选择的传播节点脆弱点序号,逗号分隔。") + parser.add_argument("--strategy-target", choices=["", "audience", "pathway"], default="", help="策略生成目标类型;Step5 只能 audience,Step6 只能 pathway。") + parser.add_argument("--select-strategy-audiences", default="", help="Step5 用户选择的受众策略生成对象序号/受众ID/名称,逗号分隔。") + parser.add_argument("--select-strategy-pathways", default="", help="Step6 用户选择的传播节点策略生成对象序号/节点ID/账号名,逗号分隔。") return parser @@ -2758,6 +3169,20 @@ def main(argv: list[str] | None = None) -> int: 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 + if config.get("require_user_assist") is False or config.get("user_assist") is False: + args.no_user_assist = True + selection_map = { + "select_audiences": "select_audiences", + "select_pathways": "select_pathways", + "select_audience_vulns": "select_audience_vulns", + "select_pathway_vulns": "select_pathway_vulns", + "select_strategy_audiences": "select_strategy_audiences", + "select_strategy_pathways": "select_strategy_pathways", + "strategy_target": "strategy_target", + } + for config_key, arg_name in selection_map.items(): + if config.get(config_key) is not None: + setattr(args, arg_name, config.get(config_key)) cli_config = { "api_base": args.api_base, @@ -2785,6 +3210,14 @@ def main(argv: list[str] | None = None) -> int: max_strategies=max(args.max_strategies, 1), downstream_audiences=max(args.downstream_audiences, 1), downstream_pathways=max(args.downstream_pathways, 1), + require_user_assist=not args.no_user_assist, + select_audience_refs=split_selection_refs(args.select_audiences), + select_pathway_refs=split_selection_refs(args.select_pathways), + select_audience_vuln_refs=split_selection_refs(args.select_audience_vulns), + select_pathway_vuln_refs=split_selection_refs(args.select_pathway_vulns), + select_strategy_audience_refs=split_selection_refs(args.select_strategy_audiences), + select_strategy_pathway_refs=split_selection_refs(args.select_strategy_pathways), + strategy_target=str(args.strategy_target or ""), sync_step_status=not args.no_step_status, model_retries=max(args.model_retries, 0), verify_ssl=bool(args.verify_ssl), @@ -2838,6 +3271,8 @@ def main(argv: list[str] | None = None) -> int: final_payload = { "日志类型": "执行完成", "是否成功": "是", + "是否等待用户选择": yes_no(has_pending_assist(state)), + "待用户选择": state.get("_pendingUserAssist"), "执行模式": mode_label(args.mode), "状态文件": str(state_path), "摘要文件": str(ctx.summary_path) if ctx.summary_path else "", 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 index d6468b51..2bce3d25 100644 --- a/skills-data/xdfx-split-controller/scripts/xdfx_run_full_steps.py +++ b/skills-data/xdfx-split-controller/scripts/xdfx_run_full_steps.py @@ -15,6 +15,10 @@ import subprocess import sys from pathlib import Path +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") STEPS = ("step1", "step2", "step3", "step4", "step5", "step6") @@ -53,7 +57,7 @@ def selected_steps(first: str, last: str) -> list[str]: return list(STEPS[start : end + 1]) -def stream_command(step: str, cmd: list[str]) -> int: +def stream_command(step: str, cmd: list[str]) -> tuple[int, bool]: """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() @@ -68,10 +72,13 @@ def stream_command(step: str, cmd: list[str]) -> int: env=env, ) assert process.stdout is not None + waiting_user = False for line in process.stdout: + if line.startswith("用户协助 "): + waiting_user = True sys.stdout.buffer.write(line.encode("utf-8", errors="replace")) sys.stdout.buffer.flush() - return process.wait() + return process.wait(), waiting_user def main(argv: list[str] | None = None) -> int: @@ -83,7 +90,10 @@ def main(argv: list[str] | None = None) -> int: # 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) + code, waiting_user = stream_command(step, cmd) + if waiting_user: + print("已发起用户协助,暂停后续步骤;请根据用户选择重跑当前步骤。", flush=True) + return code if code: exit_code = code if not args.continue_on_error: diff --git a/skills-data/xdfx-split-flow-skill-guide.md b/skills-data/xdfx-split-flow-skill-guide.md index f09c6203..d75eef97 100644 --- a/skills-data/xdfx-split-flow-skill-guide.md +++ b/skills-data/xdfx-split-flow-skill-guide.md @@ -112,19 +112,20 @@ python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow- ## 每步反馈格式 -脚本会输出五类反馈行: +脚本会输出六类反馈行: - `操作反馈`:当前正在做什么、调用哪个接口。 - `入库反馈`:当前已入库对象的类型、名称和返回 ID。 - `错误反馈`:当前错误的阶段、接口、状态码、请求摘要和响应摘要。 - `JSON解析复核`:模型返回内容无法稳定解析为 JSON,需要执行智能体复核是否重试。 +- `用户协助`:当前步骤需要用户选择候选;总控必须暂停后续步骤,展示选项,等待用户选择后重跑同一个 step。 - `步骤反馈`:当前步骤最终状态和累计入库数量。 `步骤反馈` 中包含: - `步骤`:步骤编号。 - `步骤名称`:步骤名称。 -- `状态`:`成功`、`部分完成` 或 `已跳过`。 +- `状态`:`成功`、`部分完成`、`等待用户选择` 或 `已跳过`。 - `执行状态`:中文执行状态。 - `累计入库数量`:当前累计入库数量。 - `本步骤新增错误数`:本步骤新增错误数。 @@ -134,6 +135,8 @@ python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow- - Step1 入库最优 3 个受众。 - Step2 入库最优 3 个传播节点。 +- 默认开启用户协助选择:Step1 选择受众;Step2 选择关键传播节点;Step3/Step4 选择要入库的脆弱点;Step5 只能选择多个受众生成策略;Step6 只能选择多个关键传播节点生成策略。受众和关键传播节点不能在同一步交叉生成策略。 +- 用户选择后重跑当前 step:`--select-audiences`、`--select-pathways`、`--select-audience-vulns`、`--select-pathway-vulns`、`--select-strategy-audiences`、`--select-strategy-pathways`。 - Step3/Step5 的受众下游分析只能从“已在 Step2 分析出并关联关键传播节点”的受众中选择,默认取前 2 个;没有产生传播节点的受众不得继续分析脆弱点和策略。 - Step4/Step6 的传播节点下游分析默认从已入库传播节点中选择前 2 个继续;可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。 - Step3-Step6 对上述选中的每个受众/传播节点默认生成最多 3 个脆弱点或策略。 @@ -159,7 +162,7 @@ Step2 关键传播节点分析完成:当前已入库传播节点 X 个,本 - 调用受众分析模型。 - 抽取结构化受众。 -- 选择最优 3 个受众。 +- 输出 `用户协助`,等待用户选择受众;带 `--select-audiences` 重跑后才保存选中的受众。 - 保存受众与受众账号。 - 同步前端 `step-analysis-audience`、`step-analysis-keypoint` 状态。 @@ -177,7 +180,7 @@ topAudiences - 基于 Step1 的受众逐个分析关键传播节点。 - 跨受众去重。 -- 选择全局最优 3 个节点。 +- 输出 `用户协助`,等待用户选择关键传播节点;带 `--select-pathways` 重跑后才保存选中的节点。 - 保存传播节点。 - 保存受众与传播节点关系。 - 同步前端 `step-analysis-keypoint`、`step-analysis-weakpoint` 状态。 @@ -194,8 +197,8 @@ topPathways 动作: -- 基于 `topAudiences` 分析受众脆弱点。 -- 每个受众最多保存 3 个脆弱点。 +- 只基于已关联关键传播节点的受众分析脆弱点候选。 +- 输出 `用户协助`,等待用户选择受众脆弱点;带 `--select-audience-vulns` 重跑后才保存选中的脆弱点。 - 同步前端 `step-analysis-weakpoint` 状态。 输出写入: @@ -211,7 +214,7 @@ audienceVulns 动作: - 基于 `topPathways` 分析传播节点脆弱点。 -- 每个节点最多保存 3 个脆弱点。 +- 输出 `用户协助`,等待用户选择传播节点脆弱点;带 `--select-pathway-vulns` 重跑后才保存选中的脆弱点。 - 同步前端 `step-analysis-weakpoint` 状态。 输出写入: @@ -226,7 +229,9 @@ pathwayVulns 动作: -- 基于 `topAudiences` 与 `audienceVulns` 生成策略。 +- 基于已关联关键传播节点的受众与 `audienceVulns` 生成策略候选。 +- 输出 `用户协助`,等待用户选择一个或多个受众;带 `--select-strategy-audiences` 重跑后才生成并保存这些受众的策略。 +- 本步骤只能选择受众,不能选择或混入关键传播节点。 - 保存策略主表。 - 同步写入策略 channel、narrate、message 和策略-受众关系。 - 同步前端 `step-analysis-strategy`、`step-analysis-strategy-gantt` 状态。 @@ -243,7 +248,9 @@ audienceStrategies 动作: -- 基于 `topPathways` 与 `pathwayVulns` 生成策略。 +- 基于 `topPathways` 与 `pathwayVulns` 生成策略候选。 +- 输出 `用户协助`,等待用户选择一个或多个关键传播节点;带 `--select-strategy-pathways` 重跑后才生成并保存这些传播节点的策略。 +- 本步骤只能选择关键传播节点,不能选择或混入受众。 - 保存策略主表。 - 同步写入策略 channel、narrate、message 和策略-传播节点关系。 - 同步前端 `step-analysis-strategy`、`step-analysis-strategy-gantt` 状态。 diff --git a/skills-data/xdfx-step1-audience/SKILL.md b/skills-data/xdfx-step1-audience/SKILL.md index 5b9f7474..a45221e6 100644 --- a/skills-data/xdfx-step1-audience/SKILL.md +++ b/skills-data/xdfx-step1-audience/SKILL.md @@ -1,6 +1,6 @@ --- name: xdfx-step1-audience -description: xdfx 拆分流程 step1,分析受众目标,选择最优3个受众,并保存受众与受众账号。 +description: xdfx 拆分流程 step1,分析受众目标,发起用户协助选择受众,并保存选中的受众与受众账号。 --- # Step1 受众分析与入库 @@ -33,7 +33,7 @@ python scripts/xdfx_flow.py --mode step1 --input xdfx-input.json --state xdfx-fl - 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。 - 抽取结构化受众。 - 抽取结果中的 `用户编号列表` / `编号id` / `id` 必须对应上一轮 `zhanghao` 账号数组的索引值;脚本按索引依次找到账号并保存。 -- 选择并入库最优 3 个受众。 +- 输出 `用户协助`,等待用户选择受众;用户选择后带 `--select-audiences 1,2,3` 重跑 Step1,才保存选中的受众。 - 保存受众目标。 - 保存受众目标账号。 - 同步前端步骤状态 `step-analysis-audience` 与 `step-analysis-keypoint`。 @@ -52,6 +52,8 @@ python scripts/xdfx_flow.py --mode step1 --input xdfx-input.json --state xdfx-fl 命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step1 的完成状态、当前受众入库数量和新增错误数,再决定是否继续下一步。 +如果输出 `用户协助 {...}`,必须暂停流程,把受众选项发给用户;用户选择后重跑 Step1 并追加 `--select-audiences ...`。 + 执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。 保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的受众、受众账号和返回 ID。 diff --git a/skills-data/xdfx-step1-audience/scripts/xdfx_flow.py b/skills-data/xdfx-step1-audience/scripts/xdfx_flow.py index 8e62e51e..d385f95b 100644 --- a/skills-data/xdfx-step1-audience/scripts/xdfx_flow.py +++ b/skills-data/xdfx-step1-audience/scripts/xdfx_flow.py @@ -28,6 +28,10 @@ from typing import Any import xdfx_auth +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") # Keep every backend path in one table so endpoint drift is easy to audit. ENDPOINTS = { @@ -76,6 +80,7 @@ LOG_PREFIXES = { "operation": "操作反馈", "error": "错误反馈", "json_review": "JSON解析复核", + "assist": "用户协助", "save": "入库反馈", "step": "步骤反馈", "done": "执行完成", @@ -85,6 +90,7 @@ STATUS_LABELS = { "ok": "成功", "partial": "部分完成", "skipped": "已跳过", + "waiting_user": "等待用户选择", } STEP_NUMBER_LABELS = { @@ -226,6 +232,14 @@ class FlowContext: max_strategies: int = 3 downstream_audiences: int = 2 downstream_pathways: int = 2 + require_user_assist: bool = True + select_audience_refs: list[str] | None = None + select_pathway_refs: list[str] | None = None + select_audience_vuln_refs: list[str] | None = None + select_pathway_vuln_refs: list[str] | None = None + select_strategy_audience_refs: list[str] | None = None + select_strategy_pathway_refs: list[str] | None = None + strategy_target: str = "" sync_step_status: bool = True model_retries: int = 1 verify_ssl: bool = False @@ -284,6 +298,129 @@ def safe_compact(value: Any, limit: int = 1400) -> str: return compact(redact_sensitive(value), limit) +def split_selection_refs(raw: Any) -> list[str]: + """Parse user choices like '1,3' or ['1', '3'] into stable strings.""" + if raw is None: + return [] + if isinstance(raw, (list, tuple, set)): + parts: list[str] = [] + for item in raw: + parts.extend(split_selection_refs(item)) + return parts + text = str(raw).strip() + if not text: + return [] + if text.lower() in {"all", "全部"}: + return ["all"] + return [part.strip() for part in re.split(r"[,,;;\s]+", text) if part.strip()] + + +def normalized_ref(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")).lower() + + +def has_selection(refs: list[str] | None) -> bool: + return bool([ref for ref in refs or [] if str(ref).strip()]) + + +def clear_pending_assist(state: dict[str, Any]) -> None: + state.pop("_pendingUserAssist", None) + + +def has_pending_assist(state: dict[str, Any]) -> bool: + return isinstance(state.get("_pendingUserAssist"), dict) + + +def user_assist_command_hint(state: dict[str, Any], mode: str, flag_name: str, example: str) -> str: + command = f"python scripts/xdfx_flow.py --mode {mode} --state {state.get('_statePath') or 'xdfx-flow-state.json'}" + if state.get("_summaryPath"): + command += f" --summary {state.get('_summaryPath')}" + return f"{command} --{flag_name} {example}" + + +def emit_user_assist( + state: dict[str, Any], + stage: str, + *, + selection_key: str, + title: str, + prompt: str, + options: list[dict[str, Any]], + command_hint: str, + allow_multiple: bool = True, + max_count: int | None = None, + exclusive_note: str = "", +) -> None: + payload = { + "日志类型": "用户协助", + "阶段": stage_label(stage), + "步骤": feedback_step_label(stage), + "选择类型": selection_key, + "标题": title, + "提示": prompt, + "是否允许多选": yes_no(allow_multiple), + "最多选择数量": max_count, + "互斥规则": exclusive_note, + "选项": options, + "继续命令示例": command_hint, + "状态文件": state.get("_statePath", ""), + "摘要文件": state.get("_summaryPath", ""), + "时间": utc_now(), + } + state["_pendingUserAssist"] = { + "selectionKey": selection_key, + "stage": stage, + "title": title, + "commandHint": command_hint, + "optionCount": len(options), + "createdAt": payload["时间"], + } + print(LOG_PREFIXES["assist"] + " " + json.dumps(payload, ensure_ascii=False), flush=True) + record_event(state, stage, "等待用户协助选择", {"selectionKey": selection_key, "optionCount": len(options)}) + + +def ref_matches(ref: str, ordinal: int, aliases: list[Any]) -> bool: + normalized = normalized_ref(ref) + if normalized == "all": + return True + if normalized == str(ordinal): + return True + return any(normalized and normalized == normalized_ref(alias) for alias in aliases if alias is not None) + + +def select_by_refs( + items: list[Any], + refs: list[str], + alias_fn, + *, + max_count: int | None = None, +) -> tuple[list[Any], list[str]]: + if not refs: + selected = list(items) + elif any(normalized_ref(ref) in {"all", "全部"} for ref in refs): + selected = list(items) + else: + selected = [] + missing: list[str] = [] + for ref in refs: + found = None + for index, item in enumerate(items, start=1): + if ref_matches(ref, index, alias_fn(item, index)): + found = item + break + if found is None: + missing.append(ref) + continue + if found not in selected: + selected.append(found) + if max_count is not None: + return selected[:max_count], missing + return selected, missing + if max_count is not None: + selected = selected[:max_count] + return selected, [] + + def stable_json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) @@ -336,7 +473,7 @@ def load_json_file(path: str | Path) -> dict[str, Any]: p = Path(path) if not p.exists(): return {} - return json.loads(p.read_text(encoding="utf-8")) + return json.loads(p.read_text(encoding="utf-8-sig")) def save_json_file(path: str | Path, value: Any) -> None: @@ -369,6 +506,8 @@ def default_state() -> dict[str, Any]: "pathwayCandidates": [], "topPathways": [], "audiencesWithPathways": [], + "audienceVulnCandidates": {}, + "pathwayVulnCandidates": {}, "audienceVulns": {}, "pathwayVulns": {}, "audienceStrategies": [], @@ -2182,6 +2321,121 @@ def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, aud return selected +def audience_option(item: dict[str, Any], index: int) -> dict[str, Any]: + return { + "序号": index, + "受众目标": item.get("targetAudience"), + "推荐理由": compact(item.get("recReason", ""), 300), + "账号引用": item.get("userIndexes") or [], + } + + +def audience_aliases(item: dict[str, Any], index: int) -> list[Any]: + return [index, item.get("targetAudience"), item.get("rank")] + + +def pathway_option(item: dict[str, Any], index: int) -> dict[str, Any]: + return { + "序号": index, + "账号名称": item.get("accountName") or account_name(item.get("account") or {}), + "账号ID": item.get("accountId") or account_id(item.get("account") or {}), + "关联受众": item.get("relatedAudiences") or [], + "推荐理由": compact(item.get("recReason", ""), 300), + } + + +def pathway_aliases(item: dict[str, Any], index: int) -> list[Any]: + account = item.get("account") or item + return [ + index, + item.get("pathwayAccountId"), + item.get("pathwayId"), + item.get("accountId"), + item.get("accountName"), + account_id(account), + account_name(account), + ] + + +def vuln_candidate_entries( + items: list[dict[str, Any]], + candidate_store: dict[str, Any], + *, + audience: bool, +) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + seq = 1 + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + owner_name = item.get("targetAudience") if audience else item.get("accountName") + for index, vuln in enumerate(candidate_store.get(str(owner_id)) or [], start=1): + if not isinstance(vuln, dict): + continue + entries.append( + { + "seq": seq, + "ownerId": owner_id, + "ownerName": owner_name, + "localIndex": index, + "vuln": vuln, + } + ) + seq += 1 + return entries + + +def vuln_option(entry: dict[str, Any]) -> dict[str, Any]: + vuln = entry.get("vuln") or {} + return { + "序号": entry.get("seq"), + "对象ID": entry.get("ownerId"), + "对象名称": entry.get("ownerName"), + "本对象内序号": entry.get("localIndex"), + "脆弱点": vuln.get("vulnerabilities"), + "理由": compact(vuln.get("reason", ""), 260), + } + + +def vuln_aliases(entry: dict[str, Any], index: int) -> list[Any]: + vuln = entry.get("vuln") or {} + owner_id = entry.get("ownerId") + return [ + index, + entry.get("seq"), + f"{owner_id}:{entry.get('localIndex')}", + vuln.get("vulnerabilities"), + ] + + +def strategy_target_option(item: dict[str, Any], index: int, *, audience: bool) -> dict[str, Any]: + if audience: + return { + "序号": index, + "类型": "受众", + "对象ID": item.get("designId"), + "对象名称": item.get("targetAudience"), + "已入库脆弱点数量": len((item.get("_vulns") or [])), + "关联传播节点": item.get("downstreamPathways") or [], + } + return { + "序号": index, + "类型": "关键传播节点", + "对象ID": item.get("pathwayAccountId"), + "对象名称": item.get("accountName"), + "已入库脆弱点数量": len((item.get("_vulns") or [])), + "关联受众": item.get("relatedAudiences") or [], + } + + +def strategy_target_aliases(item: dict[str, Any], index: int, *, audience: bool) -> list[Any]: + return [ + index, + item.get("designId") if audience else item.get("pathwayAccountId"), + item.get("targetAudience") if audience else item.get("accountName"), + item.get("accountId") if not audience else None, + ] + + 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") @@ -2189,39 +2443,65 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None: if state.get("topAudiences") and not ctx.dry_run: record_event(state, stage, "topAudiences 已存在,跳过。如需重跑请清空 state 中该字段。") return - payload = { - "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。", - "otherinfo": {"方向": state.get("direction") or "U方向"}, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": f"任务背景和目的:{state['mubiao']}", - } - 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 + candidates = [item for item in state.get("audienceCandidates") or [] if isinstance(item, dict)] + accounts = [item for item in state.get("audienceAccounts") or [] if isinstance(item, dict)] + if not candidates: + payload = { + "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。", + "otherinfo": {"方向": state.get("direction") or "U方向"}, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": f"任务背景和目的:{state['mubiao']}", + } + 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。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。", - "zhanghao": accounts, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": state["mubiao"], - } - data, _, _ = call_model_json(ctx, state, "group", group_payload, stage + "/extract") - candidates = extract_audiences(data) - state["audienceCandidates"] = candidates - top = select_top_audiences(candidates, ctx.max_audiences) + # 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。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。", + "zhanghao": accounts, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": state["mubiao"], + } + data, _, _ = call_model_json(ctx, state, "group", group_payload, stage + "/extract") + candidates = extract_audiences(data) + state["audienceCandidates"] = candidates + if not candidates: + record_error(state, stage, "没有生成可供选择的受众候选") + return + if ctx.require_user_assist and not has_selection(ctx.select_audience_refs): + emit_user_assist( + state, + stage, + selection_key="audience", + title="请选择要入库并继续分析的受众", + prompt=f"可多选,最多建议选择 {ctx.max_audiences} 个。回复序号即可,例如:1,2,3。", + options=[audience_option(item, index) for index, item in enumerate(candidates, start=1)], + command_hint=user_assist_command_hint(state, "step1", "select-audiences", "1,2,3"), + allow_multiple=True, + max_count=ctx.max_audiences, + ) + return + selected_refs = ctx.select_audience_refs or [] + top, missing = select_by_refs(candidates, selected_refs, audience_aliases, max_count=ctx.max_audiences) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的受众未匹配:{', '.join(missing)}") + if not top: + record_error(state, stage, "没有选中任何受众,无法继续") + return saved: list[dict[str, Any]] = [] for item in top: # Save the audience first; account rows and downstream analyses depend @@ -2282,60 +2562,85 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: state["audiencesWithPathways"] = audience_pathway_summary(state) record_event(state, stage, "topPathways 已存在,跳过。如需重跑请清空 state 中该字段。") return - all_candidates: list[dict[str, Any]] = [] - for audience in audiences: - # Analyze pathway candidates in the context of each saved audience. - payload = { - "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。", - "otherinfo": { - "方向": state.get("direction") or "U方向", - "任务": "传播", - "平台": state.get("platform") or "fb", - "受众": {"名称": audience.get("targetAudience"), "理由": audience.get("recReason")}, - }, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": f"任务背景和目的:{state['mubiao']}", - } - 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。", - "zhanghao": accounts, - "history": [], - "stream": True, - "model_name": "liyou", - "temperature": 0.7, - "max_tokens": 1000, - "prompt_name": f"{audience.get('targetAudience')}:{audience.get('recReason')};", - } - data, _, _ = call_model_json(ctx, state, "spread", spread_payload, stage + "/extract") - refs = extract_pathway_refs(data) - for ref_item in refs: - account = resolve_account_ref(ref_item.get("ref"), accounts) - if account is None: - record_error(state, stage, f"传播节点引用 {ref_item.get('ref')} 未匹配账号", response=data) - continue - all_candidates.append( - { - "account": account, - "accountId": account_id(account), - "accountName": account_name(account), - "recReason": ref_item.get("recReason", ""), - "rank": ref_item.get("rank"), - "designIds": [audience["designId"]], - "relatedAudiences": [audience.get("targetAudience", "")], - } - ) - merged = merge_pathway_candidates(all_candidates) - state["pathwayCandidates"] = merged - top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways] + ranked = [item for item in state.get("pathwayCandidates") or [] if isinstance(item, dict)] + if not ranked: + all_candidates: list[dict[str, Any]] = [] + for audience in audiences: + # Analyze pathway candidates in the context of each saved audience. + payload = { + "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。", + "otherinfo": { + "方向": state.get("direction") or "U方向", + "任务": "传播", + "平台": state.get("platform") or "fb", + "受众": {"名称": audience.get("targetAudience"), "理由": audience.get("recReason")}, + }, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": f"任务背景和目的:{state['mubiao']}", + } + 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。", + "zhanghao": accounts, + "history": [], + "stream": True, + "model_name": "liyou", + "temperature": 0.7, + "max_tokens": 1000, + "prompt_name": f"{audience.get('targetAudience')}:{audience.get('recReason')};", + } + data, _, _ = call_model_json(ctx, state, "spread", spread_payload, stage + "/extract") + refs = extract_pathway_refs(data) + for ref_item in refs: + account = resolve_account_ref(ref_item.get("ref"), accounts) + if account is None: + record_error(state, stage, f"传播节点引用 {ref_item.get('ref')} 未匹配账号", response=data) + continue + all_candidates.append( + { + "account": account, + "accountId": account_id(account), + "accountName": account_name(account), + "recReason": ref_item.get("recReason", ""), + "rank": ref_item.get("rank"), + "designIds": [audience["designId"]], + "relatedAudiences": [audience.get("targetAudience", "")], + } + ) + merged = merge_pathway_candidates(all_candidates) + ranked = sorted(merged, key=pathway_score, reverse=True) + state["pathwayCandidates"] = ranked + if not ranked: + record_error(state, stage, "没有生成可供选择的关键传播节点候选") + return + if ctx.require_user_assist and not has_selection(ctx.select_pathway_refs): + emit_user_assist( + state, + stage, + selection_key="pathway", + title="请选择要入库并继续分析的关键传播节点", + prompt=f"可多选,最多建议选择 {ctx.max_pathways} 个。回复序号、账号名称或账号ID均可,例如:1,2,3。", + options=[pathway_option(item, index) for index, item in enumerate(ranked, start=1)], + command_hint=user_assist_command_hint(state, "step2", "select-pathways", "1,2,3"), + allow_multiple=True, + max_count=ctx.max_pathways, + ) + return + top, missing = select_by_refs(ranked, ctx.select_pathway_refs or [], pathway_aliases, max_count=ctx.max_pathways) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的关键传播节点未匹配:{', '.join(missing)}") + if not top: + record_error(state, stage, "没有选中任何关键传播节点,无法继续") + return saved: list[dict[str, Any]] = [] for item in top: # Save the account row before writing the design-pathway relation. @@ -2395,7 +2700,10 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> stage = "step3-audience-vulns" if audience else "step4-pathway-vulns" items = downstream_items(ctx, state, stage, audience=audience) output_key = "audienceVulns" if audience else "pathwayVulns" + candidate_key = "audienceVulnCandidates" if audience else "pathwayVulnCandidates" + select_refs = ctx.select_audience_vuln_refs if audience else ctx.select_pathway_vuln_refs state.setdefault(output_key, {}) + candidate_store = state.setdefault(candidate_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") @@ -2403,6 +2711,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> continue if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run: continue + if str(owner_id) in candidate_store and candidate_store[str(owner_id)]: + continue zhanghao = selected_accounts_for_item(item, audience=audience) prompt = ( "你是中国对台湾方向的认知战专家,你要传播行动要求的叙事主题。请基于该受众目标的画像、关注议题、社会分工定位、利益诉求与上一轮账号佐证,分析该受众目标有哪些脆弱点可以成为后续策略设计的关键要素。请输出理由、脆弱点1、理由1、脆弱点2、理由2、脆弱点3、理由3,只返回JSON。" @@ -2422,8 +2732,43 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> } data, _, _ = call_model_json(ctx, state, "cuiruo", payload, stage + "/extract") vulns = extract_vulns(data, ctx.max_vulns) + candidate_store[str(owner_id)] = vulns + entries = vuln_candidate_entries(items, candidate_store, audience=audience) + if not entries: + record_error(state, stage, "没有生成可供选择的脆弱点候选") + return + if ctx.require_user_assist and not has_selection(select_refs): + flag = "select-audience-vulns" if audience else "select-pathway-vulns" + emit_user_assist( + state, + stage, + selection_key="audience_vuln" if audience else "pathway_vuln", + title="请选择要入库的受众脆弱点" if audience else "请选择要入库的传播节点脆弱点", + prompt="可多选。回复全局序号即可,例如:1,2,4;也可用 对象ID:本对象内序号。", + options=[vuln_option(entry) for entry in entries], + command_hint=user_assist_command_hint(state, "step3" if audience else "step4", flag, "1,2,4"), + allow_multiple=True, + ) + return + selected_entries, missing = select_by_refs(entries, select_refs or [], vuln_aliases) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的脆弱点未匹配:{', '.join(missing)}") + selected_by_owner: dict[str, list[dict[str, Any]]] = {} + for entry in selected_entries: + owner_key = str(entry.get("ownerId")) + selected_by_owner.setdefault(owner_key, []).append(entry.get("vuln") or {}) + if not selected_by_owner: + record_error(state, stage, "没有选中任何脆弱点,无法继续") + return + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + if not owner_id: + continue + if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run: + continue saved: list[dict[str, Any]] = [] - for vuln in vulns: + for vuln in selected_by_owner.get(str(owner_id), []): # The two weak-point tables have nearly identical payloads but # different foreign-key names. if audience: @@ -2475,8 +2820,53 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool """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" + if audience and (has_selection(ctx.select_strategy_pathway_refs) or ctx.strategy_target == "pathway"): + clear_pending_assist(state) + record_error(state, stage, "策略生成目标类型冲突:Step5 只能选择受众,不能同时选择关键传播节点") + return + if not audience and (has_selection(ctx.select_strategy_audience_refs) or ctx.strategy_target == "audience"): + clear_pending_assist(state) + record_error(state, stage, "策略生成目标类型冲突:Step6 只能选择关键传播节点,不能同时选择受众") + return items = downstream_items(ctx, state, stage, audience=audience) vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {} + selectable_items: list[dict[str, Any]] = [] + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + vulns = vulns_by_owner.get(str(owner_id)) or [] + if vulns: + selectable_items.append({**item, "_vulns": vulns}) + select_refs = ctx.select_strategy_audience_refs if audience else ctx.select_strategy_pathway_refs + if selectable_items and ctx.require_user_assist and not has_selection(select_refs): + flag = "select-strategy-audiences" if audience else "select-strategy-pathways" + emit_user_assist( + state, + stage, + selection_key="strategy_audience" if audience else "strategy_pathway", + title="请选择要生成策略的受众" if audience else "请选择要生成策略的关键传播节点", + prompt=( + "可多选,但本步骤只能选择受众,不能混选关键传播节点。回复序号即可,例如:1,2。" + if audience + else "可多选,但本步骤只能选择关键传播节点,不能混选受众。回复序号即可,例如:1,2。" + ), + options=[strategy_target_option(item, index, audience=audience) for index, item in enumerate(selectable_items, start=1)], + command_hint=user_assist_command_hint(state, "step5" if audience else "step6", flag, "1,2"), + allow_multiple=True, + exclusive_note="受众策略和关键传播节点策略不能在同一步命令中交叉选择。", + ) + return + selected_items, missing = select_by_refs( + selectable_items, + select_refs or [], + lambda item, index: strategy_target_aliases(item, index, audience=audience), + ) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的策略生成对象未匹配:{', '.join(missing)}") + items = selected_items + if not items: + record_error(state, stage, "没有选中任何具备脆弱点的策略生成对象,无法继续") + return 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)} @@ -2583,14 +2973,15 @@ def emit_step_feedback( # 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") + final_status = status or ("waiting_user" if has_pending_assist(state) else ("partial" if new_errors else "ok")) + final_note = note or (state.get("_pendingUserAssist", {}).get("title", "") if has_pending_assist(state) else "") payload = { "日志类型": "步骤反馈", "步骤": STEP_NUMBER_LABELS.get(step, step), "步骤名称": STEP_LABELS.get(step, step), "状态": STATUS_LABELS.get(final_status, final_status), "执行状态": status_label(final_status), - "备注": note, + "备注": final_note, "本步骤新增错误数": new_errors, "累计错误数": total_errors, "累计入库数量": state_counts(state), @@ -2638,15 +3029,27 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None: for dry-runs and unattended local troubleshooting. """ run_step_with_feedback(ctx, state, "step1") + if has_pending_assist(state): + return run_step_with_feedback(ctx, state, "step2") + if has_pending_assist(state): + return run_step_with_feedback(ctx, state, "step3") + if has_pending_assist(state): + return if state.get("topPathways"): run_step_with_feedback(ctx, state, "step4") + if has_pending_assist(state): + return else: skip_step_with_feedback(ctx, state, "step4", "没有已入库关键传播节点") run_step_with_feedback(ctx, state, "step5") + if has_pending_assist(state): + return if state.get("topPathways"): run_step_with_feedback(ctx, state, "step6") + if has_pending_assist(state): + return else: skip_step_with_feedback(ctx, state, "step6", "没有已入库关键传播节点") @@ -2722,6 +3125,14 @@ def build_arg_parser() -> argparse.ArgumentParser: 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。") + parser.add_argument("--no-user-assist", action="store_true", help="关闭每步用户协助选择,使用脚本默认排序自动继续。") + parser.add_argument("--select-audiences", default="", help="Step1 用户选择的受众序号/名称,逗号分隔,例如 1,2,3。") + parser.add_argument("--select-pathways", default="", help="Step2 用户选择的关键传播节点序号/账号名/账号ID,逗号分隔。") + parser.add_argument("--select-audience-vulns", default="", help="Step3 用户选择的受众脆弱点序号,逗号分隔。") + parser.add_argument("--select-pathway-vulns", default="", help="Step4 用户选择的传播节点脆弱点序号,逗号分隔。") + parser.add_argument("--strategy-target", choices=["", "audience", "pathway"], default="", help="策略生成目标类型;Step5 只能 audience,Step6 只能 pathway。") + parser.add_argument("--select-strategy-audiences", default="", help="Step5 用户选择的受众策略生成对象序号/受众ID/名称,逗号分隔。") + parser.add_argument("--select-strategy-pathways", default="", help="Step6 用户选择的传播节点策略生成对象序号/节点ID/账号名,逗号分隔。") return parser @@ -2758,6 +3169,20 @@ def main(argv: list[str] | None = None) -> int: 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 + if config.get("require_user_assist") is False or config.get("user_assist") is False: + args.no_user_assist = True + selection_map = { + "select_audiences": "select_audiences", + "select_pathways": "select_pathways", + "select_audience_vulns": "select_audience_vulns", + "select_pathway_vulns": "select_pathway_vulns", + "select_strategy_audiences": "select_strategy_audiences", + "select_strategy_pathways": "select_strategy_pathways", + "strategy_target": "strategy_target", + } + for config_key, arg_name in selection_map.items(): + if config.get(config_key) is not None: + setattr(args, arg_name, config.get(config_key)) cli_config = { "api_base": args.api_base, @@ -2785,6 +3210,14 @@ def main(argv: list[str] | None = None) -> int: max_strategies=max(args.max_strategies, 1), downstream_audiences=max(args.downstream_audiences, 1), downstream_pathways=max(args.downstream_pathways, 1), + require_user_assist=not args.no_user_assist, + select_audience_refs=split_selection_refs(args.select_audiences), + select_pathway_refs=split_selection_refs(args.select_pathways), + select_audience_vuln_refs=split_selection_refs(args.select_audience_vulns), + select_pathway_vuln_refs=split_selection_refs(args.select_pathway_vulns), + select_strategy_audience_refs=split_selection_refs(args.select_strategy_audiences), + select_strategy_pathway_refs=split_selection_refs(args.select_strategy_pathways), + strategy_target=str(args.strategy_target or ""), sync_step_status=not args.no_step_status, model_retries=max(args.model_retries, 0), verify_ssl=bool(args.verify_ssl), @@ -2838,6 +3271,8 @@ def main(argv: list[str] | None = None) -> int: final_payload = { "日志类型": "执行完成", "是否成功": "是", + "是否等待用户选择": yes_no(has_pending_assist(state)), + "待用户选择": state.get("_pendingUserAssist"), "执行模式": mode_label(args.mode), "状态文件": str(state_path), "摘要文件": str(ctx.summary_path) if ctx.summary_path else "", diff --git a/skills-data/xdfx-step2-pathway/SKILL.md b/skills-data/xdfx-step2-pathway/SKILL.md index 9c396ebb..be4d1f16 100644 --- a/skills-data/xdfx-step2-pathway/SKILL.md +++ b/skills-data/xdfx-step2-pathway/SKILL.md @@ -1,6 +1,6 @@ --- name: xdfx-step2-pathway -description: xdfx 拆分流程 step2,基于受众分析关键传播节点,选择最优3个节点,保存节点并建立受众关系。 +description: xdfx 拆分流程 step2,基于受众分析关键传播节点,发起用户协助选择节点,保存选中的节点并建立受众关系。 --- # Step2 关键传播节点分析与入库 @@ -20,7 +20,7 @@ python scripts/xdfx_flow.py --mode step2 --input xdfx-input.json --state xdfx-fl - 对已入库受众逐个分析关键传播节点。 - 自动解析候选账号和传播节点 JSON。 - 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。 -- 跨受众合并去重,选择并入库全局最优 3 个传播节点。 +- 跨受众合并去重后输出 `用户协助`,等待用户选择关键传播节点;用户选择后带 `--select-pathways 1,2,3` 重跑 Step2,才保存选中的节点。 - 保存传播节点。 - 保存传播节点与受众关系。 - 同步前端步骤状态 `step-analysis-keypoint` 与 `step-analysis-weakpoint`。 @@ -39,6 +39,8 @@ python scripts/xdfx_flow.py --mode step2 --input xdfx-input.json --state xdfx-fl 命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step2 的完成状态、当前传播节点入库数量和新增错误数,再决定是否继续下一步。 +如果输出 `用户协助 {...}`,必须暂停流程,把关键传播节点选项发给用户;用户选择后重跑 Step2 并追加 `--select-pathways ...`。 + 执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。 保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的传播节点和返回 ID。 diff --git a/skills-data/xdfx-step2-pathway/scripts/xdfx_flow.py b/skills-data/xdfx-step2-pathway/scripts/xdfx_flow.py index 8e62e51e..d385f95b 100644 --- a/skills-data/xdfx-step2-pathway/scripts/xdfx_flow.py +++ b/skills-data/xdfx-step2-pathway/scripts/xdfx_flow.py @@ -28,6 +28,10 @@ from typing import Any import xdfx_auth +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") # Keep every backend path in one table so endpoint drift is easy to audit. ENDPOINTS = { @@ -76,6 +80,7 @@ LOG_PREFIXES = { "operation": "操作反馈", "error": "错误反馈", "json_review": "JSON解析复核", + "assist": "用户协助", "save": "入库反馈", "step": "步骤反馈", "done": "执行完成", @@ -85,6 +90,7 @@ STATUS_LABELS = { "ok": "成功", "partial": "部分完成", "skipped": "已跳过", + "waiting_user": "等待用户选择", } STEP_NUMBER_LABELS = { @@ -226,6 +232,14 @@ class FlowContext: max_strategies: int = 3 downstream_audiences: int = 2 downstream_pathways: int = 2 + require_user_assist: bool = True + select_audience_refs: list[str] | None = None + select_pathway_refs: list[str] | None = None + select_audience_vuln_refs: list[str] | None = None + select_pathway_vuln_refs: list[str] | None = None + select_strategy_audience_refs: list[str] | None = None + select_strategy_pathway_refs: list[str] | None = None + strategy_target: str = "" sync_step_status: bool = True model_retries: int = 1 verify_ssl: bool = False @@ -284,6 +298,129 @@ def safe_compact(value: Any, limit: int = 1400) -> str: return compact(redact_sensitive(value), limit) +def split_selection_refs(raw: Any) -> list[str]: + """Parse user choices like '1,3' or ['1', '3'] into stable strings.""" + if raw is None: + return [] + if isinstance(raw, (list, tuple, set)): + parts: list[str] = [] + for item in raw: + parts.extend(split_selection_refs(item)) + return parts + text = str(raw).strip() + if not text: + return [] + if text.lower() in {"all", "全部"}: + return ["all"] + return [part.strip() for part in re.split(r"[,,;;\s]+", text) if part.strip()] + + +def normalized_ref(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")).lower() + + +def has_selection(refs: list[str] | None) -> bool: + return bool([ref for ref in refs or [] if str(ref).strip()]) + + +def clear_pending_assist(state: dict[str, Any]) -> None: + state.pop("_pendingUserAssist", None) + + +def has_pending_assist(state: dict[str, Any]) -> bool: + return isinstance(state.get("_pendingUserAssist"), dict) + + +def user_assist_command_hint(state: dict[str, Any], mode: str, flag_name: str, example: str) -> str: + command = f"python scripts/xdfx_flow.py --mode {mode} --state {state.get('_statePath') or 'xdfx-flow-state.json'}" + if state.get("_summaryPath"): + command += f" --summary {state.get('_summaryPath')}" + return f"{command} --{flag_name} {example}" + + +def emit_user_assist( + state: dict[str, Any], + stage: str, + *, + selection_key: str, + title: str, + prompt: str, + options: list[dict[str, Any]], + command_hint: str, + allow_multiple: bool = True, + max_count: int | None = None, + exclusive_note: str = "", +) -> None: + payload = { + "日志类型": "用户协助", + "阶段": stage_label(stage), + "步骤": feedback_step_label(stage), + "选择类型": selection_key, + "标题": title, + "提示": prompt, + "是否允许多选": yes_no(allow_multiple), + "最多选择数量": max_count, + "互斥规则": exclusive_note, + "选项": options, + "继续命令示例": command_hint, + "状态文件": state.get("_statePath", ""), + "摘要文件": state.get("_summaryPath", ""), + "时间": utc_now(), + } + state["_pendingUserAssist"] = { + "selectionKey": selection_key, + "stage": stage, + "title": title, + "commandHint": command_hint, + "optionCount": len(options), + "createdAt": payload["时间"], + } + print(LOG_PREFIXES["assist"] + " " + json.dumps(payload, ensure_ascii=False), flush=True) + record_event(state, stage, "等待用户协助选择", {"selectionKey": selection_key, "optionCount": len(options)}) + + +def ref_matches(ref: str, ordinal: int, aliases: list[Any]) -> bool: + normalized = normalized_ref(ref) + if normalized == "all": + return True + if normalized == str(ordinal): + return True + return any(normalized and normalized == normalized_ref(alias) for alias in aliases if alias is not None) + + +def select_by_refs( + items: list[Any], + refs: list[str], + alias_fn, + *, + max_count: int | None = None, +) -> tuple[list[Any], list[str]]: + if not refs: + selected = list(items) + elif any(normalized_ref(ref) in {"all", "全部"} for ref in refs): + selected = list(items) + else: + selected = [] + missing: list[str] = [] + for ref in refs: + found = None + for index, item in enumerate(items, start=1): + if ref_matches(ref, index, alias_fn(item, index)): + found = item + break + if found is None: + missing.append(ref) + continue + if found not in selected: + selected.append(found) + if max_count is not None: + return selected[:max_count], missing + return selected, missing + if max_count is not None: + selected = selected[:max_count] + return selected, [] + + def stable_json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) @@ -336,7 +473,7 @@ def load_json_file(path: str | Path) -> dict[str, Any]: p = Path(path) if not p.exists(): return {} - return json.loads(p.read_text(encoding="utf-8")) + return json.loads(p.read_text(encoding="utf-8-sig")) def save_json_file(path: str | Path, value: Any) -> None: @@ -369,6 +506,8 @@ def default_state() -> dict[str, Any]: "pathwayCandidates": [], "topPathways": [], "audiencesWithPathways": [], + "audienceVulnCandidates": {}, + "pathwayVulnCandidates": {}, "audienceVulns": {}, "pathwayVulns": {}, "audienceStrategies": [], @@ -2182,6 +2321,121 @@ def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, aud return selected +def audience_option(item: dict[str, Any], index: int) -> dict[str, Any]: + return { + "序号": index, + "受众目标": item.get("targetAudience"), + "推荐理由": compact(item.get("recReason", ""), 300), + "账号引用": item.get("userIndexes") or [], + } + + +def audience_aliases(item: dict[str, Any], index: int) -> list[Any]: + return [index, item.get("targetAudience"), item.get("rank")] + + +def pathway_option(item: dict[str, Any], index: int) -> dict[str, Any]: + return { + "序号": index, + "账号名称": item.get("accountName") or account_name(item.get("account") or {}), + "账号ID": item.get("accountId") or account_id(item.get("account") or {}), + "关联受众": item.get("relatedAudiences") or [], + "推荐理由": compact(item.get("recReason", ""), 300), + } + + +def pathway_aliases(item: dict[str, Any], index: int) -> list[Any]: + account = item.get("account") or item + return [ + index, + item.get("pathwayAccountId"), + item.get("pathwayId"), + item.get("accountId"), + item.get("accountName"), + account_id(account), + account_name(account), + ] + + +def vuln_candidate_entries( + items: list[dict[str, Any]], + candidate_store: dict[str, Any], + *, + audience: bool, +) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + seq = 1 + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + owner_name = item.get("targetAudience") if audience else item.get("accountName") + for index, vuln in enumerate(candidate_store.get(str(owner_id)) or [], start=1): + if not isinstance(vuln, dict): + continue + entries.append( + { + "seq": seq, + "ownerId": owner_id, + "ownerName": owner_name, + "localIndex": index, + "vuln": vuln, + } + ) + seq += 1 + return entries + + +def vuln_option(entry: dict[str, Any]) -> dict[str, Any]: + vuln = entry.get("vuln") or {} + return { + "序号": entry.get("seq"), + "对象ID": entry.get("ownerId"), + "对象名称": entry.get("ownerName"), + "本对象内序号": entry.get("localIndex"), + "脆弱点": vuln.get("vulnerabilities"), + "理由": compact(vuln.get("reason", ""), 260), + } + + +def vuln_aliases(entry: dict[str, Any], index: int) -> list[Any]: + vuln = entry.get("vuln") or {} + owner_id = entry.get("ownerId") + return [ + index, + entry.get("seq"), + f"{owner_id}:{entry.get('localIndex')}", + vuln.get("vulnerabilities"), + ] + + +def strategy_target_option(item: dict[str, Any], index: int, *, audience: bool) -> dict[str, Any]: + if audience: + return { + "序号": index, + "类型": "受众", + "对象ID": item.get("designId"), + "对象名称": item.get("targetAudience"), + "已入库脆弱点数量": len((item.get("_vulns") or [])), + "关联传播节点": item.get("downstreamPathways") or [], + } + return { + "序号": index, + "类型": "关键传播节点", + "对象ID": item.get("pathwayAccountId"), + "对象名称": item.get("accountName"), + "已入库脆弱点数量": len((item.get("_vulns") or [])), + "关联受众": item.get("relatedAudiences") or [], + } + + +def strategy_target_aliases(item: dict[str, Any], index: int, *, audience: bool) -> list[Any]: + return [ + index, + item.get("designId") if audience else item.get("pathwayAccountId"), + item.get("targetAudience") if audience else item.get("accountName"), + item.get("accountId") if not audience else None, + ] + + 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") @@ -2189,39 +2443,65 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None: if state.get("topAudiences") and not ctx.dry_run: record_event(state, stage, "topAudiences 已存在,跳过。如需重跑请清空 state 中该字段。") return - payload = { - "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。", - "otherinfo": {"方向": state.get("direction") or "U方向"}, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": f"任务背景和目的:{state['mubiao']}", - } - 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 + candidates = [item for item in state.get("audienceCandidates") or [] if isinstance(item, dict)] + accounts = [item for item in state.get("audienceAccounts") or [] if isinstance(item, dict)] + if not candidates: + payload = { + "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。", + "otherinfo": {"方向": state.get("direction") or "U方向"}, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": f"任务背景和目的:{state['mubiao']}", + } + 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。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。", - "zhanghao": accounts, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": state["mubiao"], - } - data, _, _ = call_model_json(ctx, state, "group", group_payload, stage + "/extract") - candidates = extract_audiences(data) - state["audienceCandidates"] = candidates - top = select_top_audiences(candidates, ctx.max_audiences) + # 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。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。", + "zhanghao": accounts, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": state["mubiao"], + } + data, _, _ = call_model_json(ctx, state, "group", group_payload, stage + "/extract") + candidates = extract_audiences(data) + state["audienceCandidates"] = candidates + if not candidates: + record_error(state, stage, "没有生成可供选择的受众候选") + return + if ctx.require_user_assist and not has_selection(ctx.select_audience_refs): + emit_user_assist( + state, + stage, + selection_key="audience", + title="请选择要入库并继续分析的受众", + prompt=f"可多选,最多建议选择 {ctx.max_audiences} 个。回复序号即可,例如:1,2,3。", + options=[audience_option(item, index) for index, item in enumerate(candidates, start=1)], + command_hint=user_assist_command_hint(state, "step1", "select-audiences", "1,2,3"), + allow_multiple=True, + max_count=ctx.max_audiences, + ) + return + selected_refs = ctx.select_audience_refs or [] + top, missing = select_by_refs(candidates, selected_refs, audience_aliases, max_count=ctx.max_audiences) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的受众未匹配:{', '.join(missing)}") + if not top: + record_error(state, stage, "没有选中任何受众,无法继续") + return saved: list[dict[str, Any]] = [] for item in top: # Save the audience first; account rows and downstream analyses depend @@ -2282,60 +2562,85 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: state["audiencesWithPathways"] = audience_pathway_summary(state) record_event(state, stage, "topPathways 已存在,跳过。如需重跑请清空 state 中该字段。") return - all_candidates: list[dict[str, Any]] = [] - for audience in audiences: - # Analyze pathway candidates in the context of each saved audience. - payload = { - "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。", - "otherinfo": { - "方向": state.get("direction") or "U方向", - "任务": "传播", - "平台": state.get("platform") or "fb", - "受众": {"名称": audience.get("targetAudience"), "理由": audience.get("recReason")}, - }, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": f"任务背景和目的:{state['mubiao']}", - } - 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。", - "zhanghao": accounts, - "history": [], - "stream": True, - "model_name": "liyou", - "temperature": 0.7, - "max_tokens": 1000, - "prompt_name": f"{audience.get('targetAudience')}:{audience.get('recReason')};", - } - data, _, _ = call_model_json(ctx, state, "spread", spread_payload, stage + "/extract") - refs = extract_pathway_refs(data) - for ref_item in refs: - account = resolve_account_ref(ref_item.get("ref"), accounts) - if account is None: - record_error(state, stage, f"传播节点引用 {ref_item.get('ref')} 未匹配账号", response=data) - continue - all_candidates.append( - { - "account": account, - "accountId": account_id(account), - "accountName": account_name(account), - "recReason": ref_item.get("recReason", ""), - "rank": ref_item.get("rank"), - "designIds": [audience["designId"]], - "relatedAudiences": [audience.get("targetAudience", "")], - } - ) - merged = merge_pathway_candidates(all_candidates) - state["pathwayCandidates"] = merged - top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways] + ranked = [item for item in state.get("pathwayCandidates") or [] if isinstance(item, dict)] + if not ranked: + all_candidates: list[dict[str, Any]] = [] + for audience in audiences: + # Analyze pathway candidates in the context of each saved audience. + payload = { + "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。", + "otherinfo": { + "方向": state.get("direction") or "U方向", + "任务": "传播", + "平台": state.get("platform") or "fb", + "受众": {"名称": audience.get("targetAudience"), "理由": audience.get("recReason")}, + }, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": f"任务背景和目的:{state['mubiao']}", + } + 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。", + "zhanghao": accounts, + "history": [], + "stream": True, + "model_name": "liyou", + "temperature": 0.7, + "max_tokens": 1000, + "prompt_name": f"{audience.get('targetAudience')}:{audience.get('recReason')};", + } + data, _, _ = call_model_json(ctx, state, "spread", spread_payload, stage + "/extract") + refs = extract_pathway_refs(data) + for ref_item in refs: + account = resolve_account_ref(ref_item.get("ref"), accounts) + if account is None: + record_error(state, stage, f"传播节点引用 {ref_item.get('ref')} 未匹配账号", response=data) + continue + all_candidates.append( + { + "account": account, + "accountId": account_id(account), + "accountName": account_name(account), + "recReason": ref_item.get("recReason", ""), + "rank": ref_item.get("rank"), + "designIds": [audience["designId"]], + "relatedAudiences": [audience.get("targetAudience", "")], + } + ) + merged = merge_pathway_candidates(all_candidates) + ranked = sorted(merged, key=pathway_score, reverse=True) + state["pathwayCandidates"] = ranked + if not ranked: + record_error(state, stage, "没有生成可供选择的关键传播节点候选") + return + if ctx.require_user_assist and not has_selection(ctx.select_pathway_refs): + emit_user_assist( + state, + stage, + selection_key="pathway", + title="请选择要入库并继续分析的关键传播节点", + prompt=f"可多选,最多建议选择 {ctx.max_pathways} 个。回复序号、账号名称或账号ID均可,例如:1,2,3。", + options=[pathway_option(item, index) for index, item in enumerate(ranked, start=1)], + command_hint=user_assist_command_hint(state, "step2", "select-pathways", "1,2,3"), + allow_multiple=True, + max_count=ctx.max_pathways, + ) + return + top, missing = select_by_refs(ranked, ctx.select_pathway_refs or [], pathway_aliases, max_count=ctx.max_pathways) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的关键传播节点未匹配:{', '.join(missing)}") + if not top: + record_error(state, stage, "没有选中任何关键传播节点,无法继续") + return saved: list[dict[str, Any]] = [] for item in top: # Save the account row before writing the design-pathway relation. @@ -2395,7 +2700,10 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> stage = "step3-audience-vulns" if audience else "step4-pathway-vulns" items = downstream_items(ctx, state, stage, audience=audience) output_key = "audienceVulns" if audience else "pathwayVulns" + candidate_key = "audienceVulnCandidates" if audience else "pathwayVulnCandidates" + select_refs = ctx.select_audience_vuln_refs if audience else ctx.select_pathway_vuln_refs state.setdefault(output_key, {}) + candidate_store = state.setdefault(candidate_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") @@ -2403,6 +2711,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> continue if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run: continue + if str(owner_id) in candidate_store and candidate_store[str(owner_id)]: + continue zhanghao = selected_accounts_for_item(item, audience=audience) prompt = ( "你是中国对台湾方向的认知战专家,你要传播行动要求的叙事主题。请基于该受众目标的画像、关注议题、社会分工定位、利益诉求与上一轮账号佐证,分析该受众目标有哪些脆弱点可以成为后续策略设计的关键要素。请输出理由、脆弱点1、理由1、脆弱点2、理由2、脆弱点3、理由3,只返回JSON。" @@ -2422,8 +2732,43 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> } data, _, _ = call_model_json(ctx, state, "cuiruo", payload, stage + "/extract") vulns = extract_vulns(data, ctx.max_vulns) + candidate_store[str(owner_id)] = vulns + entries = vuln_candidate_entries(items, candidate_store, audience=audience) + if not entries: + record_error(state, stage, "没有生成可供选择的脆弱点候选") + return + if ctx.require_user_assist and not has_selection(select_refs): + flag = "select-audience-vulns" if audience else "select-pathway-vulns" + emit_user_assist( + state, + stage, + selection_key="audience_vuln" if audience else "pathway_vuln", + title="请选择要入库的受众脆弱点" if audience else "请选择要入库的传播节点脆弱点", + prompt="可多选。回复全局序号即可,例如:1,2,4;也可用 对象ID:本对象内序号。", + options=[vuln_option(entry) for entry in entries], + command_hint=user_assist_command_hint(state, "step3" if audience else "step4", flag, "1,2,4"), + allow_multiple=True, + ) + return + selected_entries, missing = select_by_refs(entries, select_refs or [], vuln_aliases) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的脆弱点未匹配:{', '.join(missing)}") + selected_by_owner: dict[str, list[dict[str, Any]]] = {} + for entry in selected_entries: + owner_key = str(entry.get("ownerId")) + selected_by_owner.setdefault(owner_key, []).append(entry.get("vuln") or {}) + if not selected_by_owner: + record_error(state, stage, "没有选中任何脆弱点,无法继续") + return + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + if not owner_id: + continue + if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run: + continue saved: list[dict[str, Any]] = [] - for vuln in vulns: + for vuln in selected_by_owner.get(str(owner_id), []): # The two weak-point tables have nearly identical payloads but # different foreign-key names. if audience: @@ -2475,8 +2820,53 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool """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" + if audience and (has_selection(ctx.select_strategy_pathway_refs) or ctx.strategy_target == "pathway"): + clear_pending_assist(state) + record_error(state, stage, "策略生成目标类型冲突:Step5 只能选择受众,不能同时选择关键传播节点") + return + if not audience and (has_selection(ctx.select_strategy_audience_refs) or ctx.strategy_target == "audience"): + clear_pending_assist(state) + record_error(state, stage, "策略生成目标类型冲突:Step6 只能选择关键传播节点,不能同时选择受众") + return items = downstream_items(ctx, state, stage, audience=audience) vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {} + selectable_items: list[dict[str, Any]] = [] + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + vulns = vulns_by_owner.get(str(owner_id)) or [] + if vulns: + selectable_items.append({**item, "_vulns": vulns}) + select_refs = ctx.select_strategy_audience_refs if audience else ctx.select_strategy_pathway_refs + if selectable_items and ctx.require_user_assist and not has_selection(select_refs): + flag = "select-strategy-audiences" if audience else "select-strategy-pathways" + emit_user_assist( + state, + stage, + selection_key="strategy_audience" if audience else "strategy_pathway", + title="请选择要生成策略的受众" if audience else "请选择要生成策略的关键传播节点", + prompt=( + "可多选,但本步骤只能选择受众,不能混选关键传播节点。回复序号即可,例如:1,2。" + if audience + else "可多选,但本步骤只能选择关键传播节点,不能混选受众。回复序号即可,例如:1,2。" + ), + options=[strategy_target_option(item, index, audience=audience) for index, item in enumerate(selectable_items, start=1)], + command_hint=user_assist_command_hint(state, "step5" if audience else "step6", flag, "1,2"), + allow_multiple=True, + exclusive_note="受众策略和关键传播节点策略不能在同一步命令中交叉选择。", + ) + return + selected_items, missing = select_by_refs( + selectable_items, + select_refs or [], + lambda item, index: strategy_target_aliases(item, index, audience=audience), + ) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的策略生成对象未匹配:{', '.join(missing)}") + items = selected_items + if not items: + record_error(state, stage, "没有选中任何具备脆弱点的策略生成对象,无法继续") + return 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)} @@ -2583,14 +2973,15 @@ def emit_step_feedback( # 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") + final_status = status or ("waiting_user" if has_pending_assist(state) else ("partial" if new_errors else "ok")) + final_note = note or (state.get("_pendingUserAssist", {}).get("title", "") if has_pending_assist(state) else "") payload = { "日志类型": "步骤反馈", "步骤": STEP_NUMBER_LABELS.get(step, step), "步骤名称": STEP_LABELS.get(step, step), "状态": STATUS_LABELS.get(final_status, final_status), "执行状态": status_label(final_status), - "备注": note, + "备注": final_note, "本步骤新增错误数": new_errors, "累计错误数": total_errors, "累计入库数量": state_counts(state), @@ -2638,15 +3029,27 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None: for dry-runs and unattended local troubleshooting. """ run_step_with_feedback(ctx, state, "step1") + if has_pending_assist(state): + return run_step_with_feedback(ctx, state, "step2") + if has_pending_assist(state): + return run_step_with_feedback(ctx, state, "step3") + if has_pending_assist(state): + return if state.get("topPathways"): run_step_with_feedback(ctx, state, "step4") + if has_pending_assist(state): + return else: skip_step_with_feedback(ctx, state, "step4", "没有已入库关键传播节点") run_step_with_feedback(ctx, state, "step5") + if has_pending_assist(state): + return if state.get("topPathways"): run_step_with_feedback(ctx, state, "step6") + if has_pending_assist(state): + return else: skip_step_with_feedback(ctx, state, "step6", "没有已入库关键传播节点") @@ -2722,6 +3125,14 @@ def build_arg_parser() -> argparse.ArgumentParser: 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。") + parser.add_argument("--no-user-assist", action="store_true", help="关闭每步用户协助选择,使用脚本默认排序自动继续。") + parser.add_argument("--select-audiences", default="", help="Step1 用户选择的受众序号/名称,逗号分隔,例如 1,2,3。") + parser.add_argument("--select-pathways", default="", help="Step2 用户选择的关键传播节点序号/账号名/账号ID,逗号分隔。") + parser.add_argument("--select-audience-vulns", default="", help="Step3 用户选择的受众脆弱点序号,逗号分隔。") + parser.add_argument("--select-pathway-vulns", default="", help="Step4 用户选择的传播节点脆弱点序号,逗号分隔。") + parser.add_argument("--strategy-target", choices=["", "audience", "pathway"], default="", help="策略生成目标类型;Step5 只能 audience,Step6 只能 pathway。") + parser.add_argument("--select-strategy-audiences", default="", help="Step5 用户选择的受众策略生成对象序号/受众ID/名称,逗号分隔。") + parser.add_argument("--select-strategy-pathways", default="", help="Step6 用户选择的传播节点策略生成对象序号/节点ID/账号名,逗号分隔。") return parser @@ -2758,6 +3169,20 @@ def main(argv: list[str] | None = None) -> int: 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 + if config.get("require_user_assist") is False or config.get("user_assist") is False: + args.no_user_assist = True + selection_map = { + "select_audiences": "select_audiences", + "select_pathways": "select_pathways", + "select_audience_vulns": "select_audience_vulns", + "select_pathway_vulns": "select_pathway_vulns", + "select_strategy_audiences": "select_strategy_audiences", + "select_strategy_pathways": "select_strategy_pathways", + "strategy_target": "strategy_target", + } + for config_key, arg_name in selection_map.items(): + if config.get(config_key) is not None: + setattr(args, arg_name, config.get(config_key)) cli_config = { "api_base": args.api_base, @@ -2785,6 +3210,14 @@ def main(argv: list[str] | None = None) -> int: max_strategies=max(args.max_strategies, 1), downstream_audiences=max(args.downstream_audiences, 1), downstream_pathways=max(args.downstream_pathways, 1), + require_user_assist=not args.no_user_assist, + select_audience_refs=split_selection_refs(args.select_audiences), + select_pathway_refs=split_selection_refs(args.select_pathways), + select_audience_vuln_refs=split_selection_refs(args.select_audience_vulns), + select_pathway_vuln_refs=split_selection_refs(args.select_pathway_vulns), + select_strategy_audience_refs=split_selection_refs(args.select_strategy_audiences), + select_strategy_pathway_refs=split_selection_refs(args.select_strategy_pathways), + strategy_target=str(args.strategy_target or ""), sync_step_status=not args.no_step_status, model_retries=max(args.model_retries, 0), verify_ssl=bool(args.verify_ssl), @@ -2838,6 +3271,8 @@ def main(argv: list[str] | None = None) -> int: final_payload = { "日志类型": "执行完成", "是否成功": "是", + "是否等待用户选择": yes_no(has_pending_assist(state)), + "待用户选择": state.get("_pendingUserAssist"), "执行模式": mode_label(args.mode), "状态文件": str(state_path), "摘要文件": str(ctx.summary_path) if ctx.summary_path else "", diff --git a/skills-data/xdfx-step3-audience-vulns/SKILL.md b/skills-data/xdfx-step3-audience-vulns/SKILL.md index 552b6e5d..c6ac83a4 100644 --- a/skills-data/xdfx-step3-audience-vulns/SKILL.md +++ b/skills-data/xdfx-step3-audience-vulns/SKILL.md @@ -1,11 +1,11 @@ --- name: xdfx-step3-audience-vulns -description: xdfx 拆分流程 step3,为已入库受众抽取并保存最多3个脆弱点。 +description: xdfx 拆分流程 step3,为已分析出关键传播节点的受众抽取脆弱点,发起用户协助选择后保存。 --- # Step3 受众脆弱点抽取与入库 -前置条件:`xdfx-flow-state.json` 中已有 Step1 写入的 `topAudiences[*].designId`。 +前置条件:`xdfx-flow-state.json` 中已有 Step1 写入的 `topAudiences[*].designId`,且 Step2 已写入关联这些受众的 `topPathways`。 不要手写接口调用,直接运行: @@ -17,8 +17,8 @@ python scripts/xdfx_flow.py --mode step3 --input xdfx-input.json --state xdfx-fl 本步骤由脚本完成: -- 从已入库受众中默认选择前 2 个继续分析;可用 `--downstream-audiences 1` 调成只处理 1 个。 -- 对每个被选中的受众抽取并入库最多 3 个脆弱点。 +- 只从“已在 Step2 分析出并关联关键传播节点”的受众中抽取脆弱点候选。 +- 输出 `用户协助`,等待用户选择受众脆弱点;用户选择后带 `--select-audience-vulns 1,2,4` 重跑 Step3,才保存选中的脆弱点。 - 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。 - 自动修复和解析模型 JSON。 - 调用受众脆弱点保存接口。 @@ -39,6 +39,8 @@ python scripts/xdfx_flow.py --mode step3 --input xdfx-input.json --state xdfx-fl 命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step3 的完成状态、当前受众脆弱点数量和新增错误数,再决定是否继续下一步。 +如果输出 `用户协助 {...}`,必须暂停流程,把受众脆弱点选项发给用户;用户选择后重跑 Step3 并追加 `--select-audience-vulns ...`。 + 执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。 保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的受众脆弱点和返回 ID。 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 8e62e51e..d385f95b 100644 --- a/skills-data/xdfx-step3-audience-vulns/scripts/xdfx_flow.py +++ b/skills-data/xdfx-step3-audience-vulns/scripts/xdfx_flow.py @@ -28,6 +28,10 @@ from typing import Any import xdfx_auth +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") # Keep every backend path in one table so endpoint drift is easy to audit. ENDPOINTS = { @@ -76,6 +80,7 @@ LOG_PREFIXES = { "operation": "操作反馈", "error": "错误反馈", "json_review": "JSON解析复核", + "assist": "用户协助", "save": "入库反馈", "step": "步骤反馈", "done": "执行完成", @@ -85,6 +90,7 @@ STATUS_LABELS = { "ok": "成功", "partial": "部分完成", "skipped": "已跳过", + "waiting_user": "等待用户选择", } STEP_NUMBER_LABELS = { @@ -226,6 +232,14 @@ class FlowContext: max_strategies: int = 3 downstream_audiences: int = 2 downstream_pathways: int = 2 + require_user_assist: bool = True + select_audience_refs: list[str] | None = None + select_pathway_refs: list[str] | None = None + select_audience_vuln_refs: list[str] | None = None + select_pathway_vuln_refs: list[str] | None = None + select_strategy_audience_refs: list[str] | None = None + select_strategy_pathway_refs: list[str] | None = None + strategy_target: str = "" sync_step_status: bool = True model_retries: int = 1 verify_ssl: bool = False @@ -284,6 +298,129 @@ def safe_compact(value: Any, limit: int = 1400) -> str: return compact(redact_sensitive(value), limit) +def split_selection_refs(raw: Any) -> list[str]: + """Parse user choices like '1,3' or ['1', '3'] into stable strings.""" + if raw is None: + return [] + if isinstance(raw, (list, tuple, set)): + parts: list[str] = [] + for item in raw: + parts.extend(split_selection_refs(item)) + return parts + text = str(raw).strip() + if not text: + return [] + if text.lower() in {"all", "全部"}: + return ["all"] + return [part.strip() for part in re.split(r"[,,;;\s]+", text) if part.strip()] + + +def normalized_ref(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")).lower() + + +def has_selection(refs: list[str] | None) -> bool: + return bool([ref for ref in refs or [] if str(ref).strip()]) + + +def clear_pending_assist(state: dict[str, Any]) -> None: + state.pop("_pendingUserAssist", None) + + +def has_pending_assist(state: dict[str, Any]) -> bool: + return isinstance(state.get("_pendingUserAssist"), dict) + + +def user_assist_command_hint(state: dict[str, Any], mode: str, flag_name: str, example: str) -> str: + command = f"python scripts/xdfx_flow.py --mode {mode} --state {state.get('_statePath') or 'xdfx-flow-state.json'}" + if state.get("_summaryPath"): + command += f" --summary {state.get('_summaryPath')}" + return f"{command} --{flag_name} {example}" + + +def emit_user_assist( + state: dict[str, Any], + stage: str, + *, + selection_key: str, + title: str, + prompt: str, + options: list[dict[str, Any]], + command_hint: str, + allow_multiple: bool = True, + max_count: int | None = None, + exclusive_note: str = "", +) -> None: + payload = { + "日志类型": "用户协助", + "阶段": stage_label(stage), + "步骤": feedback_step_label(stage), + "选择类型": selection_key, + "标题": title, + "提示": prompt, + "是否允许多选": yes_no(allow_multiple), + "最多选择数量": max_count, + "互斥规则": exclusive_note, + "选项": options, + "继续命令示例": command_hint, + "状态文件": state.get("_statePath", ""), + "摘要文件": state.get("_summaryPath", ""), + "时间": utc_now(), + } + state["_pendingUserAssist"] = { + "selectionKey": selection_key, + "stage": stage, + "title": title, + "commandHint": command_hint, + "optionCount": len(options), + "createdAt": payload["时间"], + } + print(LOG_PREFIXES["assist"] + " " + json.dumps(payload, ensure_ascii=False), flush=True) + record_event(state, stage, "等待用户协助选择", {"selectionKey": selection_key, "optionCount": len(options)}) + + +def ref_matches(ref: str, ordinal: int, aliases: list[Any]) -> bool: + normalized = normalized_ref(ref) + if normalized == "all": + return True + if normalized == str(ordinal): + return True + return any(normalized and normalized == normalized_ref(alias) for alias in aliases if alias is not None) + + +def select_by_refs( + items: list[Any], + refs: list[str], + alias_fn, + *, + max_count: int | None = None, +) -> tuple[list[Any], list[str]]: + if not refs: + selected = list(items) + elif any(normalized_ref(ref) in {"all", "全部"} for ref in refs): + selected = list(items) + else: + selected = [] + missing: list[str] = [] + for ref in refs: + found = None + for index, item in enumerate(items, start=1): + if ref_matches(ref, index, alias_fn(item, index)): + found = item + break + if found is None: + missing.append(ref) + continue + if found not in selected: + selected.append(found) + if max_count is not None: + return selected[:max_count], missing + return selected, missing + if max_count is not None: + selected = selected[:max_count] + return selected, [] + + def stable_json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) @@ -336,7 +473,7 @@ def load_json_file(path: str | Path) -> dict[str, Any]: p = Path(path) if not p.exists(): return {} - return json.loads(p.read_text(encoding="utf-8")) + return json.loads(p.read_text(encoding="utf-8-sig")) def save_json_file(path: str | Path, value: Any) -> None: @@ -369,6 +506,8 @@ def default_state() -> dict[str, Any]: "pathwayCandidates": [], "topPathways": [], "audiencesWithPathways": [], + "audienceVulnCandidates": {}, + "pathwayVulnCandidates": {}, "audienceVulns": {}, "pathwayVulns": {}, "audienceStrategies": [], @@ -2182,6 +2321,121 @@ def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, aud return selected +def audience_option(item: dict[str, Any], index: int) -> dict[str, Any]: + return { + "序号": index, + "受众目标": item.get("targetAudience"), + "推荐理由": compact(item.get("recReason", ""), 300), + "账号引用": item.get("userIndexes") or [], + } + + +def audience_aliases(item: dict[str, Any], index: int) -> list[Any]: + return [index, item.get("targetAudience"), item.get("rank")] + + +def pathway_option(item: dict[str, Any], index: int) -> dict[str, Any]: + return { + "序号": index, + "账号名称": item.get("accountName") or account_name(item.get("account") or {}), + "账号ID": item.get("accountId") or account_id(item.get("account") or {}), + "关联受众": item.get("relatedAudiences") or [], + "推荐理由": compact(item.get("recReason", ""), 300), + } + + +def pathway_aliases(item: dict[str, Any], index: int) -> list[Any]: + account = item.get("account") or item + return [ + index, + item.get("pathwayAccountId"), + item.get("pathwayId"), + item.get("accountId"), + item.get("accountName"), + account_id(account), + account_name(account), + ] + + +def vuln_candidate_entries( + items: list[dict[str, Any]], + candidate_store: dict[str, Any], + *, + audience: bool, +) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + seq = 1 + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + owner_name = item.get("targetAudience") if audience else item.get("accountName") + for index, vuln in enumerate(candidate_store.get(str(owner_id)) or [], start=1): + if not isinstance(vuln, dict): + continue + entries.append( + { + "seq": seq, + "ownerId": owner_id, + "ownerName": owner_name, + "localIndex": index, + "vuln": vuln, + } + ) + seq += 1 + return entries + + +def vuln_option(entry: dict[str, Any]) -> dict[str, Any]: + vuln = entry.get("vuln") or {} + return { + "序号": entry.get("seq"), + "对象ID": entry.get("ownerId"), + "对象名称": entry.get("ownerName"), + "本对象内序号": entry.get("localIndex"), + "脆弱点": vuln.get("vulnerabilities"), + "理由": compact(vuln.get("reason", ""), 260), + } + + +def vuln_aliases(entry: dict[str, Any], index: int) -> list[Any]: + vuln = entry.get("vuln") or {} + owner_id = entry.get("ownerId") + return [ + index, + entry.get("seq"), + f"{owner_id}:{entry.get('localIndex')}", + vuln.get("vulnerabilities"), + ] + + +def strategy_target_option(item: dict[str, Any], index: int, *, audience: bool) -> dict[str, Any]: + if audience: + return { + "序号": index, + "类型": "受众", + "对象ID": item.get("designId"), + "对象名称": item.get("targetAudience"), + "已入库脆弱点数量": len((item.get("_vulns") or [])), + "关联传播节点": item.get("downstreamPathways") or [], + } + return { + "序号": index, + "类型": "关键传播节点", + "对象ID": item.get("pathwayAccountId"), + "对象名称": item.get("accountName"), + "已入库脆弱点数量": len((item.get("_vulns") or [])), + "关联受众": item.get("relatedAudiences") or [], + } + + +def strategy_target_aliases(item: dict[str, Any], index: int, *, audience: bool) -> list[Any]: + return [ + index, + item.get("designId") if audience else item.get("pathwayAccountId"), + item.get("targetAudience") if audience else item.get("accountName"), + item.get("accountId") if not audience else None, + ] + + 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") @@ -2189,39 +2443,65 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None: if state.get("topAudiences") and not ctx.dry_run: record_event(state, stage, "topAudiences 已存在,跳过。如需重跑请清空 state 中该字段。") return - payload = { - "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。", - "otherinfo": {"方向": state.get("direction") or "U方向"}, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": f"任务背景和目的:{state['mubiao']}", - } - 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 + candidates = [item for item in state.get("audienceCandidates") or [] if isinstance(item, dict)] + accounts = [item for item in state.get("audienceAccounts") or [] if isinstance(item, dict)] + if not candidates: + payload = { + "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。", + "otherinfo": {"方向": state.get("direction") or "U方向"}, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": f"任务背景和目的:{state['mubiao']}", + } + 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。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。", - "zhanghao": accounts, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": state["mubiao"], - } - data, _, _ = call_model_json(ctx, state, "group", group_payload, stage + "/extract") - candidates = extract_audiences(data) - state["audienceCandidates"] = candidates - top = select_top_audiences(candidates, ctx.max_audiences) + # 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。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。", + "zhanghao": accounts, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": state["mubiao"], + } + data, _, _ = call_model_json(ctx, state, "group", group_payload, stage + "/extract") + candidates = extract_audiences(data) + state["audienceCandidates"] = candidates + if not candidates: + record_error(state, stage, "没有生成可供选择的受众候选") + return + if ctx.require_user_assist and not has_selection(ctx.select_audience_refs): + emit_user_assist( + state, + stage, + selection_key="audience", + title="请选择要入库并继续分析的受众", + prompt=f"可多选,最多建议选择 {ctx.max_audiences} 个。回复序号即可,例如:1,2,3。", + options=[audience_option(item, index) for index, item in enumerate(candidates, start=1)], + command_hint=user_assist_command_hint(state, "step1", "select-audiences", "1,2,3"), + allow_multiple=True, + max_count=ctx.max_audiences, + ) + return + selected_refs = ctx.select_audience_refs or [] + top, missing = select_by_refs(candidates, selected_refs, audience_aliases, max_count=ctx.max_audiences) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的受众未匹配:{', '.join(missing)}") + if not top: + record_error(state, stage, "没有选中任何受众,无法继续") + return saved: list[dict[str, Any]] = [] for item in top: # Save the audience first; account rows and downstream analyses depend @@ -2282,60 +2562,85 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: state["audiencesWithPathways"] = audience_pathway_summary(state) record_event(state, stage, "topPathways 已存在,跳过。如需重跑请清空 state 中该字段。") return - all_candidates: list[dict[str, Any]] = [] - for audience in audiences: - # Analyze pathway candidates in the context of each saved audience. - payload = { - "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。", - "otherinfo": { - "方向": state.get("direction") or "U方向", - "任务": "传播", - "平台": state.get("platform") or "fb", - "受众": {"名称": audience.get("targetAudience"), "理由": audience.get("recReason")}, - }, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": f"任务背景和目的:{state['mubiao']}", - } - 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。", - "zhanghao": accounts, - "history": [], - "stream": True, - "model_name": "liyou", - "temperature": 0.7, - "max_tokens": 1000, - "prompt_name": f"{audience.get('targetAudience')}:{audience.get('recReason')};", - } - data, _, _ = call_model_json(ctx, state, "spread", spread_payload, stage + "/extract") - refs = extract_pathway_refs(data) - for ref_item in refs: - account = resolve_account_ref(ref_item.get("ref"), accounts) - if account is None: - record_error(state, stage, f"传播节点引用 {ref_item.get('ref')} 未匹配账号", response=data) - continue - all_candidates.append( - { - "account": account, - "accountId": account_id(account), - "accountName": account_name(account), - "recReason": ref_item.get("recReason", ""), - "rank": ref_item.get("rank"), - "designIds": [audience["designId"]], - "relatedAudiences": [audience.get("targetAudience", "")], - } - ) - merged = merge_pathway_candidates(all_candidates) - state["pathwayCandidates"] = merged - top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways] + ranked = [item for item in state.get("pathwayCandidates") or [] if isinstance(item, dict)] + if not ranked: + all_candidates: list[dict[str, Any]] = [] + for audience in audiences: + # Analyze pathway candidates in the context of each saved audience. + payload = { + "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。", + "otherinfo": { + "方向": state.get("direction") or "U方向", + "任务": "传播", + "平台": state.get("platform") or "fb", + "受众": {"名称": audience.get("targetAudience"), "理由": audience.get("recReason")}, + }, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": f"任务背景和目的:{state['mubiao']}", + } + 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。", + "zhanghao": accounts, + "history": [], + "stream": True, + "model_name": "liyou", + "temperature": 0.7, + "max_tokens": 1000, + "prompt_name": f"{audience.get('targetAudience')}:{audience.get('recReason')};", + } + data, _, _ = call_model_json(ctx, state, "spread", spread_payload, stage + "/extract") + refs = extract_pathway_refs(data) + for ref_item in refs: + account = resolve_account_ref(ref_item.get("ref"), accounts) + if account is None: + record_error(state, stage, f"传播节点引用 {ref_item.get('ref')} 未匹配账号", response=data) + continue + all_candidates.append( + { + "account": account, + "accountId": account_id(account), + "accountName": account_name(account), + "recReason": ref_item.get("recReason", ""), + "rank": ref_item.get("rank"), + "designIds": [audience["designId"]], + "relatedAudiences": [audience.get("targetAudience", "")], + } + ) + merged = merge_pathway_candidates(all_candidates) + ranked = sorted(merged, key=pathway_score, reverse=True) + state["pathwayCandidates"] = ranked + if not ranked: + record_error(state, stage, "没有生成可供选择的关键传播节点候选") + return + if ctx.require_user_assist and not has_selection(ctx.select_pathway_refs): + emit_user_assist( + state, + stage, + selection_key="pathway", + title="请选择要入库并继续分析的关键传播节点", + prompt=f"可多选,最多建议选择 {ctx.max_pathways} 个。回复序号、账号名称或账号ID均可,例如:1,2,3。", + options=[pathway_option(item, index) for index, item in enumerate(ranked, start=1)], + command_hint=user_assist_command_hint(state, "step2", "select-pathways", "1,2,3"), + allow_multiple=True, + max_count=ctx.max_pathways, + ) + return + top, missing = select_by_refs(ranked, ctx.select_pathway_refs or [], pathway_aliases, max_count=ctx.max_pathways) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的关键传播节点未匹配:{', '.join(missing)}") + if not top: + record_error(state, stage, "没有选中任何关键传播节点,无法继续") + return saved: list[dict[str, Any]] = [] for item in top: # Save the account row before writing the design-pathway relation. @@ -2395,7 +2700,10 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> stage = "step3-audience-vulns" if audience else "step4-pathway-vulns" items = downstream_items(ctx, state, stage, audience=audience) output_key = "audienceVulns" if audience else "pathwayVulns" + candidate_key = "audienceVulnCandidates" if audience else "pathwayVulnCandidates" + select_refs = ctx.select_audience_vuln_refs if audience else ctx.select_pathway_vuln_refs state.setdefault(output_key, {}) + candidate_store = state.setdefault(candidate_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") @@ -2403,6 +2711,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> continue if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run: continue + if str(owner_id) in candidate_store and candidate_store[str(owner_id)]: + continue zhanghao = selected_accounts_for_item(item, audience=audience) prompt = ( "你是中国对台湾方向的认知战专家,你要传播行动要求的叙事主题。请基于该受众目标的画像、关注议题、社会分工定位、利益诉求与上一轮账号佐证,分析该受众目标有哪些脆弱点可以成为后续策略设计的关键要素。请输出理由、脆弱点1、理由1、脆弱点2、理由2、脆弱点3、理由3,只返回JSON。" @@ -2422,8 +2732,43 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> } data, _, _ = call_model_json(ctx, state, "cuiruo", payload, stage + "/extract") vulns = extract_vulns(data, ctx.max_vulns) + candidate_store[str(owner_id)] = vulns + entries = vuln_candidate_entries(items, candidate_store, audience=audience) + if not entries: + record_error(state, stage, "没有生成可供选择的脆弱点候选") + return + if ctx.require_user_assist and not has_selection(select_refs): + flag = "select-audience-vulns" if audience else "select-pathway-vulns" + emit_user_assist( + state, + stage, + selection_key="audience_vuln" if audience else "pathway_vuln", + title="请选择要入库的受众脆弱点" if audience else "请选择要入库的传播节点脆弱点", + prompt="可多选。回复全局序号即可,例如:1,2,4;也可用 对象ID:本对象内序号。", + options=[vuln_option(entry) for entry in entries], + command_hint=user_assist_command_hint(state, "step3" if audience else "step4", flag, "1,2,4"), + allow_multiple=True, + ) + return + selected_entries, missing = select_by_refs(entries, select_refs or [], vuln_aliases) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的脆弱点未匹配:{', '.join(missing)}") + selected_by_owner: dict[str, list[dict[str, Any]]] = {} + for entry in selected_entries: + owner_key = str(entry.get("ownerId")) + selected_by_owner.setdefault(owner_key, []).append(entry.get("vuln") or {}) + if not selected_by_owner: + record_error(state, stage, "没有选中任何脆弱点,无法继续") + return + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + if not owner_id: + continue + if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run: + continue saved: list[dict[str, Any]] = [] - for vuln in vulns: + for vuln in selected_by_owner.get(str(owner_id), []): # The two weak-point tables have nearly identical payloads but # different foreign-key names. if audience: @@ -2475,8 +2820,53 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool """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" + if audience and (has_selection(ctx.select_strategy_pathway_refs) or ctx.strategy_target == "pathway"): + clear_pending_assist(state) + record_error(state, stage, "策略生成目标类型冲突:Step5 只能选择受众,不能同时选择关键传播节点") + return + if not audience and (has_selection(ctx.select_strategy_audience_refs) or ctx.strategy_target == "audience"): + clear_pending_assist(state) + record_error(state, stage, "策略生成目标类型冲突:Step6 只能选择关键传播节点,不能同时选择受众") + return items = downstream_items(ctx, state, stage, audience=audience) vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {} + selectable_items: list[dict[str, Any]] = [] + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + vulns = vulns_by_owner.get(str(owner_id)) or [] + if vulns: + selectable_items.append({**item, "_vulns": vulns}) + select_refs = ctx.select_strategy_audience_refs if audience else ctx.select_strategy_pathway_refs + if selectable_items and ctx.require_user_assist and not has_selection(select_refs): + flag = "select-strategy-audiences" if audience else "select-strategy-pathways" + emit_user_assist( + state, + stage, + selection_key="strategy_audience" if audience else "strategy_pathway", + title="请选择要生成策略的受众" if audience else "请选择要生成策略的关键传播节点", + prompt=( + "可多选,但本步骤只能选择受众,不能混选关键传播节点。回复序号即可,例如:1,2。" + if audience + else "可多选,但本步骤只能选择关键传播节点,不能混选受众。回复序号即可,例如:1,2。" + ), + options=[strategy_target_option(item, index, audience=audience) for index, item in enumerate(selectable_items, start=1)], + command_hint=user_assist_command_hint(state, "step5" if audience else "step6", flag, "1,2"), + allow_multiple=True, + exclusive_note="受众策略和关键传播节点策略不能在同一步命令中交叉选择。", + ) + return + selected_items, missing = select_by_refs( + selectable_items, + select_refs or [], + lambda item, index: strategy_target_aliases(item, index, audience=audience), + ) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的策略生成对象未匹配:{', '.join(missing)}") + items = selected_items + if not items: + record_error(state, stage, "没有选中任何具备脆弱点的策略生成对象,无法继续") + return 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)} @@ -2583,14 +2973,15 @@ def emit_step_feedback( # 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") + final_status = status or ("waiting_user" if has_pending_assist(state) else ("partial" if new_errors else "ok")) + final_note = note or (state.get("_pendingUserAssist", {}).get("title", "") if has_pending_assist(state) else "") payload = { "日志类型": "步骤反馈", "步骤": STEP_NUMBER_LABELS.get(step, step), "步骤名称": STEP_LABELS.get(step, step), "状态": STATUS_LABELS.get(final_status, final_status), "执行状态": status_label(final_status), - "备注": note, + "备注": final_note, "本步骤新增错误数": new_errors, "累计错误数": total_errors, "累计入库数量": state_counts(state), @@ -2638,15 +3029,27 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None: for dry-runs and unattended local troubleshooting. """ run_step_with_feedback(ctx, state, "step1") + if has_pending_assist(state): + return run_step_with_feedback(ctx, state, "step2") + if has_pending_assist(state): + return run_step_with_feedback(ctx, state, "step3") + if has_pending_assist(state): + return if state.get("topPathways"): run_step_with_feedback(ctx, state, "step4") + if has_pending_assist(state): + return else: skip_step_with_feedback(ctx, state, "step4", "没有已入库关键传播节点") run_step_with_feedback(ctx, state, "step5") + if has_pending_assist(state): + return if state.get("topPathways"): run_step_with_feedback(ctx, state, "step6") + if has_pending_assist(state): + return else: skip_step_with_feedback(ctx, state, "step6", "没有已入库关键传播节点") @@ -2722,6 +3125,14 @@ def build_arg_parser() -> argparse.ArgumentParser: 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。") + parser.add_argument("--no-user-assist", action="store_true", help="关闭每步用户协助选择,使用脚本默认排序自动继续。") + parser.add_argument("--select-audiences", default="", help="Step1 用户选择的受众序号/名称,逗号分隔,例如 1,2,3。") + parser.add_argument("--select-pathways", default="", help="Step2 用户选择的关键传播节点序号/账号名/账号ID,逗号分隔。") + parser.add_argument("--select-audience-vulns", default="", help="Step3 用户选择的受众脆弱点序号,逗号分隔。") + parser.add_argument("--select-pathway-vulns", default="", help="Step4 用户选择的传播节点脆弱点序号,逗号分隔。") + parser.add_argument("--strategy-target", choices=["", "audience", "pathway"], default="", help="策略生成目标类型;Step5 只能 audience,Step6 只能 pathway。") + parser.add_argument("--select-strategy-audiences", default="", help="Step5 用户选择的受众策略生成对象序号/受众ID/名称,逗号分隔。") + parser.add_argument("--select-strategy-pathways", default="", help="Step6 用户选择的传播节点策略生成对象序号/节点ID/账号名,逗号分隔。") return parser @@ -2758,6 +3169,20 @@ def main(argv: list[str] | None = None) -> int: 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 + if config.get("require_user_assist") is False or config.get("user_assist") is False: + args.no_user_assist = True + selection_map = { + "select_audiences": "select_audiences", + "select_pathways": "select_pathways", + "select_audience_vulns": "select_audience_vulns", + "select_pathway_vulns": "select_pathway_vulns", + "select_strategy_audiences": "select_strategy_audiences", + "select_strategy_pathways": "select_strategy_pathways", + "strategy_target": "strategy_target", + } + for config_key, arg_name in selection_map.items(): + if config.get(config_key) is not None: + setattr(args, arg_name, config.get(config_key)) cli_config = { "api_base": args.api_base, @@ -2785,6 +3210,14 @@ def main(argv: list[str] | None = None) -> int: max_strategies=max(args.max_strategies, 1), downstream_audiences=max(args.downstream_audiences, 1), downstream_pathways=max(args.downstream_pathways, 1), + require_user_assist=not args.no_user_assist, + select_audience_refs=split_selection_refs(args.select_audiences), + select_pathway_refs=split_selection_refs(args.select_pathways), + select_audience_vuln_refs=split_selection_refs(args.select_audience_vulns), + select_pathway_vuln_refs=split_selection_refs(args.select_pathway_vulns), + select_strategy_audience_refs=split_selection_refs(args.select_strategy_audiences), + select_strategy_pathway_refs=split_selection_refs(args.select_strategy_pathways), + strategy_target=str(args.strategy_target or ""), sync_step_status=not args.no_step_status, model_retries=max(args.model_retries, 0), verify_ssl=bool(args.verify_ssl), @@ -2838,6 +3271,8 @@ def main(argv: list[str] | None = None) -> int: final_payload = { "日志类型": "执行完成", "是否成功": "是", + "是否等待用户选择": yes_no(has_pending_assist(state)), + "待用户选择": state.get("_pendingUserAssist"), "执行模式": mode_label(args.mode), "状态文件": str(state_path), "摘要文件": str(ctx.summary_path) if ctx.summary_path else "", diff --git a/skills-data/xdfx-step4-pathway-vulns/SKILL.md b/skills-data/xdfx-step4-pathway-vulns/SKILL.md index f845c8fe..83c726c1 100644 --- a/skills-data/xdfx-step4-pathway-vulns/SKILL.md +++ b/skills-data/xdfx-step4-pathway-vulns/SKILL.md @@ -1,6 +1,6 @@ --- name: xdfx-step4-pathway-vulns -description: xdfx 拆分流程 step4,为已入库关键传播节点抽取并保存最多3个脆弱点。 +description: xdfx 拆分流程 step4,为已入库关键传播节点抽取脆弱点,发起用户协助选择后保存。 --- # Step4 传播节点脆弱点抽取与入库 @@ -17,8 +17,8 @@ python scripts/xdfx_flow.py --mode step4 --input xdfx-input.json --state xdfx-fl 本步骤由脚本完成: -- 从已入库关键传播节点中默认选择前 2 个继续分析;可用 `--downstream-pathways 1` 调成只处理 1 个。 -- 对每个被选中的关键传播节点抽取并入库最多 3 个脆弱点。 +- 从已入库关键传播节点中抽取脆弱点候选。 +- 输出 `用户协助`,等待用户选择传播节点脆弱点;用户选择后带 `--select-pathway-vulns 1,3` 重跑 Step4,才保存选中的脆弱点。 - 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。 - 自动修复和解析模型 JSON。 - 调用传播节点脆弱点保存接口。 @@ -39,6 +39,8 @@ python scripts/xdfx_flow.py --mode step4 --input xdfx-input.json --state xdfx-fl 命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step4 的完成状态、当前传播节点脆弱点数量和新增错误数,再决定是否继续下一步。 +如果输出 `用户协助 {...}`,必须暂停流程,把传播节点脆弱点选项发给用户;用户选择后重跑 Step4 并追加 `--select-pathway-vulns ...`。 + 执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。 保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的传播节点脆弱点和返回 ID。 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 8e62e51e..d385f95b 100644 --- a/skills-data/xdfx-step4-pathway-vulns/scripts/xdfx_flow.py +++ b/skills-data/xdfx-step4-pathway-vulns/scripts/xdfx_flow.py @@ -28,6 +28,10 @@ from typing import Any import xdfx_auth +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") # Keep every backend path in one table so endpoint drift is easy to audit. ENDPOINTS = { @@ -76,6 +80,7 @@ LOG_PREFIXES = { "operation": "操作反馈", "error": "错误反馈", "json_review": "JSON解析复核", + "assist": "用户协助", "save": "入库反馈", "step": "步骤反馈", "done": "执行完成", @@ -85,6 +90,7 @@ STATUS_LABELS = { "ok": "成功", "partial": "部分完成", "skipped": "已跳过", + "waiting_user": "等待用户选择", } STEP_NUMBER_LABELS = { @@ -226,6 +232,14 @@ class FlowContext: max_strategies: int = 3 downstream_audiences: int = 2 downstream_pathways: int = 2 + require_user_assist: bool = True + select_audience_refs: list[str] | None = None + select_pathway_refs: list[str] | None = None + select_audience_vuln_refs: list[str] | None = None + select_pathway_vuln_refs: list[str] | None = None + select_strategy_audience_refs: list[str] | None = None + select_strategy_pathway_refs: list[str] | None = None + strategy_target: str = "" sync_step_status: bool = True model_retries: int = 1 verify_ssl: bool = False @@ -284,6 +298,129 @@ def safe_compact(value: Any, limit: int = 1400) -> str: return compact(redact_sensitive(value), limit) +def split_selection_refs(raw: Any) -> list[str]: + """Parse user choices like '1,3' or ['1', '3'] into stable strings.""" + if raw is None: + return [] + if isinstance(raw, (list, tuple, set)): + parts: list[str] = [] + for item in raw: + parts.extend(split_selection_refs(item)) + return parts + text = str(raw).strip() + if not text: + return [] + if text.lower() in {"all", "全部"}: + return ["all"] + return [part.strip() for part in re.split(r"[,,;;\s]+", text) if part.strip()] + + +def normalized_ref(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")).lower() + + +def has_selection(refs: list[str] | None) -> bool: + return bool([ref for ref in refs or [] if str(ref).strip()]) + + +def clear_pending_assist(state: dict[str, Any]) -> None: + state.pop("_pendingUserAssist", None) + + +def has_pending_assist(state: dict[str, Any]) -> bool: + return isinstance(state.get("_pendingUserAssist"), dict) + + +def user_assist_command_hint(state: dict[str, Any], mode: str, flag_name: str, example: str) -> str: + command = f"python scripts/xdfx_flow.py --mode {mode} --state {state.get('_statePath') or 'xdfx-flow-state.json'}" + if state.get("_summaryPath"): + command += f" --summary {state.get('_summaryPath')}" + return f"{command} --{flag_name} {example}" + + +def emit_user_assist( + state: dict[str, Any], + stage: str, + *, + selection_key: str, + title: str, + prompt: str, + options: list[dict[str, Any]], + command_hint: str, + allow_multiple: bool = True, + max_count: int | None = None, + exclusive_note: str = "", +) -> None: + payload = { + "日志类型": "用户协助", + "阶段": stage_label(stage), + "步骤": feedback_step_label(stage), + "选择类型": selection_key, + "标题": title, + "提示": prompt, + "是否允许多选": yes_no(allow_multiple), + "最多选择数量": max_count, + "互斥规则": exclusive_note, + "选项": options, + "继续命令示例": command_hint, + "状态文件": state.get("_statePath", ""), + "摘要文件": state.get("_summaryPath", ""), + "时间": utc_now(), + } + state["_pendingUserAssist"] = { + "selectionKey": selection_key, + "stage": stage, + "title": title, + "commandHint": command_hint, + "optionCount": len(options), + "createdAt": payload["时间"], + } + print(LOG_PREFIXES["assist"] + " " + json.dumps(payload, ensure_ascii=False), flush=True) + record_event(state, stage, "等待用户协助选择", {"selectionKey": selection_key, "optionCount": len(options)}) + + +def ref_matches(ref: str, ordinal: int, aliases: list[Any]) -> bool: + normalized = normalized_ref(ref) + if normalized == "all": + return True + if normalized == str(ordinal): + return True + return any(normalized and normalized == normalized_ref(alias) for alias in aliases if alias is not None) + + +def select_by_refs( + items: list[Any], + refs: list[str], + alias_fn, + *, + max_count: int | None = None, +) -> tuple[list[Any], list[str]]: + if not refs: + selected = list(items) + elif any(normalized_ref(ref) in {"all", "全部"} for ref in refs): + selected = list(items) + else: + selected = [] + missing: list[str] = [] + for ref in refs: + found = None + for index, item in enumerate(items, start=1): + if ref_matches(ref, index, alias_fn(item, index)): + found = item + break + if found is None: + missing.append(ref) + continue + if found not in selected: + selected.append(found) + if max_count is not None: + return selected[:max_count], missing + return selected, missing + if max_count is not None: + selected = selected[:max_count] + return selected, [] + + def stable_json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) @@ -336,7 +473,7 @@ def load_json_file(path: str | Path) -> dict[str, Any]: p = Path(path) if not p.exists(): return {} - return json.loads(p.read_text(encoding="utf-8")) + return json.loads(p.read_text(encoding="utf-8-sig")) def save_json_file(path: str | Path, value: Any) -> None: @@ -369,6 +506,8 @@ def default_state() -> dict[str, Any]: "pathwayCandidates": [], "topPathways": [], "audiencesWithPathways": [], + "audienceVulnCandidates": {}, + "pathwayVulnCandidates": {}, "audienceVulns": {}, "pathwayVulns": {}, "audienceStrategies": [], @@ -2182,6 +2321,121 @@ def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, aud return selected +def audience_option(item: dict[str, Any], index: int) -> dict[str, Any]: + return { + "序号": index, + "受众目标": item.get("targetAudience"), + "推荐理由": compact(item.get("recReason", ""), 300), + "账号引用": item.get("userIndexes") or [], + } + + +def audience_aliases(item: dict[str, Any], index: int) -> list[Any]: + return [index, item.get("targetAudience"), item.get("rank")] + + +def pathway_option(item: dict[str, Any], index: int) -> dict[str, Any]: + return { + "序号": index, + "账号名称": item.get("accountName") or account_name(item.get("account") or {}), + "账号ID": item.get("accountId") or account_id(item.get("account") or {}), + "关联受众": item.get("relatedAudiences") or [], + "推荐理由": compact(item.get("recReason", ""), 300), + } + + +def pathway_aliases(item: dict[str, Any], index: int) -> list[Any]: + account = item.get("account") or item + return [ + index, + item.get("pathwayAccountId"), + item.get("pathwayId"), + item.get("accountId"), + item.get("accountName"), + account_id(account), + account_name(account), + ] + + +def vuln_candidate_entries( + items: list[dict[str, Any]], + candidate_store: dict[str, Any], + *, + audience: bool, +) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + seq = 1 + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + owner_name = item.get("targetAudience") if audience else item.get("accountName") + for index, vuln in enumerate(candidate_store.get(str(owner_id)) or [], start=1): + if not isinstance(vuln, dict): + continue + entries.append( + { + "seq": seq, + "ownerId": owner_id, + "ownerName": owner_name, + "localIndex": index, + "vuln": vuln, + } + ) + seq += 1 + return entries + + +def vuln_option(entry: dict[str, Any]) -> dict[str, Any]: + vuln = entry.get("vuln") or {} + return { + "序号": entry.get("seq"), + "对象ID": entry.get("ownerId"), + "对象名称": entry.get("ownerName"), + "本对象内序号": entry.get("localIndex"), + "脆弱点": vuln.get("vulnerabilities"), + "理由": compact(vuln.get("reason", ""), 260), + } + + +def vuln_aliases(entry: dict[str, Any], index: int) -> list[Any]: + vuln = entry.get("vuln") or {} + owner_id = entry.get("ownerId") + return [ + index, + entry.get("seq"), + f"{owner_id}:{entry.get('localIndex')}", + vuln.get("vulnerabilities"), + ] + + +def strategy_target_option(item: dict[str, Any], index: int, *, audience: bool) -> dict[str, Any]: + if audience: + return { + "序号": index, + "类型": "受众", + "对象ID": item.get("designId"), + "对象名称": item.get("targetAudience"), + "已入库脆弱点数量": len((item.get("_vulns") or [])), + "关联传播节点": item.get("downstreamPathways") or [], + } + return { + "序号": index, + "类型": "关键传播节点", + "对象ID": item.get("pathwayAccountId"), + "对象名称": item.get("accountName"), + "已入库脆弱点数量": len((item.get("_vulns") or [])), + "关联受众": item.get("relatedAudiences") or [], + } + + +def strategy_target_aliases(item: dict[str, Any], index: int, *, audience: bool) -> list[Any]: + return [ + index, + item.get("designId") if audience else item.get("pathwayAccountId"), + item.get("targetAudience") if audience else item.get("accountName"), + item.get("accountId") if not audience else None, + ] + + 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") @@ -2189,39 +2443,65 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None: if state.get("topAudiences") and not ctx.dry_run: record_event(state, stage, "topAudiences 已存在,跳过。如需重跑请清空 state 中该字段。") return - payload = { - "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。", - "otherinfo": {"方向": state.get("direction") or "U方向"}, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": f"任务背景和目的:{state['mubiao']}", - } - 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 + candidates = [item for item in state.get("audienceCandidates") or [] if isinstance(item, dict)] + accounts = [item for item in state.get("audienceAccounts") or [] if isinstance(item, dict)] + if not candidates: + payload = { + "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。", + "otherinfo": {"方向": state.get("direction") or "U方向"}, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": f"任务背景和目的:{state['mubiao']}", + } + 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。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。", - "zhanghao": accounts, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": state["mubiao"], - } - data, _, _ = call_model_json(ctx, state, "group", group_payload, stage + "/extract") - candidates = extract_audiences(data) - state["audienceCandidates"] = candidates - top = select_top_audiences(candidates, ctx.max_audiences) + # 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。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。", + "zhanghao": accounts, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": state["mubiao"], + } + data, _, _ = call_model_json(ctx, state, "group", group_payload, stage + "/extract") + candidates = extract_audiences(data) + state["audienceCandidates"] = candidates + if not candidates: + record_error(state, stage, "没有生成可供选择的受众候选") + return + if ctx.require_user_assist and not has_selection(ctx.select_audience_refs): + emit_user_assist( + state, + stage, + selection_key="audience", + title="请选择要入库并继续分析的受众", + prompt=f"可多选,最多建议选择 {ctx.max_audiences} 个。回复序号即可,例如:1,2,3。", + options=[audience_option(item, index) for index, item in enumerate(candidates, start=1)], + command_hint=user_assist_command_hint(state, "step1", "select-audiences", "1,2,3"), + allow_multiple=True, + max_count=ctx.max_audiences, + ) + return + selected_refs = ctx.select_audience_refs or [] + top, missing = select_by_refs(candidates, selected_refs, audience_aliases, max_count=ctx.max_audiences) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的受众未匹配:{', '.join(missing)}") + if not top: + record_error(state, stage, "没有选中任何受众,无法继续") + return saved: list[dict[str, Any]] = [] for item in top: # Save the audience first; account rows and downstream analyses depend @@ -2282,60 +2562,85 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: state["audiencesWithPathways"] = audience_pathway_summary(state) record_event(state, stage, "topPathways 已存在,跳过。如需重跑请清空 state 中该字段。") return - all_candidates: list[dict[str, Any]] = [] - for audience in audiences: - # Analyze pathway candidates in the context of each saved audience. - payload = { - "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。", - "otherinfo": { - "方向": state.get("direction") or "U方向", - "任务": "传播", - "平台": state.get("platform") or "fb", - "受众": {"名称": audience.get("targetAudience"), "理由": audience.get("recReason")}, - }, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": f"任务背景和目的:{state['mubiao']}", - } - 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。", - "zhanghao": accounts, - "history": [], - "stream": True, - "model_name": "liyou", - "temperature": 0.7, - "max_tokens": 1000, - "prompt_name": f"{audience.get('targetAudience')}:{audience.get('recReason')};", - } - data, _, _ = call_model_json(ctx, state, "spread", spread_payload, stage + "/extract") - refs = extract_pathway_refs(data) - for ref_item in refs: - account = resolve_account_ref(ref_item.get("ref"), accounts) - if account is None: - record_error(state, stage, f"传播节点引用 {ref_item.get('ref')} 未匹配账号", response=data) - continue - all_candidates.append( - { - "account": account, - "accountId": account_id(account), - "accountName": account_name(account), - "recReason": ref_item.get("recReason", ""), - "rank": ref_item.get("rank"), - "designIds": [audience["designId"]], - "relatedAudiences": [audience.get("targetAudience", "")], - } - ) - merged = merge_pathway_candidates(all_candidates) - state["pathwayCandidates"] = merged - top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways] + ranked = [item for item in state.get("pathwayCandidates") or [] if isinstance(item, dict)] + if not ranked: + all_candidates: list[dict[str, Any]] = [] + for audience in audiences: + # Analyze pathway candidates in the context of each saved audience. + payload = { + "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。", + "otherinfo": { + "方向": state.get("direction") or "U方向", + "任务": "传播", + "平台": state.get("platform") or "fb", + "受众": {"名称": audience.get("targetAudience"), "理由": audience.get("recReason")}, + }, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": f"任务背景和目的:{state['mubiao']}", + } + 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。", + "zhanghao": accounts, + "history": [], + "stream": True, + "model_name": "liyou", + "temperature": 0.7, + "max_tokens": 1000, + "prompt_name": f"{audience.get('targetAudience')}:{audience.get('recReason')};", + } + data, _, _ = call_model_json(ctx, state, "spread", spread_payload, stage + "/extract") + refs = extract_pathway_refs(data) + for ref_item in refs: + account = resolve_account_ref(ref_item.get("ref"), accounts) + if account is None: + record_error(state, stage, f"传播节点引用 {ref_item.get('ref')} 未匹配账号", response=data) + continue + all_candidates.append( + { + "account": account, + "accountId": account_id(account), + "accountName": account_name(account), + "recReason": ref_item.get("recReason", ""), + "rank": ref_item.get("rank"), + "designIds": [audience["designId"]], + "relatedAudiences": [audience.get("targetAudience", "")], + } + ) + merged = merge_pathway_candidates(all_candidates) + ranked = sorted(merged, key=pathway_score, reverse=True) + state["pathwayCandidates"] = ranked + if not ranked: + record_error(state, stage, "没有生成可供选择的关键传播节点候选") + return + if ctx.require_user_assist and not has_selection(ctx.select_pathway_refs): + emit_user_assist( + state, + stage, + selection_key="pathway", + title="请选择要入库并继续分析的关键传播节点", + prompt=f"可多选,最多建议选择 {ctx.max_pathways} 个。回复序号、账号名称或账号ID均可,例如:1,2,3。", + options=[pathway_option(item, index) for index, item in enumerate(ranked, start=1)], + command_hint=user_assist_command_hint(state, "step2", "select-pathways", "1,2,3"), + allow_multiple=True, + max_count=ctx.max_pathways, + ) + return + top, missing = select_by_refs(ranked, ctx.select_pathway_refs or [], pathway_aliases, max_count=ctx.max_pathways) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的关键传播节点未匹配:{', '.join(missing)}") + if not top: + record_error(state, stage, "没有选中任何关键传播节点,无法继续") + return saved: list[dict[str, Any]] = [] for item in top: # Save the account row before writing the design-pathway relation. @@ -2395,7 +2700,10 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> stage = "step3-audience-vulns" if audience else "step4-pathway-vulns" items = downstream_items(ctx, state, stage, audience=audience) output_key = "audienceVulns" if audience else "pathwayVulns" + candidate_key = "audienceVulnCandidates" if audience else "pathwayVulnCandidates" + select_refs = ctx.select_audience_vuln_refs if audience else ctx.select_pathway_vuln_refs state.setdefault(output_key, {}) + candidate_store = state.setdefault(candidate_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") @@ -2403,6 +2711,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> continue if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run: continue + if str(owner_id) in candidate_store and candidate_store[str(owner_id)]: + continue zhanghao = selected_accounts_for_item(item, audience=audience) prompt = ( "你是中国对台湾方向的认知战专家,你要传播行动要求的叙事主题。请基于该受众目标的画像、关注议题、社会分工定位、利益诉求与上一轮账号佐证,分析该受众目标有哪些脆弱点可以成为后续策略设计的关键要素。请输出理由、脆弱点1、理由1、脆弱点2、理由2、脆弱点3、理由3,只返回JSON。" @@ -2422,8 +2732,43 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> } data, _, _ = call_model_json(ctx, state, "cuiruo", payload, stage + "/extract") vulns = extract_vulns(data, ctx.max_vulns) + candidate_store[str(owner_id)] = vulns + entries = vuln_candidate_entries(items, candidate_store, audience=audience) + if not entries: + record_error(state, stage, "没有生成可供选择的脆弱点候选") + return + if ctx.require_user_assist and not has_selection(select_refs): + flag = "select-audience-vulns" if audience else "select-pathway-vulns" + emit_user_assist( + state, + stage, + selection_key="audience_vuln" if audience else "pathway_vuln", + title="请选择要入库的受众脆弱点" if audience else "请选择要入库的传播节点脆弱点", + prompt="可多选。回复全局序号即可,例如:1,2,4;也可用 对象ID:本对象内序号。", + options=[vuln_option(entry) for entry in entries], + command_hint=user_assist_command_hint(state, "step3" if audience else "step4", flag, "1,2,4"), + allow_multiple=True, + ) + return + selected_entries, missing = select_by_refs(entries, select_refs or [], vuln_aliases) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的脆弱点未匹配:{', '.join(missing)}") + selected_by_owner: dict[str, list[dict[str, Any]]] = {} + for entry in selected_entries: + owner_key = str(entry.get("ownerId")) + selected_by_owner.setdefault(owner_key, []).append(entry.get("vuln") or {}) + if not selected_by_owner: + record_error(state, stage, "没有选中任何脆弱点,无法继续") + return + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + if not owner_id: + continue + if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run: + continue saved: list[dict[str, Any]] = [] - for vuln in vulns: + for vuln in selected_by_owner.get(str(owner_id), []): # The two weak-point tables have nearly identical payloads but # different foreign-key names. if audience: @@ -2475,8 +2820,53 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool """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" + if audience and (has_selection(ctx.select_strategy_pathway_refs) or ctx.strategy_target == "pathway"): + clear_pending_assist(state) + record_error(state, stage, "策略生成目标类型冲突:Step5 只能选择受众,不能同时选择关键传播节点") + return + if not audience and (has_selection(ctx.select_strategy_audience_refs) or ctx.strategy_target == "audience"): + clear_pending_assist(state) + record_error(state, stage, "策略生成目标类型冲突:Step6 只能选择关键传播节点,不能同时选择受众") + return items = downstream_items(ctx, state, stage, audience=audience) vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {} + selectable_items: list[dict[str, Any]] = [] + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + vulns = vulns_by_owner.get(str(owner_id)) or [] + if vulns: + selectable_items.append({**item, "_vulns": vulns}) + select_refs = ctx.select_strategy_audience_refs if audience else ctx.select_strategy_pathway_refs + if selectable_items and ctx.require_user_assist and not has_selection(select_refs): + flag = "select-strategy-audiences" if audience else "select-strategy-pathways" + emit_user_assist( + state, + stage, + selection_key="strategy_audience" if audience else "strategy_pathway", + title="请选择要生成策略的受众" if audience else "请选择要生成策略的关键传播节点", + prompt=( + "可多选,但本步骤只能选择受众,不能混选关键传播节点。回复序号即可,例如:1,2。" + if audience + else "可多选,但本步骤只能选择关键传播节点,不能混选受众。回复序号即可,例如:1,2。" + ), + options=[strategy_target_option(item, index, audience=audience) for index, item in enumerate(selectable_items, start=1)], + command_hint=user_assist_command_hint(state, "step5" if audience else "step6", flag, "1,2"), + allow_multiple=True, + exclusive_note="受众策略和关键传播节点策略不能在同一步命令中交叉选择。", + ) + return + selected_items, missing = select_by_refs( + selectable_items, + select_refs or [], + lambda item, index: strategy_target_aliases(item, index, audience=audience), + ) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的策略生成对象未匹配:{', '.join(missing)}") + items = selected_items + if not items: + record_error(state, stage, "没有选中任何具备脆弱点的策略生成对象,无法继续") + return 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)} @@ -2583,14 +2973,15 @@ def emit_step_feedback( # 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") + final_status = status or ("waiting_user" if has_pending_assist(state) else ("partial" if new_errors else "ok")) + final_note = note or (state.get("_pendingUserAssist", {}).get("title", "") if has_pending_assist(state) else "") payload = { "日志类型": "步骤反馈", "步骤": STEP_NUMBER_LABELS.get(step, step), "步骤名称": STEP_LABELS.get(step, step), "状态": STATUS_LABELS.get(final_status, final_status), "执行状态": status_label(final_status), - "备注": note, + "备注": final_note, "本步骤新增错误数": new_errors, "累计错误数": total_errors, "累计入库数量": state_counts(state), @@ -2638,15 +3029,27 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None: for dry-runs and unattended local troubleshooting. """ run_step_with_feedback(ctx, state, "step1") + if has_pending_assist(state): + return run_step_with_feedback(ctx, state, "step2") + if has_pending_assist(state): + return run_step_with_feedback(ctx, state, "step3") + if has_pending_assist(state): + return if state.get("topPathways"): run_step_with_feedback(ctx, state, "step4") + if has_pending_assist(state): + return else: skip_step_with_feedback(ctx, state, "step4", "没有已入库关键传播节点") run_step_with_feedback(ctx, state, "step5") + if has_pending_assist(state): + return if state.get("topPathways"): run_step_with_feedback(ctx, state, "step6") + if has_pending_assist(state): + return else: skip_step_with_feedback(ctx, state, "step6", "没有已入库关键传播节点") @@ -2722,6 +3125,14 @@ def build_arg_parser() -> argparse.ArgumentParser: 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。") + parser.add_argument("--no-user-assist", action="store_true", help="关闭每步用户协助选择,使用脚本默认排序自动继续。") + parser.add_argument("--select-audiences", default="", help="Step1 用户选择的受众序号/名称,逗号分隔,例如 1,2,3。") + parser.add_argument("--select-pathways", default="", help="Step2 用户选择的关键传播节点序号/账号名/账号ID,逗号分隔。") + parser.add_argument("--select-audience-vulns", default="", help="Step3 用户选择的受众脆弱点序号,逗号分隔。") + parser.add_argument("--select-pathway-vulns", default="", help="Step4 用户选择的传播节点脆弱点序号,逗号分隔。") + parser.add_argument("--strategy-target", choices=["", "audience", "pathway"], default="", help="策略生成目标类型;Step5 只能 audience,Step6 只能 pathway。") + parser.add_argument("--select-strategy-audiences", default="", help="Step5 用户选择的受众策略生成对象序号/受众ID/名称,逗号分隔。") + parser.add_argument("--select-strategy-pathways", default="", help="Step6 用户选择的传播节点策略生成对象序号/节点ID/账号名,逗号分隔。") return parser @@ -2758,6 +3169,20 @@ def main(argv: list[str] | None = None) -> int: 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 + if config.get("require_user_assist") is False or config.get("user_assist") is False: + args.no_user_assist = True + selection_map = { + "select_audiences": "select_audiences", + "select_pathways": "select_pathways", + "select_audience_vulns": "select_audience_vulns", + "select_pathway_vulns": "select_pathway_vulns", + "select_strategy_audiences": "select_strategy_audiences", + "select_strategy_pathways": "select_strategy_pathways", + "strategy_target": "strategy_target", + } + for config_key, arg_name in selection_map.items(): + if config.get(config_key) is not None: + setattr(args, arg_name, config.get(config_key)) cli_config = { "api_base": args.api_base, @@ -2785,6 +3210,14 @@ def main(argv: list[str] | None = None) -> int: max_strategies=max(args.max_strategies, 1), downstream_audiences=max(args.downstream_audiences, 1), downstream_pathways=max(args.downstream_pathways, 1), + require_user_assist=not args.no_user_assist, + select_audience_refs=split_selection_refs(args.select_audiences), + select_pathway_refs=split_selection_refs(args.select_pathways), + select_audience_vuln_refs=split_selection_refs(args.select_audience_vulns), + select_pathway_vuln_refs=split_selection_refs(args.select_pathway_vulns), + select_strategy_audience_refs=split_selection_refs(args.select_strategy_audiences), + select_strategy_pathway_refs=split_selection_refs(args.select_strategy_pathways), + strategy_target=str(args.strategy_target or ""), sync_step_status=not args.no_step_status, model_retries=max(args.model_retries, 0), verify_ssl=bool(args.verify_ssl), @@ -2838,6 +3271,8 @@ def main(argv: list[str] | None = None) -> int: final_payload = { "日志类型": "执行完成", "是否成功": "是", + "是否等待用户选择": yes_no(has_pending_assist(state)), + "待用户选择": state.get("_pendingUserAssist"), "执行模式": mode_label(args.mode), "状态文件": str(state_path), "摘要文件": str(ctx.summary_path) if ctx.summary_path else "", diff --git a/skills-data/xdfx-step5-audience-strategy/SKILL.md b/skills-data/xdfx-step5-audience-strategy/SKILL.md index 9bba2a5a..1abba8a8 100644 --- a/skills-data/xdfx-step5-audience-strategy/SKILL.md +++ b/skills-data/xdfx-step5-audience-strategy/SKILL.md @@ -1,11 +1,11 @@ --- name: xdfx-step5-audience-strategy -description: xdfx 拆分流程 step5,基于受众目标与受众脆弱点生成策略并保存。 +description: xdfx 拆分流程 step5,基于受众目标与受众脆弱点发起用户协助选择受众,生成并保存受众策略。 --- # Step5 受众目标 + 脆弱点策略生成与入库 -前置条件:`xdfx-flow-state.json` 中已有 Step3 写入的 `audienceVulns`。 +前置条件:`xdfx-flow-state.json` 中已有 Step3 写入的 `audienceVulns`,且这些受众已在 Step2 关联关键传播节点。 不要手写接口调用,直接运行: @@ -17,8 +17,9 @@ python scripts/xdfx_flow.py --mode step5 --input xdfx-input.json --state xdfx-fl 本步骤由脚本完成: -- 从已入库受众中默认选择前 2 个继续生成策略;可用 `--downstream-audiences 1` 调成只处理 1 个。 -- 对每个被选中的受众读取已入库脆弱点并默认生成最多 3 个策略。 +- 只从已有关联关键传播节点且已入库受众脆弱点的受众中列出策略生成对象。 +- 输出 `用户协助`,等待用户选择一个或多个受众;用户选择后带 `--select-strategy-audiences 1,2` 重跑 Step5,才生成并保存这些受众的策略。 +- Step5 只能选择受众,不能混入关键传播节点;不要传 `--select-strategy-pathways`。 - 调用策略生成模型接口。 - 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。 - 自动解析 `发布人设N / 账号类型N / 信息标题N` 等字段。 @@ -41,6 +42,8 @@ python scripts/xdfx_flow.py --mode step5 --input xdfx-input.json --state xdfx-fl 命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step5 的完成状态、当前受众策略数量和新增错误数,再决定是否继续下一步。 +如果输出 `用户协助 {...}`,必须暂停流程,把受众策略生成对象发给用户;用户选择后重跑 Step5 并追加 `--select-strategy-audiences ...`。 + 执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。 保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的策略、策略子表和返回 ID。 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 8e62e51e..d385f95b 100644 --- a/skills-data/xdfx-step5-audience-strategy/scripts/xdfx_flow.py +++ b/skills-data/xdfx-step5-audience-strategy/scripts/xdfx_flow.py @@ -28,6 +28,10 @@ from typing import Any import xdfx_auth +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") # Keep every backend path in one table so endpoint drift is easy to audit. ENDPOINTS = { @@ -76,6 +80,7 @@ LOG_PREFIXES = { "operation": "操作反馈", "error": "错误反馈", "json_review": "JSON解析复核", + "assist": "用户协助", "save": "入库反馈", "step": "步骤反馈", "done": "执行完成", @@ -85,6 +90,7 @@ STATUS_LABELS = { "ok": "成功", "partial": "部分完成", "skipped": "已跳过", + "waiting_user": "等待用户选择", } STEP_NUMBER_LABELS = { @@ -226,6 +232,14 @@ class FlowContext: max_strategies: int = 3 downstream_audiences: int = 2 downstream_pathways: int = 2 + require_user_assist: bool = True + select_audience_refs: list[str] | None = None + select_pathway_refs: list[str] | None = None + select_audience_vuln_refs: list[str] | None = None + select_pathway_vuln_refs: list[str] | None = None + select_strategy_audience_refs: list[str] | None = None + select_strategy_pathway_refs: list[str] | None = None + strategy_target: str = "" sync_step_status: bool = True model_retries: int = 1 verify_ssl: bool = False @@ -284,6 +298,129 @@ def safe_compact(value: Any, limit: int = 1400) -> str: return compact(redact_sensitive(value), limit) +def split_selection_refs(raw: Any) -> list[str]: + """Parse user choices like '1,3' or ['1', '3'] into stable strings.""" + if raw is None: + return [] + if isinstance(raw, (list, tuple, set)): + parts: list[str] = [] + for item in raw: + parts.extend(split_selection_refs(item)) + return parts + text = str(raw).strip() + if not text: + return [] + if text.lower() in {"all", "全部"}: + return ["all"] + return [part.strip() for part in re.split(r"[,,;;\s]+", text) if part.strip()] + + +def normalized_ref(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")).lower() + + +def has_selection(refs: list[str] | None) -> bool: + return bool([ref for ref in refs or [] if str(ref).strip()]) + + +def clear_pending_assist(state: dict[str, Any]) -> None: + state.pop("_pendingUserAssist", None) + + +def has_pending_assist(state: dict[str, Any]) -> bool: + return isinstance(state.get("_pendingUserAssist"), dict) + + +def user_assist_command_hint(state: dict[str, Any], mode: str, flag_name: str, example: str) -> str: + command = f"python scripts/xdfx_flow.py --mode {mode} --state {state.get('_statePath') or 'xdfx-flow-state.json'}" + if state.get("_summaryPath"): + command += f" --summary {state.get('_summaryPath')}" + return f"{command} --{flag_name} {example}" + + +def emit_user_assist( + state: dict[str, Any], + stage: str, + *, + selection_key: str, + title: str, + prompt: str, + options: list[dict[str, Any]], + command_hint: str, + allow_multiple: bool = True, + max_count: int | None = None, + exclusive_note: str = "", +) -> None: + payload = { + "日志类型": "用户协助", + "阶段": stage_label(stage), + "步骤": feedback_step_label(stage), + "选择类型": selection_key, + "标题": title, + "提示": prompt, + "是否允许多选": yes_no(allow_multiple), + "最多选择数量": max_count, + "互斥规则": exclusive_note, + "选项": options, + "继续命令示例": command_hint, + "状态文件": state.get("_statePath", ""), + "摘要文件": state.get("_summaryPath", ""), + "时间": utc_now(), + } + state["_pendingUserAssist"] = { + "selectionKey": selection_key, + "stage": stage, + "title": title, + "commandHint": command_hint, + "optionCount": len(options), + "createdAt": payload["时间"], + } + print(LOG_PREFIXES["assist"] + " " + json.dumps(payload, ensure_ascii=False), flush=True) + record_event(state, stage, "等待用户协助选择", {"selectionKey": selection_key, "optionCount": len(options)}) + + +def ref_matches(ref: str, ordinal: int, aliases: list[Any]) -> bool: + normalized = normalized_ref(ref) + if normalized == "all": + return True + if normalized == str(ordinal): + return True + return any(normalized and normalized == normalized_ref(alias) for alias in aliases if alias is not None) + + +def select_by_refs( + items: list[Any], + refs: list[str], + alias_fn, + *, + max_count: int | None = None, +) -> tuple[list[Any], list[str]]: + if not refs: + selected = list(items) + elif any(normalized_ref(ref) in {"all", "全部"} for ref in refs): + selected = list(items) + else: + selected = [] + missing: list[str] = [] + for ref in refs: + found = None + for index, item in enumerate(items, start=1): + if ref_matches(ref, index, alias_fn(item, index)): + found = item + break + if found is None: + missing.append(ref) + continue + if found not in selected: + selected.append(found) + if max_count is not None: + return selected[:max_count], missing + return selected, missing + if max_count is not None: + selected = selected[:max_count] + return selected, [] + + def stable_json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) @@ -336,7 +473,7 @@ def load_json_file(path: str | Path) -> dict[str, Any]: p = Path(path) if not p.exists(): return {} - return json.loads(p.read_text(encoding="utf-8")) + return json.loads(p.read_text(encoding="utf-8-sig")) def save_json_file(path: str | Path, value: Any) -> None: @@ -369,6 +506,8 @@ def default_state() -> dict[str, Any]: "pathwayCandidates": [], "topPathways": [], "audiencesWithPathways": [], + "audienceVulnCandidates": {}, + "pathwayVulnCandidates": {}, "audienceVulns": {}, "pathwayVulns": {}, "audienceStrategies": [], @@ -2182,6 +2321,121 @@ def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, aud return selected +def audience_option(item: dict[str, Any], index: int) -> dict[str, Any]: + return { + "序号": index, + "受众目标": item.get("targetAudience"), + "推荐理由": compact(item.get("recReason", ""), 300), + "账号引用": item.get("userIndexes") or [], + } + + +def audience_aliases(item: dict[str, Any], index: int) -> list[Any]: + return [index, item.get("targetAudience"), item.get("rank")] + + +def pathway_option(item: dict[str, Any], index: int) -> dict[str, Any]: + return { + "序号": index, + "账号名称": item.get("accountName") or account_name(item.get("account") or {}), + "账号ID": item.get("accountId") or account_id(item.get("account") or {}), + "关联受众": item.get("relatedAudiences") or [], + "推荐理由": compact(item.get("recReason", ""), 300), + } + + +def pathway_aliases(item: dict[str, Any], index: int) -> list[Any]: + account = item.get("account") or item + return [ + index, + item.get("pathwayAccountId"), + item.get("pathwayId"), + item.get("accountId"), + item.get("accountName"), + account_id(account), + account_name(account), + ] + + +def vuln_candidate_entries( + items: list[dict[str, Any]], + candidate_store: dict[str, Any], + *, + audience: bool, +) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + seq = 1 + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + owner_name = item.get("targetAudience") if audience else item.get("accountName") + for index, vuln in enumerate(candidate_store.get(str(owner_id)) or [], start=1): + if not isinstance(vuln, dict): + continue + entries.append( + { + "seq": seq, + "ownerId": owner_id, + "ownerName": owner_name, + "localIndex": index, + "vuln": vuln, + } + ) + seq += 1 + return entries + + +def vuln_option(entry: dict[str, Any]) -> dict[str, Any]: + vuln = entry.get("vuln") or {} + return { + "序号": entry.get("seq"), + "对象ID": entry.get("ownerId"), + "对象名称": entry.get("ownerName"), + "本对象内序号": entry.get("localIndex"), + "脆弱点": vuln.get("vulnerabilities"), + "理由": compact(vuln.get("reason", ""), 260), + } + + +def vuln_aliases(entry: dict[str, Any], index: int) -> list[Any]: + vuln = entry.get("vuln") or {} + owner_id = entry.get("ownerId") + return [ + index, + entry.get("seq"), + f"{owner_id}:{entry.get('localIndex')}", + vuln.get("vulnerabilities"), + ] + + +def strategy_target_option(item: dict[str, Any], index: int, *, audience: bool) -> dict[str, Any]: + if audience: + return { + "序号": index, + "类型": "受众", + "对象ID": item.get("designId"), + "对象名称": item.get("targetAudience"), + "已入库脆弱点数量": len((item.get("_vulns") or [])), + "关联传播节点": item.get("downstreamPathways") or [], + } + return { + "序号": index, + "类型": "关键传播节点", + "对象ID": item.get("pathwayAccountId"), + "对象名称": item.get("accountName"), + "已入库脆弱点数量": len((item.get("_vulns") or [])), + "关联受众": item.get("relatedAudiences") or [], + } + + +def strategy_target_aliases(item: dict[str, Any], index: int, *, audience: bool) -> list[Any]: + return [ + index, + item.get("designId") if audience else item.get("pathwayAccountId"), + item.get("targetAudience") if audience else item.get("accountName"), + item.get("accountId") if not audience else None, + ] + + 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") @@ -2189,39 +2443,65 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None: if state.get("topAudiences") and not ctx.dry_run: record_event(state, stage, "topAudiences 已存在,跳过。如需重跑请清空 state 中该字段。") return - payload = { - "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。", - "otherinfo": {"方向": state.get("direction") or "U方向"}, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": f"任务背景和目的:{state['mubiao']}", - } - 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 + candidates = [item for item in state.get("audienceCandidates") or [] if isinstance(item, dict)] + accounts = [item for item in state.get("audienceAccounts") or [] if isinstance(item, dict)] + if not candidates: + payload = { + "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。", + "otherinfo": {"方向": state.get("direction") or "U方向"}, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": f"任务背景和目的:{state['mubiao']}", + } + 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。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。", - "zhanghao": accounts, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": state["mubiao"], - } - data, _, _ = call_model_json(ctx, state, "group", group_payload, stage + "/extract") - candidates = extract_audiences(data) - state["audienceCandidates"] = candidates - top = select_top_audiences(candidates, ctx.max_audiences) + # 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。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。", + "zhanghao": accounts, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": state["mubiao"], + } + data, _, _ = call_model_json(ctx, state, "group", group_payload, stage + "/extract") + candidates = extract_audiences(data) + state["audienceCandidates"] = candidates + if not candidates: + record_error(state, stage, "没有生成可供选择的受众候选") + return + if ctx.require_user_assist and not has_selection(ctx.select_audience_refs): + emit_user_assist( + state, + stage, + selection_key="audience", + title="请选择要入库并继续分析的受众", + prompt=f"可多选,最多建议选择 {ctx.max_audiences} 个。回复序号即可,例如:1,2,3。", + options=[audience_option(item, index) for index, item in enumerate(candidates, start=1)], + command_hint=user_assist_command_hint(state, "step1", "select-audiences", "1,2,3"), + allow_multiple=True, + max_count=ctx.max_audiences, + ) + return + selected_refs = ctx.select_audience_refs or [] + top, missing = select_by_refs(candidates, selected_refs, audience_aliases, max_count=ctx.max_audiences) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的受众未匹配:{', '.join(missing)}") + if not top: + record_error(state, stage, "没有选中任何受众,无法继续") + return saved: list[dict[str, Any]] = [] for item in top: # Save the audience first; account rows and downstream analyses depend @@ -2282,60 +2562,85 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: state["audiencesWithPathways"] = audience_pathway_summary(state) record_event(state, stage, "topPathways 已存在,跳过。如需重跑请清空 state 中该字段。") return - all_candidates: list[dict[str, Any]] = [] - for audience in audiences: - # Analyze pathway candidates in the context of each saved audience. - payload = { - "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。", - "otherinfo": { - "方向": state.get("direction") or "U方向", - "任务": "传播", - "平台": state.get("platform") or "fb", - "受众": {"名称": audience.get("targetAudience"), "理由": audience.get("recReason")}, - }, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": f"任务背景和目的:{state['mubiao']}", - } - 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。", - "zhanghao": accounts, - "history": [], - "stream": True, - "model_name": "liyou", - "temperature": 0.7, - "max_tokens": 1000, - "prompt_name": f"{audience.get('targetAudience')}:{audience.get('recReason')};", - } - data, _, _ = call_model_json(ctx, state, "spread", spread_payload, stage + "/extract") - refs = extract_pathway_refs(data) - for ref_item in refs: - account = resolve_account_ref(ref_item.get("ref"), accounts) - if account is None: - record_error(state, stage, f"传播节点引用 {ref_item.get('ref')} 未匹配账号", response=data) - continue - all_candidates.append( - { - "account": account, - "accountId": account_id(account), - "accountName": account_name(account), - "recReason": ref_item.get("recReason", ""), - "rank": ref_item.get("rank"), - "designIds": [audience["designId"]], - "relatedAudiences": [audience.get("targetAudience", "")], - } - ) - merged = merge_pathway_candidates(all_candidates) - state["pathwayCandidates"] = merged - top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways] + ranked = [item for item in state.get("pathwayCandidates") or [] if isinstance(item, dict)] + if not ranked: + all_candidates: list[dict[str, Any]] = [] + for audience in audiences: + # Analyze pathway candidates in the context of each saved audience. + payload = { + "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。", + "otherinfo": { + "方向": state.get("direction") or "U方向", + "任务": "传播", + "平台": state.get("platform") or "fb", + "受众": {"名称": audience.get("targetAudience"), "理由": audience.get("recReason")}, + }, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": f"任务背景和目的:{state['mubiao']}", + } + 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。", + "zhanghao": accounts, + "history": [], + "stream": True, + "model_name": "liyou", + "temperature": 0.7, + "max_tokens": 1000, + "prompt_name": f"{audience.get('targetAudience')}:{audience.get('recReason')};", + } + data, _, _ = call_model_json(ctx, state, "spread", spread_payload, stage + "/extract") + refs = extract_pathway_refs(data) + for ref_item in refs: + account = resolve_account_ref(ref_item.get("ref"), accounts) + if account is None: + record_error(state, stage, f"传播节点引用 {ref_item.get('ref')} 未匹配账号", response=data) + continue + all_candidates.append( + { + "account": account, + "accountId": account_id(account), + "accountName": account_name(account), + "recReason": ref_item.get("recReason", ""), + "rank": ref_item.get("rank"), + "designIds": [audience["designId"]], + "relatedAudiences": [audience.get("targetAudience", "")], + } + ) + merged = merge_pathway_candidates(all_candidates) + ranked = sorted(merged, key=pathway_score, reverse=True) + state["pathwayCandidates"] = ranked + if not ranked: + record_error(state, stage, "没有生成可供选择的关键传播节点候选") + return + if ctx.require_user_assist and not has_selection(ctx.select_pathway_refs): + emit_user_assist( + state, + stage, + selection_key="pathway", + title="请选择要入库并继续分析的关键传播节点", + prompt=f"可多选,最多建议选择 {ctx.max_pathways} 个。回复序号、账号名称或账号ID均可,例如:1,2,3。", + options=[pathway_option(item, index) for index, item in enumerate(ranked, start=1)], + command_hint=user_assist_command_hint(state, "step2", "select-pathways", "1,2,3"), + allow_multiple=True, + max_count=ctx.max_pathways, + ) + return + top, missing = select_by_refs(ranked, ctx.select_pathway_refs or [], pathway_aliases, max_count=ctx.max_pathways) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的关键传播节点未匹配:{', '.join(missing)}") + if not top: + record_error(state, stage, "没有选中任何关键传播节点,无法继续") + return saved: list[dict[str, Any]] = [] for item in top: # Save the account row before writing the design-pathway relation. @@ -2395,7 +2700,10 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> stage = "step3-audience-vulns" if audience else "step4-pathway-vulns" items = downstream_items(ctx, state, stage, audience=audience) output_key = "audienceVulns" if audience else "pathwayVulns" + candidate_key = "audienceVulnCandidates" if audience else "pathwayVulnCandidates" + select_refs = ctx.select_audience_vuln_refs if audience else ctx.select_pathway_vuln_refs state.setdefault(output_key, {}) + candidate_store = state.setdefault(candidate_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") @@ -2403,6 +2711,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> continue if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run: continue + if str(owner_id) in candidate_store and candidate_store[str(owner_id)]: + continue zhanghao = selected_accounts_for_item(item, audience=audience) prompt = ( "你是中国对台湾方向的认知战专家,你要传播行动要求的叙事主题。请基于该受众目标的画像、关注议题、社会分工定位、利益诉求与上一轮账号佐证,分析该受众目标有哪些脆弱点可以成为后续策略设计的关键要素。请输出理由、脆弱点1、理由1、脆弱点2、理由2、脆弱点3、理由3,只返回JSON。" @@ -2422,8 +2732,43 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> } data, _, _ = call_model_json(ctx, state, "cuiruo", payload, stage + "/extract") vulns = extract_vulns(data, ctx.max_vulns) + candidate_store[str(owner_id)] = vulns + entries = vuln_candidate_entries(items, candidate_store, audience=audience) + if not entries: + record_error(state, stage, "没有生成可供选择的脆弱点候选") + return + if ctx.require_user_assist and not has_selection(select_refs): + flag = "select-audience-vulns" if audience else "select-pathway-vulns" + emit_user_assist( + state, + stage, + selection_key="audience_vuln" if audience else "pathway_vuln", + title="请选择要入库的受众脆弱点" if audience else "请选择要入库的传播节点脆弱点", + prompt="可多选。回复全局序号即可,例如:1,2,4;也可用 对象ID:本对象内序号。", + options=[vuln_option(entry) for entry in entries], + command_hint=user_assist_command_hint(state, "step3" if audience else "step4", flag, "1,2,4"), + allow_multiple=True, + ) + return + selected_entries, missing = select_by_refs(entries, select_refs or [], vuln_aliases) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的脆弱点未匹配:{', '.join(missing)}") + selected_by_owner: dict[str, list[dict[str, Any]]] = {} + for entry in selected_entries: + owner_key = str(entry.get("ownerId")) + selected_by_owner.setdefault(owner_key, []).append(entry.get("vuln") or {}) + if not selected_by_owner: + record_error(state, stage, "没有选中任何脆弱点,无法继续") + return + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + if not owner_id: + continue + if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run: + continue saved: list[dict[str, Any]] = [] - for vuln in vulns: + for vuln in selected_by_owner.get(str(owner_id), []): # The two weak-point tables have nearly identical payloads but # different foreign-key names. if audience: @@ -2475,8 +2820,53 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool """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" + if audience and (has_selection(ctx.select_strategy_pathway_refs) or ctx.strategy_target == "pathway"): + clear_pending_assist(state) + record_error(state, stage, "策略生成目标类型冲突:Step5 只能选择受众,不能同时选择关键传播节点") + return + if not audience and (has_selection(ctx.select_strategy_audience_refs) or ctx.strategy_target == "audience"): + clear_pending_assist(state) + record_error(state, stage, "策略生成目标类型冲突:Step6 只能选择关键传播节点,不能同时选择受众") + return items = downstream_items(ctx, state, stage, audience=audience) vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {} + selectable_items: list[dict[str, Any]] = [] + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + vulns = vulns_by_owner.get(str(owner_id)) or [] + if vulns: + selectable_items.append({**item, "_vulns": vulns}) + select_refs = ctx.select_strategy_audience_refs if audience else ctx.select_strategy_pathway_refs + if selectable_items and ctx.require_user_assist and not has_selection(select_refs): + flag = "select-strategy-audiences" if audience else "select-strategy-pathways" + emit_user_assist( + state, + stage, + selection_key="strategy_audience" if audience else "strategy_pathway", + title="请选择要生成策略的受众" if audience else "请选择要生成策略的关键传播节点", + prompt=( + "可多选,但本步骤只能选择受众,不能混选关键传播节点。回复序号即可,例如:1,2。" + if audience + else "可多选,但本步骤只能选择关键传播节点,不能混选受众。回复序号即可,例如:1,2。" + ), + options=[strategy_target_option(item, index, audience=audience) for index, item in enumerate(selectable_items, start=1)], + command_hint=user_assist_command_hint(state, "step5" if audience else "step6", flag, "1,2"), + allow_multiple=True, + exclusive_note="受众策略和关键传播节点策略不能在同一步命令中交叉选择。", + ) + return + selected_items, missing = select_by_refs( + selectable_items, + select_refs or [], + lambda item, index: strategy_target_aliases(item, index, audience=audience), + ) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的策略生成对象未匹配:{', '.join(missing)}") + items = selected_items + if not items: + record_error(state, stage, "没有选中任何具备脆弱点的策略生成对象,无法继续") + return 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)} @@ -2583,14 +2973,15 @@ def emit_step_feedback( # 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") + final_status = status or ("waiting_user" if has_pending_assist(state) else ("partial" if new_errors else "ok")) + final_note = note or (state.get("_pendingUserAssist", {}).get("title", "") if has_pending_assist(state) else "") payload = { "日志类型": "步骤反馈", "步骤": STEP_NUMBER_LABELS.get(step, step), "步骤名称": STEP_LABELS.get(step, step), "状态": STATUS_LABELS.get(final_status, final_status), "执行状态": status_label(final_status), - "备注": note, + "备注": final_note, "本步骤新增错误数": new_errors, "累计错误数": total_errors, "累计入库数量": state_counts(state), @@ -2638,15 +3029,27 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None: for dry-runs and unattended local troubleshooting. """ run_step_with_feedback(ctx, state, "step1") + if has_pending_assist(state): + return run_step_with_feedback(ctx, state, "step2") + if has_pending_assist(state): + return run_step_with_feedback(ctx, state, "step3") + if has_pending_assist(state): + return if state.get("topPathways"): run_step_with_feedback(ctx, state, "step4") + if has_pending_assist(state): + return else: skip_step_with_feedback(ctx, state, "step4", "没有已入库关键传播节点") run_step_with_feedback(ctx, state, "step5") + if has_pending_assist(state): + return if state.get("topPathways"): run_step_with_feedback(ctx, state, "step6") + if has_pending_assist(state): + return else: skip_step_with_feedback(ctx, state, "step6", "没有已入库关键传播节点") @@ -2722,6 +3125,14 @@ def build_arg_parser() -> argparse.ArgumentParser: 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。") + parser.add_argument("--no-user-assist", action="store_true", help="关闭每步用户协助选择,使用脚本默认排序自动继续。") + parser.add_argument("--select-audiences", default="", help="Step1 用户选择的受众序号/名称,逗号分隔,例如 1,2,3。") + parser.add_argument("--select-pathways", default="", help="Step2 用户选择的关键传播节点序号/账号名/账号ID,逗号分隔。") + parser.add_argument("--select-audience-vulns", default="", help="Step3 用户选择的受众脆弱点序号,逗号分隔。") + parser.add_argument("--select-pathway-vulns", default="", help="Step4 用户选择的传播节点脆弱点序号,逗号分隔。") + parser.add_argument("--strategy-target", choices=["", "audience", "pathway"], default="", help="策略生成目标类型;Step5 只能 audience,Step6 只能 pathway。") + parser.add_argument("--select-strategy-audiences", default="", help="Step5 用户选择的受众策略生成对象序号/受众ID/名称,逗号分隔。") + parser.add_argument("--select-strategy-pathways", default="", help="Step6 用户选择的传播节点策略生成对象序号/节点ID/账号名,逗号分隔。") return parser @@ -2758,6 +3169,20 @@ def main(argv: list[str] | None = None) -> int: 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 + if config.get("require_user_assist") is False or config.get("user_assist") is False: + args.no_user_assist = True + selection_map = { + "select_audiences": "select_audiences", + "select_pathways": "select_pathways", + "select_audience_vulns": "select_audience_vulns", + "select_pathway_vulns": "select_pathway_vulns", + "select_strategy_audiences": "select_strategy_audiences", + "select_strategy_pathways": "select_strategy_pathways", + "strategy_target": "strategy_target", + } + for config_key, arg_name in selection_map.items(): + if config.get(config_key) is not None: + setattr(args, arg_name, config.get(config_key)) cli_config = { "api_base": args.api_base, @@ -2785,6 +3210,14 @@ def main(argv: list[str] | None = None) -> int: max_strategies=max(args.max_strategies, 1), downstream_audiences=max(args.downstream_audiences, 1), downstream_pathways=max(args.downstream_pathways, 1), + require_user_assist=not args.no_user_assist, + select_audience_refs=split_selection_refs(args.select_audiences), + select_pathway_refs=split_selection_refs(args.select_pathways), + select_audience_vuln_refs=split_selection_refs(args.select_audience_vulns), + select_pathway_vuln_refs=split_selection_refs(args.select_pathway_vulns), + select_strategy_audience_refs=split_selection_refs(args.select_strategy_audiences), + select_strategy_pathway_refs=split_selection_refs(args.select_strategy_pathways), + strategy_target=str(args.strategy_target or ""), sync_step_status=not args.no_step_status, model_retries=max(args.model_retries, 0), verify_ssl=bool(args.verify_ssl), @@ -2838,6 +3271,8 @@ def main(argv: list[str] | None = None) -> int: final_payload = { "日志类型": "执行完成", "是否成功": "是", + "是否等待用户选择": yes_no(has_pending_assist(state)), + "待用户选择": state.get("_pendingUserAssist"), "执行模式": mode_label(args.mode), "状态文件": str(state_path), "摘要文件": str(ctx.summary_path) if ctx.summary_path else "", diff --git a/skills-data/xdfx-step6-pathway-strategy/SKILL.md b/skills-data/xdfx-step6-pathway-strategy/SKILL.md index 54492394..476d5436 100644 --- a/skills-data/xdfx-step6-pathway-strategy/SKILL.md +++ b/skills-data/xdfx-step6-pathway-strategy/SKILL.md @@ -1,6 +1,6 @@ --- name: xdfx-step6-pathway-strategy -description: xdfx 拆分流程 step6,基于关键传播节点与节点脆弱点生成策略并保存。 +description: xdfx 拆分流程 step6,基于关键传播节点与节点脆弱点发起用户协助选择节点,生成并保存传播节点策略。 --- # Step6 传播节点 + 脆弱点策略生成与入库 @@ -17,8 +17,9 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl 本步骤由脚本完成: -- 从已入库关键传播节点中默认选择前 2 个继续生成策略;可用 `--downstream-pathways 1` 调成只处理 1 个。 -- 对每个被选中的关键传播节点读取已入库脆弱点并默认生成最多 3 个策略。 +- 只从已入库传播节点脆弱点的关键传播节点中列出策略生成对象。 +- 输出 `用户协助`,等待用户选择一个或多个关键传播节点;用户选择后带 `--select-strategy-pathways 1,2` 重跑 Step6,才生成并保存这些传播节点的策略。 +- Step6 只能选择关键传播节点,不能混入受众;不要传 `--select-strategy-audiences`。 - 调用策略生成模型接口。 - 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。 - 自动解析 `发布人设N / 账号类型N / 信息标题N` 等字段。 @@ -41,6 +42,8 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl 命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step6 的完成状态、当前传播节点策略数量和新增错误数,再做最终汇总。 +如果输出 `用户协助 {...}`,必须暂停流程,把传播节点策略生成对象发给用户;用户选择后重跑 Step6 并追加 `--select-strategy-pathways ...`。 + 执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。 保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的策略、策略子表和返回 ID。 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 8e62e51e..d385f95b 100644 --- a/skills-data/xdfx-step6-pathway-strategy/scripts/xdfx_flow.py +++ b/skills-data/xdfx-step6-pathway-strategy/scripts/xdfx_flow.py @@ -28,6 +28,10 @@ from typing import Any import xdfx_auth +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") # Keep every backend path in one table so endpoint drift is easy to audit. ENDPOINTS = { @@ -76,6 +80,7 @@ LOG_PREFIXES = { "operation": "操作反馈", "error": "错误反馈", "json_review": "JSON解析复核", + "assist": "用户协助", "save": "入库反馈", "step": "步骤反馈", "done": "执行完成", @@ -85,6 +90,7 @@ STATUS_LABELS = { "ok": "成功", "partial": "部分完成", "skipped": "已跳过", + "waiting_user": "等待用户选择", } STEP_NUMBER_LABELS = { @@ -226,6 +232,14 @@ class FlowContext: max_strategies: int = 3 downstream_audiences: int = 2 downstream_pathways: int = 2 + require_user_assist: bool = True + select_audience_refs: list[str] | None = None + select_pathway_refs: list[str] | None = None + select_audience_vuln_refs: list[str] | None = None + select_pathway_vuln_refs: list[str] | None = None + select_strategy_audience_refs: list[str] | None = None + select_strategy_pathway_refs: list[str] | None = None + strategy_target: str = "" sync_step_status: bool = True model_retries: int = 1 verify_ssl: bool = False @@ -284,6 +298,129 @@ def safe_compact(value: Any, limit: int = 1400) -> str: return compact(redact_sensitive(value), limit) +def split_selection_refs(raw: Any) -> list[str]: + """Parse user choices like '1,3' or ['1', '3'] into stable strings.""" + if raw is None: + return [] + if isinstance(raw, (list, tuple, set)): + parts: list[str] = [] + for item in raw: + parts.extend(split_selection_refs(item)) + return parts + text = str(raw).strip() + if not text: + return [] + if text.lower() in {"all", "全部"}: + return ["all"] + return [part.strip() for part in re.split(r"[,,;;\s]+", text) if part.strip()] + + +def normalized_ref(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")).lower() + + +def has_selection(refs: list[str] | None) -> bool: + return bool([ref for ref in refs or [] if str(ref).strip()]) + + +def clear_pending_assist(state: dict[str, Any]) -> None: + state.pop("_pendingUserAssist", None) + + +def has_pending_assist(state: dict[str, Any]) -> bool: + return isinstance(state.get("_pendingUserAssist"), dict) + + +def user_assist_command_hint(state: dict[str, Any], mode: str, flag_name: str, example: str) -> str: + command = f"python scripts/xdfx_flow.py --mode {mode} --state {state.get('_statePath') or 'xdfx-flow-state.json'}" + if state.get("_summaryPath"): + command += f" --summary {state.get('_summaryPath')}" + return f"{command} --{flag_name} {example}" + + +def emit_user_assist( + state: dict[str, Any], + stage: str, + *, + selection_key: str, + title: str, + prompt: str, + options: list[dict[str, Any]], + command_hint: str, + allow_multiple: bool = True, + max_count: int | None = None, + exclusive_note: str = "", +) -> None: + payload = { + "日志类型": "用户协助", + "阶段": stage_label(stage), + "步骤": feedback_step_label(stage), + "选择类型": selection_key, + "标题": title, + "提示": prompt, + "是否允许多选": yes_no(allow_multiple), + "最多选择数量": max_count, + "互斥规则": exclusive_note, + "选项": options, + "继续命令示例": command_hint, + "状态文件": state.get("_statePath", ""), + "摘要文件": state.get("_summaryPath", ""), + "时间": utc_now(), + } + state["_pendingUserAssist"] = { + "selectionKey": selection_key, + "stage": stage, + "title": title, + "commandHint": command_hint, + "optionCount": len(options), + "createdAt": payload["时间"], + } + print(LOG_PREFIXES["assist"] + " " + json.dumps(payload, ensure_ascii=False), flush=True) + record_event(state, stage, "等待用户协助选择", {"selectionKey": selection_key, "optionCount": len(options)}) + + +def ref_matches(ref: str, ordinal: int, aliases: list[Any]) -> bool: + normalized = normalized_ref(ref) + if normalized == "all": + return True + if normalized == str(ordinal): + return True + return any(normalized and normalized == normalized_ref(alias) for alias in aliases if alias is not None) + + +def select_by_refs( + items: list[Any], + refs: list[str], + alias_fn, + *, + max_count: int | None = None, +) -> tuple[list[Any], list[str]]: + if not refs: + selected = list(items) + elif any(normalized_ref(ref) in {"all", "全部"} for ref in refs): + selected = list(items) + else: + selected = [] + missing: list[str] = [] + for ref in refs: + found = None + for index, item in enumerate(items, start=1): + if ref_matches(ref, index, alias_fn(item, index)): + found = item + break + if found is None: + missing.append(ref) + continue + if found not in selected: + selected.append(found) + if max_count is not None: + return selected[:max_count], missing + return selected, missing + if max_count is not None: + selected = selected[:max_count] + return selected, [] + + def stable_json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) @@ -336,7 +473,7 @@ def load_json_file(path: str | Path) -> dict[str, Any]: p = Path(path) if not p.exists(): return {} - return json.loads(p.read_text(encoding="utf-8")) + return json.loads(p.read_text(encoding="utf-8-sig")) def save_json_file(path: str | Path, value: Any) -> None: @@ -369,6 +506,8 @@ def default_state() -> dict[str, Any]: "pathwayCandidates": [], "topPathways": [], "audiencesWithPathways": [], + "audienceVulnCandidates": {}, + "pathwayVulnCandidates": {}, "audienceVulns": {}, "pathwayVulns": {}, "audienceStrategies": [], @@ -2182,6 +2321,121 @@ def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, aud return selected +def audience_option(item: dict[str, Any], index: int) -> dict[str, Any]: + return { + "序号": index, + "受众目标": item.get("targetAudience"), + "推荐理由": compact(item.get("recReason", ""), 300), + "账号引用": item.get("userIndexes") or [], + } + + +def audience_aliases(item: dict[str, Any], index: int) -> list[Any]: + return [index, item.get("targetAudience"), item.get("rank")] + + +def pathway_option(item: dict[str, Any], index: int) -> dict[str, Any]: + return { + "序号": index, + "账号名称": item.get("accountName") or account_name(item.get("account") or {}), + "账号ID": item.get("accountId") or account_id(item.get("account") or {}), + "关联受众": item.get("relatedAudiences") or [], + "推荐理由": compact(item.get("recReason", ""), 300), + } + + +def pathway_aliases(item: dict[str, Any], index: int) -> list[Any]: + account = item.get("account") or item + return [ + index, + item.get("pathwayAccountId"), + item.get("pathwayId"), + item.get("accountId"), + item.get("accountName"), + account_id(account), + account_name(account), + ] + + +def vuln_candidate_entries( + items: list[dict[str, Any]], + candidate_store: dict[str, Any], + *, + audience: bool, +) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + seq = 1 + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + owner_name = item.get("targetAudience") if audience else item.get("accountName") + for index, vuln in enumerate(candidate_store.get(str(owner_id)) or [], start=1): + if not isinstance(vuln, dict): + continue + entries.append( + { + "seq": seq, + "ownerId": owner_id, + "ownerName": owner_name, + "localIndex": index, + "vuln": vuln, + } + ) + seq += 1 + return entries + + +def vuln_option(entry: dict[str, Any]) -> dict[str, Any]: + vuln = entry.get("vuln") or {} + return { + "序号": entry.get("seq"), + "对象ID": entry.get("ownerId"), + "对象名称": entry.get("ownerName"), + "本对象内序号": entry.get("localIndex"), + "脆弱点": vuln.get("vulnerabilities"), + "理由": compact(vuln.get("reason", ""), 260), + } + + +def vuln_aliases(entry: dict[str, Any], index: int) -> list[Any]: + vuln = entry.get("vuln") or {} + owner_id = entry.get("ownerId") + return [ + index, + entry.get("seq"), + f"{owner_id}:{entry.get('localIndex')}", + vuln.get("vulnerabilities"), + ] + + +def strategy_target_option(item: dict[str, Any], index: int, *, audience: bool) -> dict[str, Any]: + if audience: + return { + "序号": index, + "类型": "受众", + "对象ID": item.get("designId"), + "对象名称": item.get("targetAudience"), + "已入库脆弱点数量": len((item.get("_vulns") or [])), + "关联传播节点": item.get("downstreamPathways") or [], + } + return { + "序号": index, + "类型": "关键传播节点", + "对象ID": item.get("pathwayAccountId"), + "对象名称": item.get("accountName"), + "已入库脆弱点数量": len((item.get("_vulns") or [])), + "关联受众": item.get("relatedAudiences") or [], + } + + +def strategy_target_aliases(item: dict[str, Any], index: int, *, audience: bool) -> list[Any]: + return [ + index, + item.get("designId") if audience else item.get("pathwayAccountId"), + item.get("targetAudience") if audience else item.get("accountName"), + item.get("accountId") if not audience else None, + ] + + 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") @@ -2189,39 +2443,65 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None: if state.get("topAudiences") and not ctx.dry_run: record_event(state, stage, "topAudiences 已存在,跳过。如需重跑请清空 state 中该字段。") return - payload = { - "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。", - "otherinfo": {"方向": state.get("direction") or "U方向"}, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": f"任务背景和目的:{state['mubiao']}", - } - 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 + candidates = [item for item in state.get("audienceCandidates") or [] if isinstance(item, dict)] + accounts = [item for item in state.get("audienceAccounts") or [] if isinstance(item, dict)] + if not candidates: + payload = { + "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。", + "otherinfo": {"方向": state.get("direction") or "U方向"}, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": f"任务背景和目的:{state['mubiao']}", + } + 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。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。", - "zhanghao": accounts, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": state["mubiao"], - } - data, _, _ = call_model_json(ctx, state, "group", group_payload, stage + "/extract") - candidates = extract_audiences(data) - state["audienceCandidates"] = candidates - top = select_top_audiences(candidates, ctx.max_audiences) + # 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。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。", + "zhanghao": accounts, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": state["mubiao"], + } + data, _, _ = call_model_json(ctx, state, "group", group_payload, stage + "/extract") + candidates = extract_audiences(data) + state["audienceCandidates"] = candidates + if not candidates: + record_error(state, stage, "没有生成可供选择的受众候选") + return + if ctx.require_user_assist and not has_selection(ctx.select_audience_refs): + emit_user_assist( + state, + stage, + selection_key="audience", + title="请选择要入库并继续分析的受众", + prompt=f"可多选,最多建议选择 {ctx.max_audiences} 个。回复序号即可,例如:1,2,3。", + options=[audience_option(item, index) for index, item in enumerate(candidates, start=1)], + command_hint=user_assist_command_hint(state, "step1", "select-audiences", "1,2,3"), + allow_multiple=True, + max_count=ctx.max_audiences, + ) + return + selected_refs = ctx.select_audience_refs or [] + top, missing = select_by_refs(candidates, selected_refs, audience_aliases, max_count=ctx.max_audiences) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的受众未匹配:{', '.join(missing)}") + if not top: + record_error(state, stage, "没有选中任何受众,无法继续") + return saved: list[dict[str, Any]] = [] for item in top: # Save the audience first; account rows and downstream analyses depend @@ -2282,60 +2562,85 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: state["audiencesWithPathways"] = audience_pathway_summary(state) record_event(state, stage, "topPathways 已存在,跳过。如需重跑请清空 state 中该字段。") return - all_candidates: list[dict[str, Any]] = [] - for audience in audiences: - # Analyze pathway candidates in the context of each saved audience. - payload = { - "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。", - "otherinfo": { - "方向": state.get("direction") or "U方向", - "任务": "传播", - "平台": state.get("platform") or "fb", - "受众": {"名称": audience.get("targetAudience"), "理由": audience.get("recReason")}, - }, - "history": [], - "stream": True, - "model_name": "OpenAI3", - "temperature": 0.7, - "max_tokens": 20000, - "prompt_name": f"任务背景和目的:{state['mubiao']}", - } - 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。", - "zhanghao": accounts, - "history": [], - "stream": True, - "model_name": "liyou", - "temperature": 0.7, - "max_tokens": 1000, - "prompt_name": f"{audience.get('targetAudience')}:{audience.get('recReason')};", - } - data, _, _ = call_model_json(ctx, state, "spread", spread_payload, stage + "/extract") - refs = extract_pathway_refs(data) - for ref_item in refs: - account = resolve_account_ref(ref_item.get("ref"), accounts) - if account is None: - record_error(state, stage, f"传播节点引用 {ref_item.get('ref')} 未匹配账号", response=data) - continue - all_candidates.append( - { - "account": account, - "accountId": account_id(account), - "accountName": account_name(account), - "recReason": ref_item.get("recReason", ""), - "rank": ref_item.get("rank"), - "designIds": [audience["designId"]], - "relatedAudiences": [audience.get("targetAudience", "")], - } - ) - merged = merge_pathway_candidates(all_candidates) - state["pathwayCandidates"] = merged - top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways] + ranked = [item for item in state.get("pathwayCandidates") or [] if isinstance(item, dict)] + if not ranked: + all_candidates: list[dict[str, Any]] = [] + for audience in audiences: + # Analyze pathway candidates in the context of each saved audience. + payload = { + "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。", + "otherinfo": { + "方向": state.get("direction") or "U方向", + "任务": "传播", + "平台": state.get("platform") or "fb", + "受众": {"名称": audience.get("targetAudience"), "理由": audience.get("recReason")}, + }, + "history": [], + "stream": True, + "model_name": "OpenAI3", + "temperature": 0.7, + "max_tokens": 20000, + "prompt_name": f"任务背景和目的:{state['mubiao']}", + } + 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。", + "zhanghao": accounts, + "history": [], + "stream": True, + "model_name": "liyou", + "temperature": 0.7, + "max_tokens": 1000, + "prompt_name": f"{audience.get('targetAudience')}:{audience.get('recReason')};", + } + data, _, _ = call_model_json(ctx, state, "spread", spread_payload, stage + "/extract") + refs = extract_pathway_refs(data) + for ref_item in refs: + account = resolve_account_ref(ref_item.get("ref"), accounts) + if account is None: + record_error(state, stage, f"传播节点引用 {ref_item.get('ref')} 未匹配账号", response=data) + continue + all_candidates.append( + { + "account": account, + "accountId": account_id(account), + "accountName": account_name(account), + "recReason": ref_item.get("recReason", ""), + "rank": ref_item.get("rank"), + "designIds": [audience["designId"]], + "relatedAudiences": [audience.get("targetAudience", "")], + } + ) + merged = merge_pathway_candidates(all_candidates) + ranked = sorted(merged, key=pathway_score, reverse=True) + state["pathwayCandidates"] = ranked + if not ranked: + record_error(state, stage, "没有生成可供选择的关键传播节点候选") + return + if ctx.require_user_assist and not has_selection(ctx.select_pathway_refs): + emit_user_assist( + state, + stage, + selection_key="pathway", + title="请选择要入库并继续分析的关键传播节点", + prompt=f"可多选,最多建议选择 {ctx.max_pathways} 个。回复序号、账号名称或账号ID均可,例如:1,2,3。", + options=[pathway_option(item, index) for index, item in enumerate(ranked, start=1)], + command_hint=user_assist_command_hint(state, "step2", "select-pathways", "1,2,3"), + allow_multiple=True, + max_count=ctx.max_pathways, + ) + return + top, missing = select_by_refs(ranked, ctx.select_pathway_refs or [], pathway_aliases, max_count=ctx.max_pathways) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的关键传播节点未匹配:{', '.join(missing)}") + if not top: + record_error(state, stage, "没有选中任何关键传播节点,无法继续") + return saved: list[dict[str, Any]] = [] for item in top: # Save the account row before writing the design-pathway relation. @@ -2395,7 +2700,10 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> stage = "step3-audience-vulns" if audience else "step4-pathway-vulns" items = downstream_items(ctx, state, stage, audience=audience) output_key = "audienceVulns" if audience else "pathwayVulns" + candidate_key = "audienceVulnCandidates" if audience else "pathwayVulnCandidates" + select_refs = ctx.select_audience_vuln_refs if audience else ctx.select_pathway_vuln_refs state.setdefault(output_key, {}) + candidate_store = state.setdefault(candidate_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") @@ -2403,6 +2711,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> continue if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run: continue + if str(owner_id) in candidate_store and candidate_store[str(owner_id)]: + continue zhanghao = selected_accounts_for_item(item, audience=audience) prompt = ( "你是中国对台湾方向的认知战专家,你要传播行动要求的叙事主题。请基于该受众目标的画像、关注议题、社会分工定位、利益诉求与上一轮账号佐证,分析该受众目标有哪些脆弱点可以成为后续策略设计的关键要素。请输出理由、脆弱点1、理由1、脆弱点2、理由2、脆弱点3、理由3,只返回JSON。" @@ -2422,8 +2732,43 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> } data, _, _ = call_model_json(ctx, state, "cuiruo", payload, stage + "/extract") vulns = extract_vulns(data, ctx.max_vulns) + candidate_store[str(owner_id)] = vulns + entries = vuln_candidate_entries(items, candidate_store, audience=audience) + if not entries: + record_error(state, stage, "没有生成可供选择的脆弱点候选") + return + if ctx.require_user_assist and not has_selection(select_refs): + flag = "select-audience-vulns" if audience else "select-pathway-vulns" + emit_user_assist( + state, + stage, + selection_key="audience_vuln" if audience else "pathway_vuln", + title="请选择要入库的受众脆弱点" if audience else "请选择要入库的传播节点脆弱点", + prompt="可多选。回复全局序号即可,例如:1,2,4;也可用 对象ID:本对象内序号。", + options=[vuln_option(entry) for entry in entries], + command_hint=user_assist_command_hint(state, "step3" if audience else "step4", flag, "1,2,4"), + allow_multiple=True, + ) + return + selected_entries, missing = select_by_refs(entries, select_refs or [], vuln_aliases) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的脆弱点未匹配:{', '.join(missing)}") + selected_by_owner: dict[str, list[dict[str, Any]]] = {} + for entry in selected_entries: + owner_key = str(entry.get("ownerId")) + selected_by_owner.setdefault(owner_key, []).append(entry.get("vuln") or {}) + if not selected_by_owner: + record_error(state, stage, "没有选中任何脆弱点,无法继续") + return + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + if not owner_id: + continue + if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run: + continue saved: list[dict[str, Any]] = [] - for vuln in vulns: + for vuln in selected_by_owner.get(str(owner_id), []): # The two weak-point tables have nearly identical payloads but # different foreign-key names. if audience: @@ -2475,8 +2820,53 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool """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" + if audience and (has_selection(ctx.select_strategy_pathway_refs) or ctx.strategy_target == "pathway"): + clear_pending_assist(state) + record_error(state, stage, "策略生成目标类型冲突:Step5 只能选择受众,不能同时选择关键传播节点") + return + if not audience and (has_selection(ctx.select_strategy_audience_refs) or ctx.strategy_target == "audience"): + clear_pending_assist(state) + record_error(state, stage, "策略生成目标类型冲突:Step6 只能选择关键传播节点,不能同时选择受众") + return items = downstream_items(ctx, state, stage, audience=audience) vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {} + selectable_items: list[dict[str, Any]] = [] + for item in items: + owner_id = item.get("designId") if audience else item.get("pathwayAccountId") + vulns = vulns_by_owner.get(str(owner_id)) or [] + if vulns: + selectable_items.append({**item, "_vulns": vulns}) + select_refs = ctx.select_strategy_audience_refs if audience else ctx.select_strategy_pathway_refs + if selectable_items and ctx.require_user_assist and not has_selection(select_refs): + flag = "select-strategy-audiences" if audience else "select-strategy-pathways" + emit_user_assist( + state, + stage, + selection_key="strategy_audience" if audience else "strategy_pathway", + title="请选择要生成策略的受众" if audience else "请选择要生成策略的关键传播节点", + prompt=( + "可多选,但本步骤只能选择受众,不能混选关键传播节点。回复序号即可,例如:1,2。" + if audience + else "可多选,但本步骤只能选择关键传播节点,不能混选受众。回复序号即可,例如:1,2。" + ), + options=[strategy_target_option(item, index, audience=audience) for index, item in enumerate(selectable_items, start=1)], + command_hint=user_assist_command_hint(state, "step5" if audience else "step6", flag, "1,2"), + allow_multiple=True, + exclusive_note="受众策略和关键传播节点策略不能在同一步命令中交叉选择。", + ) + return + selected_items, missing = select_by_refs( + selectable_items, + select_refs or [], + lambda item, index: strategy_target_aliases(item, index, audience=audience), + ) + clear_pending_assist(state) + if missing: + record_error(state, stage, f"用户选择的策略生成对象未匹配:{', '.join(missing)}") + items = selected_items + if not items: + record_error(state, stage, "没有选中任何具备脆弱点的策略生成对象,无法继续") + return 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)} @@ -2583,14 +2973,15 @@ def emit_step_feedback( # 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") + final_status = status or ("waiting_user" if has_pending_assist(state) else ("partial" if new_errors else "ok")) + final_note = note or (state.get("_pendingUserAssist", {}).get("title", "") if has_pending_assist(state) else "") payload = { "日志类型": "步骤反馈", "步骤": STEP_NUMBER_LABELS.get(step, step), "步骤名称": STEP_LABELS.get(step, step), "状态": STATUS_LABELS.get(final_status, final_status), "执行状态": status_label(final_status), - "备注": note, + "备注": final_note, "本步骤新增错误数": new_errors, "累计错误数": total_errors, "累计入库数量": state_counts(state), @@ -2638,15 +3029,27 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None: for dry-runs and unattended local troubleshooting. """ run_step_with_feedback(ctx, state, "step1") + if has_pending_assist(state): + return run_step_with_feedback(ctx, state, "step2") + if has_pending_assist(state): + return run_step_with_feedback(ctx, state, "step3") + if has_pending_assist(state): + return if state.get("topPathways"): run_step_with_feedback(ctx, state, "step4") + if has_pending_assist(state): + return else: skip_step_with_feedback(ctx, state, "step4", "没有已入库关键传播节点") run_step_with_feedback(ctx, state, "step5") + if has_pending_assist(state): + return if state.get("topPathways"): run_step_with_feedback(ctx, state, "step6") + if has_pending_assist(state): + return else: skip_step_with_feedback(ctx, state, "step6", "没有已入库关键传播节点") @@ -2722,6 +3125,14 @@ def build_arg_parser() -> argparse.ArgumentParser: 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。") + parser.add_argument("--no-user-assist", action="store_true", help="关闭每步用户协助选择,使用脚本默认排序自动继续。") + parser.add_argument("--select-audiences", default="", help="Step1 用户选择的受众序号/名称,逗号分隔,例如 1,2,3。") + parser.add_argument("--select-pathways", default="", help="Step2 用户选择的关键传播节点序号/账号名/账号ID,逗号分隔。") + parser.add_argument("--select-audience-vulns", default="", help="Step3 用户选择的受众脆弱点序号,逗号分隔。") + parser.add_argument("--select-pathway-vulns", default="", help="Step4 用户选择的传播节点脆弱点序号,逗号分隔。") + parser.add_argument("--strategy-target", choices=["", "audience", "pathway"], default="", help="策略生成目标类型;Step5 只能 audience,Step6 只能 pathway。") + parser.add_argument("--select-strategy-audiences", default="", help="Step5 用户选择的受众策略生成对象序号/受众ID/名称,逗号分隔。") + parser.add_argument("--select-strategy-pathways", default="", help="Step6 用户选择的传播节点策略生成对象序号/节点ID/账号名,逗号分隔。") return parser @@ -2758,6 +3169,20 @@ def main(argv: list[str] | None = None) -> int: 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 + if config.get("require_user_assist") is False or config.get("user_assist") is False: + args.no_user_assist = True + selection_map = { + "select_audiences": "select_audiences", + "select_pathways": "select_pathways", + "select_audience_vulns": "select_audience_vulns", + "select_pathway_vulns": "select_pathway_vulns", + "select_strategy_audiences": "select_strategy_audiences", + "select_strategy_pathways": "select_strategy_pathways", + "strategy_target": "strategy_target", + } + for config_key, arg_name in selection_map.items(): + if config.get(config_key) is not None: + setattr(args, arg_name, config.get(config_key)) cli_config = { "api_base": args.api_base, @@ -2785,6 +3210,14 @@ def main(argv: list[str] | None = None) -> int: max_strategies=max(args.max_strategies, 1), downstream_audiences=max(args.downstream_audiences, 1), downstream_pathways=max(args.downstream_pathways, 1), + require_user_assist=not args.no_user_assist, + select_audience_refs=split_selection_refs(args.select_audiences), + select_pathway_refs=split_selection_refs(args.select_pathways), + select_audience_vuln_refs=split_selection_refs(args.select_audience_vulns), + select_pathway_vuln_refs=split_selection_refs(args.select_pathway_vulns), + select_strategy_audience_refs=split_selection_refs(args.select_strategy_audiences), + select_strategy_pathway_refs=split_selection_refs(args.select_strategy_pathways), + strategy_target=str(args.strategy_target or ""), sync_step_status=not args.no_step_status, model_retries=max(args.model_retries, 0), verify_ssl=bool(args.verify_ssl), @@ -2838,6 +3271,8 @@ def main(argv: list[str] | None = None) -> int: final_payload = { "日志类型": "执行完成", "是否成功": "是", + "是否等待用户选择": yes_no(has_pending_assist(state)), + "待用户选择": state.get("_pendingUserAssist"), "执行模式": mode_label(args.mode), "状态文件": str(state_path), "摘要文件": str(ctx.summary_path) if ctx.summary_path else "", From 3e5120802492fd870746d277334db3313952f4bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Thu, 2 Jul 2026 10:06:24 +0800 Subject: [PATCH 2/3] feat: add roundtable chain rail scroll --- .../pages/RoundtablePlanningPage.tsx | 78 ++++++++++++------- 1 file changed, 51 insertions(+), 27 deletions(-) diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index ffb04607..b7718f96 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -2729,7 +2729,8 @@ function LeftSidebar({ ref={dzRef} id="left-sidebar" className={cn( - "shrink-0 flex flex-col gap-3 overflow-y-auto pr-1 transition-[width] duration-200 ease-out", + "min-h-0 shrink-0 flex flex-col gap-3 pr-1 transition-[width] duration-200 ease-out", + isStep2 ? "overflow-y-auto" : "overflow-hidden", isStep2 && !sidebarExpanded ? "w-[56px]" : "w-[280px]", )} > @@ -2911,6 +2912,7 @@ function RoundtableEntryRail({ businessCode?: string | null; }) { const [chains, setChains] = useState([]); + const [chainSectionExpanded, setChainSectionExpanded] = useState(true); const [expandedChainId, setExpandedChainId] = useState(null); const { drafts: records, refresh } = useRoundtableDrafts(taskId); // 分析记录「按业务链筛选」—— 选项从已加载草稿的 chainTitle 去重得到, @@ -2961,7 +2963,7 @@ function RoundtableEntryRail({ let cancelled = false; void listChains("public") .then((all) => { - if (!cancelled) setChains(all.slice(0, 3)); + if (!cancelled) setChains(all); }) .catch(() => { /* 业务链非关键,静默忽略 */ @@ -2985,7 +2987,7 @@ function RoundtableEntryRail({ }; return ( - <> +
{/* 深链接(taskId)模式:左上角展示「当前任务」(rwfx/3qfx)或「当前行动」(xdfx),替代业务链选择。 */} {deeplink ? ( isXdfx ? ( @@ -3028,33 +3030,50 @@ function RoundtableEntryRail({
) ) : ( - /* 业务链(公共,前 3 个) */ + /* 业务链(公共列表,内部滚动) */
-
+

业务链

+ + {chains.length} + +
- {chains.length === 0 ? ( + {chainSectionExpanded && chains.length === 0 ? (

暂无公共业务链

- ) : ( -
- {chains.map((chain) => { - const selected = selectedChainId === chain.id; - const graphOpen = expandedChainId === chain.id; - const stageGroups = getChainStageGroups(chain); - let seatColorIndex = 0; - return ( -
+ ) : chainSectionExpanded ? ( +
+
+ {chains.map((chain) => { + const selected = selectedChainId === chain.id; + const graphOpen = expandedChainId === chain.id; + const stageGroups = getChainStageGroups(chain); + let seatColorIndex = 0; + return ( +
)} -
- ); - })} +
+ ); + })} +
+ ) : ( +

+ 已收起,可展开查看全部公共业务链。 +

)}
)} @@ -3342,7 +3366,7 @@ function RoundtableEntryRail({ )}
- + ); } From b12497898aa1aa3a7a816edbaa4c2d171623fa03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Thu, 2 Jul 2026 10:14:40 +0800 Subject: [PATCH 3/3] fix: polish roundtable chain rail toggle --- .../pages/RoundtablePlanningPage.tsx | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index b7718f96..633964e5 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -3032,12 +3032,12 @@ function RoundtableEntryRail({ ) : ( /* 业务链(公共列表,内部滚动) */
-
+

业务链

- + {chains.length}
- {chainSectionExpanded && chains.length === 0 ? ( -

暂无公共业务链

- ) : chainSectionExpanded ? ( -
-
+
+
+ {chains.length === 0 ? ( +

暂无公共业务链

+ ) : ( +
+
{chains.map((chain) => { const selected = selectedChainId === chain.id; const graphOpen = expandedChainId === chain.id; @@ -3225,13 +3235,11 @@ function RoundtableEntryRail({
); })} +
+
+ )}
- ) : ( -

- 已收起,可展开查看全部公共业务链。 -

- )}
)}