diff --git a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx
index e36087c8..2338e457 100644
--- a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx
+++ b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx
@@ -138,6 +138,7 @@ export function AgentDetailPanel({
disabled,
defaultCollapsed,
showRecentChats,
+ compactAppearanceActions,
}: {
agent: Agent | null | undefined;
agentName: string;
@@ -147,6 +148,8 @@ export function AgentDetailPanel({
defaultCollapsed?: boolean;
/** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */
showRecentChats?: boolean;
+ /** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */
+ compactAppearanceActions?: ReactNode;
}) {
const router = useRouter();
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
@@ -830,6 +833,7 @@ export function AgentDetailPanel({
智能体信息
{headerActions}
+ {compactAppearanceActions}
{collapseButton}
@@ -841,17 +845,20 @@ export function AgentDetailPanel({
<>
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
{collapsed ? (
-
diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx
index 684d341d..5aef92ea 100644
--- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx
+++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ChevronsDown, CloudUpload, PanelRightClose, Pause, Play, Plus, RotateCcw, Settings2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
@@ -73,11 +73,13 @@ export function AIWritingPanel({
initialNotebookId,
initialDocumentIds,
studioToken,
+ headerActionsBeforeCollapse,
}: {
hideAdvancedFields?: boolean;
initialNotebookId?: number;
initialDocumentIds?: number[];
studioToken?: string;
+ headerActionsBeforeCollapse?: ReactNode;
} = {}) {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
@@ -429,6 +431,7 @@ export function AIWritingPanel({
onRename={rename}
onRefresh={refresh}
/>
+ {headerActionsBeforeCollapse}
{/* 收起写作配置:仅在 idle 配置阶段、表单展开时出现(与 AIWritingPage 的整块收起联动) */}
{writingForm && isIdle && !isHistoryMode && writingForm.formExpanded && (
)}
diff --git a/frontend-web/src/pages/LightAppManagementPage.tsx b/frontend-web/src/pages/LightAppManagementPage.tsx
index 679c6e95..6fe09c3d 100644
--- a/frontend-web/src/pages/LightAppManagementPage.tsx
+++ b/frontend-web/src/pages/LightAppManagementPage.tsx
@@ -89,9 +89,9 @@ const emptyForm = (): FormState => ({
});
const newParam = (): LightAppRouteParam => ({
- key: "",
+ key: "access_token",
paramType: "login",
- source: "accessToken",
+ source: "access_token",
customValue: "",
themeValue: "red=red;blue=blue;dark-blue=dark-blue",
});
@@ -107,7 +107,7 @@ function formFromApp(app: LightApp): FormState {
routeParams: app.routeParams.map((p) => ({
key: p.key,
paramType: p.paramType,
- source: p.source ?? "accessToken",
+ source: p.source ?? "access_token",
customValue: p.customValue ?? "",
themeValue: p.themeValue ?? "",
})),
@@ -118,7 +118,7 @@ function getParamSourceLabel(param: LightAppRouteParam): string {
if (param.paramType === "theme") {
return `主题参数: ${param.themeValue || ""}`;
}
- const source = param.source || "accessToken";
+ const source = param.source || "access_token";
const option = LOGIN_SOURCE_OPTIONS.find((o) => o.value === source);
if (source === "other") {
return `登录参数: ${param.customValue || ""}`;
@@ -977,7 +977,7 @@ export default function LightAppManagementPage() {
onChange={(v) =>
patchParam(idx, {
paramType: v as "login" | "theme",
- source: v === "login" ? (p.source || "accessToken") : undefined,
+ source: v === "login" ? (p.source || "access_token") : undefined,
})
}
/>
@@ -988,7 +988,7 @@ export default function LightAppManagementPage() {
+ ) : null
+ }
+ />
diff --git a/frontend-web/src/pages/TaskButtonsPage.tsx b/frontend-web/src/pages/TaskButtonsPage.tsx
index b624cb72..a4ce58b8 100644
--- a/frontend-web/src/pages/TaskButtonsPage.tsx
+++ b/frontend-web/src/pages/TaskButtonsPage.tsx
@@ -124,7 +124,7 @@ export default function TaskButtonsPage() {
);
const addLoginParam = (id: string) =>
- updateLoginParams(id, (ps) => [...ps, { key: "", source: "accessToken", customValue: "" }]);
+ updateLoginParams(id, (ps) => [...ps, { key: "access_token", source: "access_token", customValue: "" }]);
const patchLoginParam = (id: string, idx: number, patch: Partial) =>
updateLoginParams(id, (ps) => ps.map((p, i) => (i === idx ? { ...p, ...patch } : p)));
diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx
index 1d58ac9f..a303c9a2 100644
--- a/frontend-web/src/pages/WorkspaceRoutes.tsx
+++ b/frontend-web/src/pages/WorkspaceRoutes.tsx
@@ -5,7 +5,12 @@ import { getStoredAuth } from '@/core/auth';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
+import {
+ LayoutModeSwitcher,
+ ThemeSwitcher,
+} from '@/components/workspace/appearance-controls';
import { WorkspaceSidebar } from '@/components/workspace/workspace-sidebar';
+import { UserIdentityBadge } from '@/components/workspace/user-identity-badge';
import { CommandPalette } from '@/components/workspace/command-palette';
import { Toaster } from 'sonner';
import { SubtasksProvider } from '@/core/tasks/context';
@@ -73,6 +78,27 @@ function isInIframe(): boolean {
}
}
+function shouldShowCompactTopActions(pathname: string): boolean {
+ return (
+ pathname === '/page/workspace/agents' ||
+ pathname === '/page/workspace/skills' ||
+ pathname === '/page/workspace/memory' ||
+ pathname === '/page/workspace/knowledge' ||
+ pathname.startsWith('/page/workspace/knowledge/')
+ );
+}
+
+function CompactWorkspaceTopActions() {
+ return (
+
+ );
+}
+
export function WorkspaceLayout({
children,
pageSidebarCollapsed,
@@ -99,7 +125,10 @@ export function WorkspaceLayout({
}) {
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
- const isGeneralQa = useLocation().pathname.startsWith("/page/workspace/chats");
+ const pathname = useLocation().pathname;
+ const isGeneralQa = pathname.startsWith('/page/workspace/chats');
+ const showCompactTopActions =
+ headerHidden && shouldShowCompactTopActions(pathname);
const pageSidebarWidth = pageSidebarHidden
? '0rem'
@@ -137,7 +166,14 @@ export function WorkspaceLayout({
- {children}
+ {showCompactTopActions ? (
+ <>
+
+ {children}
+ >
+ ) : (
+ children
+ )}
@@ -223,7 +259,7 @@ export default function WorkspaceRoutes({
path="chats"
element={
-
+
}
/>
@@ -339,7 +375,10 @@ export default function WorkspaceRoutes({
path="ai-writing/*"
element={
-
+
}
/>
@@ -372,7 +411,7 @@ export default function WorkspaceRoutes({
element={
-
+
}
@@ -434,7 +473,7 @@ export default function WorkspaceRoutes({
path="scheduled/tasks"
element={
-
+
}
/>
diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
index 0c33fc0d..ffb04607 100644
--- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
+++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
@@ -61,6 +61,10 @@ import {
import { useTheme } from "@/contexts/ThemeContext";
import { useArtifacts } from "@/components/workspace/artifacts";
+import {
+ LayoutModeSwitcher,
+ ThemeSwitcher,
+} from "@/components/workspace/appearance-controls";
import { useSidebarSafe } from "@/components/ui/sidebar";
import {
DropdownMenu,
@@ -282,7 +286,11 @@ function readEmbedHashQueryPrefill(): RoundtablePrefill | null {
}
-export default function RoundtablePlanningPage() {
+export default function RoundtablePlanningPage({
+ compactChrome = false,
+}: {
+ compactChrome?: boolean;
+}) {
const { theme } = useTheme();
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
const { watchJob } = useRoundtableJobNotifier();
@@ -2075,6 +2083,12 @@ export default function RoundtablePlanningPage() {
>
+ {compactChrome && (
+ <>
+
+
+ >
+ )}
diff --git a/frontend-web/src/shims/next-navigation.ts b/frontend-web/src/shims/next-navigation.ts
index b3bf4941..7ab31dde 100644
--- a/frontend-web/src/shims/next-navigation.ts
+++ b/frontend-web/src/shims/next-navigation.ts
@@ -8,21 +8,21 @@ import {
import { RouteBaseContext } from "@/core/route-base";
-export function useRouter() {
- const navigate = useNavigate();
- const base = useContext(RouteBaseContext);
-
- return useMemo(
- () => ({
- push: (href: string) =>
- navigate(href.replace("/page/workspace", base)),
- replace: (href: string) =>
- navigate(href.replace("/page/workspace", base), { replace: true }),
- back: () => navigate(-1),
- forward: () => navigate(1),
- refresh: () => window.location.reload(),
- prefetch: async () => {},
- }),
+export function useRouter() {
+ const navigate = useNavigate();
+ const base = useContext(RouteBaseContext);
+ const resolveHref = (href: string) =>
+ href.startsWith(base) ? href : href.replace("/page/workspace", base);
+
+ return useMemo(
+ () => ({
+ push: (href: string) => navigate(resolveHref(href)),
+ replace: (href: string) => navigate(resolveHref(href), { replace: true }),
+ back: () => navigate(-1),
+ forward: () => navigate(1),
+ refresh: () => window.location.reload(),
+ prefetch: async () => {},
+ }),
[navigate, base],
);
}
diff --git a/frontend-web/src/strategy-components/api/task-buttons.ts b/frontend-web/src/strategy-components/api/task-buttons.ts
index f63199b1..4b22536b 100644
--- a/frontend-web/src/strategy-components/api/task-buttons.ts
+++ b/frontend-web/src/strategy-components/api/task-buttons.ts
@@ -49,7 +49,7 @@ interface ServerButton {
function toLoginParam(p: ServerLoginParam): TaskButtonLoginParam {
return {
key: p.key ?? "",
- source: p.source || "accessToken",
+ source: p.source || "access_token",
customValue: p.custom_value ?? "",
};
}
diff --git a/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.tsx b/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.tsx
index 959ab7f2..79d5cfa6 100644
--- a/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.tsx
+++ b/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.tsx
@@ -28,6 +28,7 @@ import {
platformTypeChange,
} from './GeneralQA.mock';
import { requestCrudService } from '@/service/request-axios-service';
+import { authStorageGet } from '@/core/auth/scoped-storage';
/**
* 通用问答组件
@@ -138,7 +139,7 @@ const GeneralQA: React.FC<{
const mockSingleRoundStoppedRef = useRef(false);
const mockMultiRoundStoppedRef = useRef(false);
const chatListRef = useRef(null);
- const userId = useRef(localStorage.getItem('userId') || '387'); // 用户id
+ const userId = useRef(authStorageGet('userId') || '387'); // 用户id
const qaType = useRef('GENERAL'); // 问答类型
const chatDataId = useRef(); // 聊天窗口id
const compareChatId = useRef(); // 对比对话记录id
diff --git a/frontend-web/src/strategy-components/lib/light-app.ts b/frontend-web/src/strategy-components/lib/light-app.ts
index bde56c29..c3a2f1ae 100644
--- a/frontend-web/src/strategy-components/lib/light-app.ts
+++ b/frontend-web/src/strategy-components/lib/light-app.ts
@@ -7,7 +7,7 @@
import type { LightApp, LightAppRouteParam } from "@/strategy-components/api/light-apps";
import { authStorageGet } from "@/core/auth/scoped-storage";
-import { normalizeExternalAccessToken } from "@/core/auth/external-access-token";
+import { readCurrentExternalAccessTokenValue } from "@/core/auth/external-access-token";
/** iframe 可挂载的父菜单(取自 core/page-layout/sidebar-menu.ts 的现有菜单 id)。 */
export const MOUNT_OPTIONS: { label: string; value: string }[] = [
@@ -29,7 +29,8 @@ export function mountLabel(value: string | null | undefined): string {
* userName / gateDepartName / typeName 取 consumer 接口 userDetailByYuserId 的返回值。
*/
export const LOGIN_SOURCE_OPTIONS: { label: string; value: string }[] = [
- { label: "accessToken", value: "accessToken" },
+ { label: "access_token", value: "access_token" },
+ { label: "accessToken(兼容旧配置)", value: "accessToken" },
{ label: "yUserId", value: "yUserId" },
{ label: "userName", value: "userName" },
{ label: "gateDepartName", value: "gateDepartName" },
@@ -143,8 +144,8 @@ export function resolveRouteParamValue(
}
if (param.source) {
const value = userInfo[param.source];
- if (param.source === "accessToken") {
- return normalizeExternalAccessToken(value);
+ if (param.source === "access_token" || param.source === "accessToken") {
+ return readCurrentExternalAccessTokenValue(userInfo);
}
return value == null ? "" : String(value);
}
@@ -157,15 +158,50 @@ export function resolveRouteParamValue(
* 兼容 hash 路由地址(如 `https://x#/route`)—— 管理员粘贴完整地址,参数自动追加在末尾,
* 落在 hash 路由的 query 上(与现有 XK/PG 页面 `?accessToken=` 拼接方式一致)。
*/
+function appendQueryPairs(url: string, pairs: string[]): string {
+ if (pairs.length === 0) return url;
+ const hashIndex = url.indexOf("#");
+ if (hashIndex >= 0) {
+ const beforeHash = url.slice(0, hashIndex);
+ const hash = url.slice(hashIndex);
+ const sep = hash.includes("?") ? "&" : "?";
+ return `${beforeHash}${hash}${sep}${pairs.join("&")}`;
+ }
+ const sep = url.includes("?") ? "&" : "?";
+ return `${url}${sep}${pairs.join("&")}`;
+}
+
+function replaceAccessTokenParam(url: string, accessToken: string): { url: string; replaced: boolean } {
+ if (!accessToken) return { url, replaced: false };
+ let replaced = false;
+ const nextUrl = url.replace(/([?&]access_token)(?:=[^]*)?(?=[]|$)/g, (match) => {
+ replaced = true;
+ return `${match.split("=")[0]}=${encodeURIComponent(accessToken)}`;
+ });
+ return { url: nextUrl, replaced };
+}
+
export function buildLightAppUrl(
app: Pick,
userInfo: Record = readUserInfo(),
theme?: AppThemeColor,
): string {
- const pairs = (app.routeParams ?? [])
- .filter((p) => p.key)
- .map((p) => `${encodeURIComponent(p.key)}=${encodeURIComponent(resolveRouteParamValue(p, userInfo, theme))}`);
- if (pairs.length === 0) return app.url;
- const sep = app.url.includes("?") ? "&" : "?";
- return `${app.url}${sep}${pairs.join("&")}`;
+ const routeParams = app.routeParams ?? [];
+ const accessToken = readCurrentExternalAccessTokenValue(userInfo);
+ const tokenUrlResult = replaceAccessTokenParam(app.url, accessToken);
+ const pairs = routeParams
+ .filter((p) => (p.key ?? "").trim())
+ .filter((p) => (p.key ?? "").trim() !== "access_token" || !tokenUrlResult.replaced)
+ .map((p) => {
+ const key = (p.key ?? "").trim();
+ const value = key === "access_token" ? accessToken : resolveRouteParamValue(p, userInfo, theme);
+ return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
+ });
+ const hasAccessTokenParam =
+ tokenUrlResult.replaced ||
+ routeParams.some((p) => (p.key ?? "").trim() === "access_token");
+ if (!hasAccessTokenParam && accessToken) {
+ pairs.push(`${encodeURIComponent("access_token")}=${encodeURIComponent(accessToken)}`);
+ }
+ return appendQueryPairs(tokenUrlResult.url, pairs);
}
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py
index 77b51ff5..b53b5955 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py
@@ -349,7 +349,10 @@ async def _search_notebook(
results: list[Material] = []
mat_idx = 0
try:
- async with httpx.AsyncClient(timeout=20) as client:
+ # trust_env=False:笔记后端是内网/本机地址,但模型走公网,部署常设
+ # HTTP_PROXY/HTTPS_PROXY;httpx 默认会把这个内网请求也走代理 → 不可达。
+ # 强制忽略代理 env、直连(与内网 ES 检索同款修法)。
+ async with httpx.AsyncClient(timeout=20, trust_env=False) as client:
for kw in keywords[:_NOTEBOOK_MAX_KEYWORDS]:
logger.info("[notebook-search] POST kw=%r body=%s", kw, {
"query": kw,
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py
index ce3e6f2b..3f5fcce1 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py
@@ -113,8 +113,19 @@ class IntranetSearchProvider(BaseSearchProvider):
"ok": False,
"count": 0,
}
+ # 排查用:每次真实调用都打一行请求明细(端点 / 知识库 / 检索词 / 超时),
+ # 内网复现时直接看后端日志即可知道"到底请求了哪个地址、用什么参数"。
+ logger.info(
+ "[intranet-search] POST endpoint=%s kb=%s timeout=%ss query=%r",
+ self.endpoint, self.knowledge_base_name, self.timeout, query,
+ )
try:
- async with httpx.AsyncClient(timeout=self.timeout) as client:
+ # trust_env=False:**关键**。内网 ES 是内网地址,但本部署的模型走公网,
+ # 服务器通常设了 HTTP_PROXY/HTTPS_PROXY 出公网;httpx 默认 trust_env=True
+ # 会把这个内网请求也套用公网代理 → 代理到不了内网 IP → 连接被重置/空回复,
+ # 表现为「接口异常」。强制忽略代理 env、直连内网(与 multi_agent.py 的回环
+ # 调用同款修法)。
+ async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client:
resp = await client.post(self.endpoint, json=payload)
call["http_status"] = getattr(resp, "status_code", None)
resp.raise_for_status()
@@ -122,23 +133,31 @@ class IntranetSearchProvider(BaseSearchProvider):
data = resp.json()
except httpx.HTTPStatusError as e:
# 非 2xx:接口有响应但状态码异常,把响应体一并暴露给前端
- logger.warning("intranet search failed for query=%r: %s", query, e)
+ body_for_log = e.response.text if e.response is not None else ""
+ logger.warning(
+ "[intranet-search] HTTP error endpoint=%s query=%r status=%s body=%s",
+ self.endpoint, query, getattr(e.response, "status_code", None), _raw_snippet(body_for_log),
+ )
message = f"{type(e).__name__}: {e}"
self.errors.append(message)
- body = e.response.text if e.response is not None else ""
call.update({
"status": "http_error",
"error": message,
"http_status": getattr(e.response, "status_code", None),
})
- snippet = _raw_snippet(body)
+ snippet = _raw_snippet(body_for_log)
if snippet:
call["raw_response"] = snippet
- self._record_raw(body)
+ self._record_raw(body_for_log)
return []
except Exception as e:
- # 连接失败 / 超时 / JSON 解析失败等;JSON 解析失败时 raw_text 已是接口响应体
- logger.warning("intranet search failed for query=%r: %s", query, e)
+ # 连接失败 / 超时 / JSON 解析失败等;JSON 解析失败时 raw_text 已是接口响应体。
+ # 打出异常类型,便于区分 ConnectError / ConnectTimeout / ReadTimeout /
+ # RemoteProtocolError(空回复) / JSONDecodeError(返回非 JSON) 等具体根因。
+ logger.warning(
+ "[intranet-search] request failed endpoint=%s query=%r error=%s: %s",
+ self.endpoint, query, type(e).__name__, e,
+ )
message = f"{type(e).__name__}: {e}"
self.errors.append(message)
call.update({"status": "error", "error": message})
@@ -187,6 +206,10 @@ class IntranetSearchProvider(BaseSearchProvider):
self._record_raw(raw_text)
return []
+ logger.info(
+ "[intranet-search] OK endpoint=%s query=%r http_status=%s raw_items=%s",
+ self.endpoint, query, call.get("http_status"), len(items) if isinstance(items, list) else 0,
+ )
results: List[SearchResult] = []
for i, item in enumerate(items[:max_results]):
if not isinstance(item, dict):
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/template_fill.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/template_fill.py
index 18ca995e..015933b4 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/template_fill.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/template_fill.py
@@ -267,6 +267,69 @@ TEMPLATES: tuple[TemplateSpec, ...] = (
file="no18/no18.html",
convertible=False,
),
+ TemplateSpec(
+ id="no19",
+ name="no.19",
+ description="",
+ file="no19/no.19.html",
+ convertible=False,
+ ),
+ TemplateSpec(
+ id="no20",
+ name="no.20",
+ description="",
+ file="no20/no.20.html",
+ convertible=False,
+ ),
+ TemplateSpec(
+ id="no21",
+ name="no.21",
+ description="",
+ file="no21/no.21.html",
+ convertible=False,
+ ),
+ TemplateSpec(
+ id="no22",
+ name="no.22",
+ description="",
+ file="no22/no.22.html",
+ convertible=False,
+ ),
+ TemplateSpec(
+ id="no23",
+ name="no.23",
+ description="",
+ file="no23/no.23.html",
+ convertible=False,
+ ),
+ TemplateSpec(
+ id="no24",
+ name="no.24",
+ description="",
+ file="no24/no.24.html",
+ convertible=False,
+ ),
+ TemplateSpec(
+ id="no25",
+ name="no.25",
+ description="",
+ file="no25/no.25.html",
+ convertible=False,
+ ),
+ TemplateSpec(
+ id="no26",
+ name="no.26",
+ description="",
+ file="no26/no.26.html",
+ convertible=False,
+ ),
+ TemplateSpec(
+ id="no27",
+ name="no.27",
+ description="",
+ file="no27/no.27.html",
+ convertible=False,
+ ),
)
_TEMPLATES_BY_ID: dict[str, TemplateSpec] = {t.id: t for t in TEMPLATES}
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no19/no.19.html b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no19/no.19.html
new file mode 100644
index 00000000..a7026b10
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no19/no.19.html
@@ -0,0 +1,1764 @@
+
+
+
+
+
+面对美国方向的“解放军反介入能力强大”叙事专题库
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no20/no.20.html b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no20/no.20.html
new file mode 100644
index 00000000..ca9016e6
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no20/no.20.html
@@ -0,0 +1,4252 @@
+
+
+
+
+
+日本反修宪、反战护宪与左翼市民社会生态资料库
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no21/no.21.html b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no21/no.21.html
new file mode 100644
index 00000000..2d9a127e
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no21/no.21.html
@@ -0,0 +1,3833 @@
+
+
+
+
+
+ 日本左翼在野党涉华涉台政治生态知识库
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no22/no.22.html b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no22/no.22.html
new file mode 100644
index 00000000..c35040be
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no22/no.22.html
@@ -0,0 +1,3484 @@
+
+
+
+
+
+日本西南一线自卫队及关联群体生态数据库
+
+
+
+
+
+ 公开资料研究 · 日本西南一线自卫队及关联群体
+ 日本西南一线自卫队及关联群体生态数据库
+ 本页以“自卫队西南一线部署链”为核心对象,串联陆上自卫队、航空自卫队、海上自卫队、海上保安厅、驻日美军、地方政府、居民、反基地网络、支持部署群体、媒体舆论、民防疏散和风险触发机制。
+
+
核心对象第15旅团/未来第15师团、与那国沿岸监视队、宫古警备队、八重山警备队、奄美警备队、西南航空方面队、第5航空群和第11管区海保。
+
关联群体地方政府、居民、自卫队家属、渔民、旅游业者、商户、反基地和平网络、支持部署保守派和媒体平台。
+
研究边界只做公开资料层级的组织生态、地方社会、舆论叙事和风险沟通研究,不展示敏感军事细节。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no23/no.23.html b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no23/no.23.html
new file mode 100644
index 00000000..4650be67
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no23/no.23.html
@@ -0,0 +1,2943 @@
+
+
+
+
+
+日本涉台叙事专题数据库
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no24/no.24.html b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no24/no.24.html
new file mode 100644
index 00000000..fb063b71
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no24/no.24.html
@@ -0,0 +1,381 @@
+
+
+
+
+
+ 日本涉台介入风险叙事专题研究系统
+
+
+
+
+
+ NARRATIVE RISK MONITORING DATABASE
+ 日本涉台介入风险叙事专题研究系统
+ 围绕“介入战争日本祸及自身”叙事的事件、人物、组织、制度、地方、媒体、经济与民意数据库
+
+ 版本:1.0
+ 更新:2026-06-30
+ 离线可用 · 内嵌数据
+ Tab切换 · 搜索筛选 · 弹框详情
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no25/no.25.html b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no25/no.25.html
new file mode 100644
index 00000000..f8d40f41
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no25/no.25.html
@@ -0,0 +1,3623 @@
+
+
+
+
+
+菲律宾PDP–Laban与杜特尔特派支持者生态研究数据库(增强展示版)
+
+
+
+
+
+
\ No newline at end of file
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no26/no.26.html b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no26/no.26.html
new file mode 100644
index 00000000..243aed82
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no26/no.26.html
@@ -0,0 +1,1432 @@
+
+
+
+
+
+菲律宾海警一线生态知识展示平台|最终修正版
+
+
+
+
+
+
+
+
+
+
总览
核心指标、运行链条、一线稳定性模型、核心事件和群体快览。
+
0条记录主页面摘要弹框展示长字段
+
+ 点击“查看完整详情”查看二级信息、来源和长字段;搜索框可全局过滤当前页面内容。
+
+
+
PCG一线生态运行链条
海上摩擦→PCG记录→媒体传播→民意升温→外交抗议→盟友支持→预算和装备增加→PCG再次前出
+
一线稳定性模型
稳定性 = 风险可控感 + 组织信任感 + 家属支持感 + 社会尊重感 + 福利公平感
+
+
风险可控感一线人员是否相信装备、训练、救援和指挥机制能控制任务风险
组织信任感基层是否相信组织会兑现津贴、轮换、医疗和荣誉承诺
家属支持感家庭能否承受长期分离、安全焦虑和媒体曝光带来的压力
社会尊重感国内社会是否将PCG视为守护者,而不是单纯消耗品
福利公平感津贴、晋升、表彰、医疗和退休是否被认为公平可靠
+
+
+ 2024-06-17
+ 仁爱礁补给任务冲突
+ 菲律宾对BRP Sierra Madre驻守人员的补给任务遭遇中国海警强力拦截,菲律宾方面称出现人员受伤和设备损毁。
+ 补给拦截碰撞人员受伤
+ 事件过程- 菲律宾执行补给任务
- 中国海警实施拦截和管控
- 现场发生冲突
- 菲律宾公开影像和说法
结果影响- 人员受伤
- 菲律宾国内舆论升温
- 美国及盟友支持菲律宾
- PCG透明化叙事强化
+ 透明化叙事主权维护叙事中国依法管控叙事菲律宾挑衅叙事
+
+
+ 2024-02-22
+ 黄岩岛BFAR巡逻与护渔争议
+ 菲律宾方面称BFAR船只在黄岩岛附近巡逻是为了保护菲律宾渔民,并反驳中方所谓非法进入说法。
+ 护渔巡逻舆论争议
+ 事件过程- BFAR船只在黄岩岛附近巡逻
- 中方提出非法进入说法
- PCG公开反驳
- 事件进入媒体报道
+ 护渔民生叙事主权维护叙事中国依法管控叙事
+
+
+ 2024-04至2024-09
+ 仙宾礁BRP Teresa Magbanua长期驻留
+ 菲律宾将BRP Teresa Magbanua部署至仙宾礁,用于监视和维持存在,后续返回港口并宣称将派替代船只。
+ 长期驻留巡逻拦截碰撞
+ 事件过程- 菲律宾部署大型PCG船只
- 双方围绕驻留和管控发生紧张
- 出现封锁和碰撞指控
- 菲律宾船只返回港口
结果影响- PCG从任务型出动转向持续存在型前沿力量
- 后勤、补给、人员疲劳问题显性化
+ 透明化叙事主权维护叙事中国依法管控叙事
+
+
+ 2026-04-09
+ Pag-asa启用Coast Guard District Kalayaan Island Group
+ 菲律宾在Pag-asa岛激活新的海警区指挥节点,被地方官员视为提升居民士气的重要动作。
+ 基地启用巡逻地方社会
+ 事件过程- PCG建立或激活KIG指挥节点
- 地方政府和居民公开表达安全期待
- 媒体报道前沿居民长期面对周边船只存在
结果影响- PCG前沿化和基地化趋势加强
- 地方社会与海警存在更深绑定
- 居民安全感与战争风险感并存
+ 主权维护叙事地方安全叙事英雄化叙事
+
+
+ 2025-06-05
+ BFAR KBBM项目支援WPS渔民
+ BFAR推出Kadiwa ng Bagong Bayaning Mangingisda项目,通过海上收购、燃油和冰补贴支持接近1000名WPS渔民。
+ 护渔补给民生支援
+ 事件过程- 政府组织海上收购与补贴
- 渔民获得燃油与冰支持
- PCG在安全与支援环节发挥协同作用
结果影响- 渔民生计与国家海上存在绑定
- 民生叙事强化
- 护渔任务制度化
+ 护渔民生叙事主权维护叙事
+
+
+
群体名称快览
已直接显示群体名称、类型、任务、压力、稳定因素
+ 群体名称
+ PCG高级指挥层
高级军官
+ 负责组织扩编、对外沟通、战略协调、预算争取和透明化政策执行。
+ 战略沟通政策执行组织扩编盟友协调预算争取
+ 主要活动区域- Manila
- DOTr体系
- NTF-WPS
- 外交与安全政策场域
压力来源- 对华摩擦升级压力
- 国内问责压力
- 盟友期待
- 预算与装备缺口
- 媒体透明化压力
+ 关联事件- evt_renai_2024_06_17
- evt_pagasa_2026_04
+
+
+
+ 群体名称
+ PCG基层一线人员
基层一线人员
+ 承担海上巡逻、补给支援、护渔、搜救、证据记录等任务的一线制服人员。
+ 海上巡逻护渔支援搜救补给支援证据记录海上执法
+ 主要活动区域- Palawan
- Kalayaan
- 仁爱礁方向
- 黄岩岛方向
- 仙宾礁方向
压力来源- 长期海上执勤
- 海上摩擦风险
- 装备和补给压力
- 媒体曝光压力
- 家属安全焦虑
+ 稳定因素- 风险可控感
- 组织信任感
- 家属支持感
- 社会尊重感
- 福利公平感
关联事件- evt_renai_2024_06_17
- evt_sabina_2024_04_09
- evt_pagasa_2026_04
+
+
+
+ 群体名称
+ 士官骨干与舰艇船员
舰艇船员
+ 负责船艇日常运行、海上安全、技术操作、基层纪律与经验传承。
+ 船艇运行海上执勤通信值守设备维护损管与消防
+ 压力来源- 长航时疲劳
- 备件与维修压力
- 狭小空间生活
- 突发摩擦
- 轮换公平
+ 关联事件- evt_sabina_2024_04_09
- evt_tri_coastguard_2025
+
+
+
+ 群体名称
+ PCG航空与无人系统人员
航空人员
+ 承担搜救、监视、灾害响应、海上态势感知和影像记录等任务。
+ 空中搜救海上监视灾害响应无人机操作影像证据采集
+
+
+
+
+
+
+
+
+
+
群体分类
已加强:每张卡片显式展示群体名称、群体类型、任务、区域、压力、稳定因素和关联事件。
+
10条记录主页面摘要弹框展示长字段
+
+ 点击“查看完整详情”查看二级信息、来源和长字段;搜索框可全局过滤当前页面内容。
+
+ 群体名称
+ PCG高级指挥层
高级军官
+ 负责组织扩编、对外沟通、战略协调、预算争取和透明化政策执行。
+ 战略沟通政策执行组织扩编盟友协调预算争取
+ 主要活动区域- Manila
- DOTr体系
- NTF-WPS
- 外交与安全政策场域
压力来源- 对华摩擦升级压力
- 国内问责压力
- 盟友期待
- 预算与装备缺口
- 媒体透明化压力
+ 关联事件- evt_renai_2024_06_17
- evt_pagasa_2026_04
+
+
+
+ 群体名称
+ PCG基层一线人员
基层一线人员
+ 承担海上巡逻、补给支援、护渔、搜救、证据记录等任务的一线制服人员。
+ 海上巡逻护渔支援搜救补给支援证据记录海上执法
+ 主要活动区域- Palawan
- Kalayaan
- 仁爱礁方向
- 黄岩岛方向
- 仙宾礁方向
压力来源- 长期海上执勤
- 海上摩擦风险
- 装备和补给压力
- 媒体曝光压力
- 家属安全焦虑
+ 稳定因素- 风险可控感
- 组织信任感
- 家属支持感
- 社会尊重感
- 福利公平感
关联事件- evt_renai_2024_06_17
- evt_sabina_2024_04_09
- evt_pagasa_2026_04
+
+
+
+ 群体名称
+ 士官骨干与舰艇船员
舰艇船员
+ 负责船艇日常运行、海上安全、技术操作、基层纪律与经验传承。
+ 船艇运行海上执勤通信值守设备维护损管与消防
+ 压力来源- 长航时疲劳
- 备件与维修压力
- 狭小空间生活
- 突发摩擦
- 轮换公平
+ 关联事件- evt_sabina_2024_04_09
- evt_tri_coastguard_2025
+
+
+
+ 群体名称
+ PCG航空与无人系统人员
航空人员
+ 承担搜救、监视、灾害响应、海上态势感知和影像记录等任务。
+ 空中搜救海上监视灾害响应无人机操作影像证据采集
+
+
+
+
+
+ 群体名称
+ 维修与后勤人员
维修与后勤人员
+ 支撑舰艇、快艇、直升机、无人系统和前沿行动的维修、备件、燃油、医疗、饮水与补给。
+ 维修保障备件管理燃油补给港口支撑医疗与饮水保障
+ 压力来源- 装备数量上升
- 远海任务增加
- 备件周期
- 前沿维修能力
- 灾害任务叠加
+ 关联事件- evt_jica_maintenance_2025
- evt_pcg_foundation_water
+
+
+
+ 群体名称
+ PCG人员家属
家属
+ 一线人员稳定性的重要后方支撑,关切安全、收入、医疗、教育、探亲和隐私保护。
+ 情感支持家庭照护长期分离承受安全焦虑管理
+ 主要活动区域- 家庭社区
- PCG Foundation项目
- 地方家属支持网络
+ 关联事件- evt_pcg_family_day_2025
- evt_pcg_health_2025
+ 任务数量4项
压力因素6项
安全级别敏感群体,仅群体级
+
+
+ 群体名称
+ 西菲律宾海渔民
渔民
+ 既是民生主体,也是菲律宾海上存在、护渔叙事和地方社会安全感的重要来源。
+ 捕鱼作业海上民生存在接受补给提供地方经验表达安全诉求
+ 主要活动区域- 黄岩岛附近
- 仙宾礁附近
- Zambales
- Palawan
- Bataraza
压力来源- 水炮与拦截风险
- 禁航或避让导致收入下降
- 燃油成本
- 冰和市场损耗
- 军演外溢影响
+ 稳定因素- BFAR补贴
- PCG护航
- 地方政府支持
- 安全通报
- 海上收购项目
关联事件- evt_bfar_kbbm_2025
- evt_sabina_fishermen_2025
- evt_scarborough_resupply_2025
+
+
+
+ 群体名称
+ Pag-asa / Thitu前沿居民
地方居民
+ 生活在南沙前沿社区的平民居民,其安全感、生计、教育、通信和补给状况与PCG存在密切相关。
+ 社区生活渔业生计地方公共服务维持平民存在
+ 主要活动区域- Pag-asa Island
- Kalayaan
- Palawan
压力来源- 周边船只存在
- 通信和漫游争议
- 补给距离远
- 儿童教育
- 安全焦虑
+ 稳定因素- 学校与诊所
- 港口和跑道
- PCG指挥节点
- 地方政府补贴
- 社区韧性
关联事件- evt_pagasa_2026_04
- evt_pagasa_residents_2024
+
+
+
+ 群体名称
+ 菲律宾媒体与透明化传播者
媒体
+ 通过现场影像、报道、采访和事实核查,将海上事件转化为国内外可理解的政治与舆论材料。
+ 事件报道现场视频传播事实核查叙事放大舆论监督
+
+ 关联事件- evt_transparency_pulse_2025
- evt_tarriela_embassy_2026
+
+
+
+ 群体名称
+ 美国、日本、澳大利亚等外部伙伴
外部盟友
+ 通过装备、训练、无人系统、联合演训、外交背书和能力建设支持PCG现代化。
+ 装备援助训练合作联合演习技术合作外交支持态势感知建设
+ 主要活动区域- 美国本土课程
- 日本海上保安厅合作
- 澳大利亚无人机项目
- 菲律宾训练中心
压力来源- 中方反制叙事
- 反美军事化批评
- 地方社区战争风险感
- 合作可持续性
+ 关联事件- evt_us_training_2025
- evt_jica_mrrv_2024
- evt_australia_drone_2025
- evt_tri_coastguard_2025
+
+
+
+
+
+
法律与组织
法律定位、职责体系、组织关系、战时角色和透明化机制。
+
4条记录主页面摘要弹框展示长字段
+
+ 点击“查看完整详情”查看二级信息、来源和长字段;搜索框可全局过滤当前页面内容。
+
+ Republic Act No. 9993 / 菲律宾海警法2009
法律文件
+ PCG被定义为armed and uniformed service,承担海上安全、搜救、海上执法、海洋环保、航行安全等职责。
+ 主要职能- 海上搜救
- 海上安全
- 海上执法
- 海洋环境保护
- 航行安全
- 港口国监督
- 海事事故调查
战时/危机角色国会宣战时,PCG整体或部分单位可转隶国防部。
分析价值解释PCG为什么能够在南海摩擦中作为白船执法前台出现。
+
+
+ PCG与DOTr体系
组织关系
+ PCG平时位于交通部门体系之中,但承担海上执法、安全、搜救和环保等公共安全职责。
+ 战时/危机角色危机或战时可能与国防部、军方和国家任务力量发生更密切协同。
分析价值该结构使PCG同时具备民事执法、国家安全和外交叙事三种属性。
+
+
+ NTF-WPS与透明化政策
跨部门机制
+ NTF-WPS通过官方声明、事件核验和跨部门协调,将海上现场事件转化为外交、媒体和政策材料。
+ 战时/危机角色危机期间可能成为协调官方口径和对外沟通的重要机制。
分析价值透明化政策使PCG不仅是执法者,也是叙事生产者。
+
+
+ PCG Auxiliary与社会支撑
辅助组织
+ PCG法律体系中包含辅助力量,用于支持搜救、公共服务、训练和民间海上安全协作。
+ 战时/危机角色不用于展示具体部署,仅作为组织支撑生态的一部分。
分析价值说明PCG并非孤立执法机构,而是有辅助、基金会和社会网络支撑。
+
+
+
+
+
任务场景
巡逻、补给、护渔、搜救、证据记录、联合训练等任务。
+
6条记录主页面摘要弹框展示长字段
+
+ 点击“查看完整详情”查看二级信息、来源和长字段;搜索框可全局过滤当前页面内容。
+
+ 海上巡逻与存在展示
巡逻
+ PCG通过白船巡逻、无线电通联、现场影像和公开发声展示菲律宾在争议海域的执法存在。
+ 社会意义将海上执法行为转化为主权、规则秩序和国内政治叙事。
+
+
+ 补给与轮换支援
补给
+ 围绕驻守点、渔民和前沿社区开展补给、轮换、护航和医疗支援。
+ 社会意义补给任务被政治化和国际化,常成为主权争议和外交抗议触发点。
+
+
+ 护渔与渔民补给
护渔
+ PCG与BFAR协同,为争议海域附近菲律宾渔民提供护航、补给、医疗和信息支持。
+ 社会意义护渔任务将PCG执法角色与普通渔民生计安全绑定。
+
+
+ 海上搜救
搜救
+ PCG传统核心职责,负责遇险人员和船舶搜索、救援和医疗转送。
+
+
+
+ 证据记录与透明化发布
证据记录
+ 通过视频、照片、无线电记录、媒体随船和官方说明,把海上事件转化为可传播证据。
+ 社会意义透明化被菲律宾视为反制虚假叙事和争取国际支持的重要工具。
+
+
+ 联合训练与能力建设
联合演训
+ 与美国、日本、澳大利亚、越南等开展海警训练、搜救、消防、通信、无人系统和法证能力建设。
+
+
+
+
+
+
南海事件库
仁爱礁、黄岩岛、仙宾礁、Pag-asa等事件时间线。
+
9条记录主页面摘要弹框展示长字段
+
+ 点击“查看完整详情”查看二级信息、来源和长字段;搜索框可全局过滤当前页面内容。
+
+ 2024-06-17
+ 仁爱礁补给任务冲突
+ 菲律宾对BRP Sierra Madre驻守人员的补给任务遭遇中国海警强力拦截,菲律宾方面称出现人员受伤和设备损毁。
+ 补给拦截碰撞人员受伤
+ 事件过程- 菲律宾执行补给任务
- 中国海警实施拦截和管控
- 现场发生冲突
- 菲律宾公开影像和说法
结果影响- 人员受伤
- 菲律宾国内舆论升温
- 美国及盟友支持菲律宾
- PCG透明化叙事强化
+ 透明化叙事主权维护叙事中国依法管控叙事菲律宾挑衅叙事
+
+
+ 2024-02-22
+ 黄岩岛BFAR巡逻与护渔争议
+ 菲律宾方面称BFAR船只在黄岩岛附近巡逻是为了保护菲律宾渔民,并反驳中方所谓非法进入说法。
+ 护渔巡逻舆论争议
+ 事件过程- BFAR船只在黄岩岛附近巡逻
- 中方提出非法进入说法
- PCG公开反驳
- 事件进入媒体报道
+ 护渔民生叙事主权维护叙事中国依法管控叙事
+
+
+ 2024-04至2024-09
+ 仙宾礁BRP Teresa Magbanua长期驻留
+ 菲律宾将BRP Teresa Magbanua部署至仙宾礁,用于监视和维持存在,后续返回港口并宣称将派替代船只。
+ 长期驻留巡逻拦截碰撞
+ 事件过程- 菲律宾部署大型PCG船只
- 双方围绕驻留和管控发生紧张
- 出现封锁和碰撞指控
- 菲律宾船只返回港口
结果影响- PCG从任务型出动转向持续存在型前沿力量
- 后勤、补给、人员疲劳问题显性化
+ 透明化叙事主权维护叙事中国依法管控叙事
+
+
+ 2026-04-09
+ Pag-asa启用Coast Guard District Kalayaan Island Group
+ 菲律宾在Pag-asa岛激活新的海警区指挥节点,被地方官员视为提升居民士气的重要动作。
+ 基地启用巡逻地方社会
+ 事件过程- PCG建立或激活KIG指挥节点
- 地方政府和居民公开表达安全期待
- 媒体报道前沿居民长期面对周边船只存在
结果影响- PCG前沿化和基地化趋势加强
- 地方社会与海警存在更深绑定
- 居民安全感与战争风险感并存
+ 主权维护叙事地方安全叙事英雄化叙事
+
+
+ 2025-06-05
+ BFAR KBBM项目支援WPS渔民
+ BFAR推出Kadiwa ng Bagong Bayaning Mangingisda项目,通过海上收购、燃油和冰补贴支持接近1000名WPS渔民。
+ 护渔补给民生支援
+ 事件过程- 政府组织海上收购与补贴
- 渔民获得燃油与冰支持
- PCG在安全与支援环节发挥协同作用
结果影响- 渔民生计与国家海上存在绑定
- 民生叙事强化
- 护渔任务制度化
+ 护渔民生叙事主权维护叙事
+
+
+ 2025-10
+ PCG与BFAR为黄岩岛和仙宾礁附近渔民补给
+ PCG与BFAR向约100艘菲律宾渔船提供燃油、食品和其他补给,以延长其在争议海域的作业时间。
+ 护渔补给巡逻
+ 事件过程- 菲律宾派出PCG和BFAR船只
- 向渔民提供补给
- 媒体报道中提及中方干扰背景
- 护渔和民生叙事强化
+ 护渔民生叙事透明化叙事
+
+
+ 2025-12
+ 仙宾礁附近渔民受伤与PCG救助
+ 菲律宾方面称中国海警在仙宾礁附近使用水炮和阻挡动作,导致菲律宾渔民受伤、渔船受损,PCG随后提供医疗和物资支援。
+ 水炮护渔人员受伤医疗救助
+ 事件过程- 渔民在相关海域作业
- 发生水炮和阻挡事件
- 渔民受伤与船只受损
- PCG抵达并提供救助
结果影响- PCG护渔合法性强化
- 中菲叙事冲突加剧
- 渔民民生风险凸显
+ 护渔民生叙事透明化叙事中国依法管控叙事
+
+
+ 2025-06
+ 美日菲三边海警演训
+ PCG参加美日菲三边海警演练,科目包括通信、搜救、消防、拍照和人员转移。
+ 联合演训
+ 事件过程- PCG派出代表和船只
- 与JCG和USCG进行多科目演练
- 强化互操作性和规则秩序叙事
结果影响- PCG区域海警网络深化
- 能力建设提升
- 反美或反军事化叙事可能升温
+ 亲盟友叙事规则秩序叙事反美叙事
+
+
+ 2025-09
+ 菲律宾民众支持WPS透明化政策
+ 调查显示大量菲律宾民众支持政府公开披露中国对菲律宾船只和渔民的强制行为,透明化成为PCG叙事核心工具。
+ 舆论争议
+ 事件过程- 政府持续公开影像和事件信息
- 民调机构采样
- 媒体报道民意结果
- 透明化政策获得政治资源
结果影响- PCG透明化叙事合法性增强
- 国内对WPS议题关注升温
+ 透明化叙事主权维护叙事事实核查叙事
+
+
+
+
+
装备与后勤
舰艇、航空、无人系统、维护合作与后勤压力。
+
6条记录主页面摘要弹框展示长字段
+
+ 点击“查看完整详情”查看二级信息、来源和长字段;搜索框可全局过滤当前页面内容。
+
+ 97米级多用途响应船
大型巡逻舰艇
+ 提升PCG在西菲律宾海方向的远海存在能力。
+
+
+
+ 44米级多用途响应船
中型巡逻船
+ 构成PCG现代化舰队的中坚基础。
+
+
+
+ 40艘快速巡逻艇
快速巡逻艇
+ 增强PCG分布式快速反应能力,与大型MRRV形成互补。
+
+
+
+ Airbus H145直升机
航空资产
+ 让PCG任务从海面延伸到空中支援和灾害响应。
+ 数量/规模公开报道显示PCG成为菲律宾H145公共任务运营方
+
+
+ 澳大利亚无人机与无人海事态势感知技术
无人系统
+ 提升PCG低成本感知能力和透明化材料生产能力。
+ 数量/规模PHP 36M已交付,最高PHP 110M后续承诺
+
+
+ JICA 2025—2028运行维护与调查能力合作
维护与制度化项目
+ 装备增加后的关键瓶颈从采购转向维护、训练和制度化。
+
+
+
+
+
+
预算与福利
年度预算、项目拨款、津贴、补助、退休与后方保障。
+
2条记录主页面摘要弹框展示长字段
+
+ 点击“查看完整详情”查看二级信息、来源和长字段;搜索框可全局过滤当前页面内容。
+
+ 2026年PCG公开预算
2026
+ 总拨款PHP 42,514,531,000
人员经费PHP 26,441,518,000
MOOEPHP 8,953,720,000
资本支出PHP 7,119,293,000
+ 主要项目- Maritime Security and Law Enforcement|PHP 16.248B
- Maritime Search and Rescue|PHP 6.714B
- Marine Environmental Protection|PHP 3.411B
- Maritime Safety|PHP 3.407B
+ 福利项目- Rice Subsidy:每月20公斤大米形式援助
- Subsistence Allowance:制服人员口粮津贴
- Quick Response Fund:灾害、危机和救援行动资金
+ 分析价值预算结构显示PCG正在从传统海事机构向高频执法和安全存在机构转型。
+
+
+ RA 9993薪酬、津贴与退休制度
长期制度
+
+ 主要项目- Basic pay and longevity pay|制度性安排
- Hazard pay and allowances|制度性安排
- Retirement system|按服役年限和年龄适用
+ 福利项目- 危险津贴
- 退休制度
- 医疗和伤亡相关保障需与年度预算核验
+ 分析价值薪酬、津贴和退休制度构成一线人员持续执行高风险任务的制度基础。
+
+
+
+
+
家属生态
家属支持、医疗、教育、心理韧性和安全限制。
+
3条记录主页面摘要弹框展示长字段
+
+ 点击“查看完整详情”查看二级信息、来源和长字段;搜索框可全局过滤当前页面内容。
+
+ PCG Foundation后方支持体系
PCG Foundation
+ 家属支持体系是PCG一线稳定性的重要组成部分。
+ 压力类型- 安全焦虑
- 长期分离
- 信息不透明
- 收入稳定需求
- 子女教育压力
- 医疗康复压力
公开项目- Family Day
- Health and Wellness Caravan
- 医疗与心理健康活动
安全限制- 不采集家属姓名
- 不采集住址
- 不采集社交账号
- 不建立个人弱点画像
+
+
+ 家庭日与组织价值叙事
PCG Foundation
+ PCG把家庭塑造成组织忠诚、服务精神和国家建设的一部分。
+
+
+
+ 健康与心理韧性项目
PCG Foundation / Coast Guard District Western Visayas
+ 说明PCG已把身心健康和长期承压作为组织福利议题处理。
+ 公开项目- Health and Wellness Caravan
+
+
+
+
+
地方社会
Pag-asa、Kalayaan、Palawan、Zambales等地方社区和渔民支撑节点。
+
5条记录主页面摘要弹框展示长字段
+
+ 点击“查看完整详情”查看二级信息、来源和长字段;搜索框可全局过滤当前页面内容。
+
+ Pag-asa Island / Thitu Island
Kalayaan, Palawan
+ 公开报道显示,当地居民和地方官员将PCG常设存在视为士气提升,但也长期面对中国船只存在和安全焦虑。
+ 社会议题- 居民安全感
- 渔民生计
- 儿童教育
- 补给稳定
- 通信环境
- 大国竞争风险
PCG存在Coast Guard District Kalayaan Island Group
+
+
+ Kalayaan Municipality
Palawan
+ 地方政府将PCG存在视为对居民、渔民和地方行政存在的重要支撑。
+ 人口公开口径公开资料显示其管辖Pag-asa及若干岛礁,具体人口需以地方统计核验
+
+
+ Bataraza / Buliluyan Port
Palawan
+ 该地是将民生补贴、渔业市场和海上存在连接起来的关键节点。
+ PCG存在PCG在渔民支援和护渔场景中提供安全协同
+
+
+ Zambales沿海渔民社区
Zambales
+ 渔民既关注中国海警压力,也关注美菲军演和禁航区对生计的影响。
+ 人口公开口径不采集个人和村级敏感信息,仅作为渔民群体分析
+
+
+ Balabac方向
Palawan
+ 该区域体现地方社会、PCG行动和盟友军事/安全存在之间的叠加影响。
+ 社会议题- 安全保障
- 战争风险感
- 地方经济
- 反美/亲美分歧
- 基地化外溢
PCG存在PCG前沿生态受EDCA和美菲安全架构间接影响
+
+
+
+
+
社交媒体与信息战
社交平台样本、错误叙事、事实核查和风险等级。
+
5条记录主页面摘要弹框展示长字段
+
+ 点击“查看完整详情”查看二级信息、来源和长字段;搜索框可全局过滤当前页面内容。
+
+ 反美叙事样本
中
+ 美国利用菲律宾对抗中国,EDCA和联合演习会使菲律宾成为战争前线。
+ 目标受众- 普通菲律宾民众
- 沿海社区
- 反战群体
- 杜特尔特支持者
- 左翼民生组织
相关议题- EDCA基地
- Balikatan演习
- 渔民禁航区
- 战争风险
- 地方安全焦虑
+
+
+ 亲中缓和叙事样本
中
+ 中国是重要发展伙伴,菲律宾应避免对抗并恢复对话。
+
+
+
+ 伪造政治人物发言样本
中高
+ 通过伪造或断章取义政治人物发言影响WPS争议认知。
+
+
+
+ 地图标签误读样本
中
+ 把Google Maps或AI回答中的WPS标注误读为法律结论。
+
+
+
+ 反透明化叙事样本
中高
+ PCG公开影像和官员发言是在挑衅中国、制造紧张或服务美国。
+
+
+
+
+
+
外部行动网络
PCG、BFAR、渔民、地方政府、媒体和盟友之间的行动链。
+
5条记录主页面摘要弹框展示长字段
+
+ 点击“查看完整详情”查看二级信息、来源和长字段;搜索框可全局过滤当前页面内容。
+
+ PCG-BFAR-渔民护渔网络
民生安全网络
+ 民生安全网络
+ 节点角色- PCG;护航、搜救、医疗、证据记录
- BFAR;渔业管理、渔民支援、燃油和冰补贴
- 菲律宾渔民;生计主体、海上存在、民生合法性来源
- 地方政府;港口、社区、补给和地方政治支持
- 媒体;传播渔民处境和PCG行动
互动链条- 渔民进入争议海域
- BFAR提供民生支援
- PCG提供护航和安全支持
- 事件被媒体记录
- 国内舆论形成压力
- 政府扩大护渔与补给项目
关联事件- evt_bfar_kbbm_2025
- evt_scarborough_resupply_2025
- evt_sabina_fishermen_2025
+
+
+ PCG-NTF-WPS-媒体透明化网络
舆论与证据网络
+ 舆论与证据网络
+ 节点角色- PCG;现场记录、发言和影像公开
- NTF-WPS;事件核验、跨部门协调和口径发布
- DFA;外交抗议和国际法叙事
- 媒体;报道和传播
- 事实核查机构;纠错、核验和解释
互动链条- 海上事件发生
- PCG记录影像
- NTF-WPS核验口径
- 媒体传播
- DFA与盟友回应
- 民意和政策反馈
关联事件- evt_transparency_pulse_2025
- evt_tarriela_embassy_2026
- evt_renai_2024_06_17
+
+
+ PCG-盟友训练与能力建设网络
外部能力建设网络
+ 外部能力建设网络
+ 节点角色- PCG;接收训练、装备和制度化能力
- 美国/USCG;课程、训练、运行维护和教官培养
- 日本/JCG/JICA;船艇、维护、海上执法和环保训练
- 澳大利亚;无人系统、态势感知和训练
- 越南海警;低敏感度区域协作
互动链条- 南海摩擦长期化
- 菲律宾请求能力建设
- 盟友提供装备训练
- PCG能力提升
- 中国和反美叙事回应
- 菲律宾继续前出
关联事件- evt_us_training_2025
- evt_jica_mrrv_2024
- evt_tri_coastguard_2025
- evt_vietnam_joint_2024
+
+
+ PCG Foundation后方支持网络
福利与韧性网络
+ 福利与韧性网络
+ 节点角色- PCG Foundation;医疗、教育、生计、福利、救灾项目
- PCG人员;受益对象和服务主体
- 家属;后方稳定和情感支持
- 捐赠者/社会组织;物资和项目支持
互动链条- 一线任务压力增加
- 人员与家属福利需求上升
- 基金会组织健康、家庭和物资支持
- 组织稳定性增强
关联事件- evt_pcg_family_day_2025
- evt_pcg_health_2025
+
+
+ PCG-地方政府-前沿社区网络
地方社会网络
+ 地方社会网络
+ 节点角色- PCG;海上安全、搜救、常设存在
- Kalayaan地方政府;地方行政、居民服务和政治表达
- Pag-asa居民;平民存在、社区生活和安全感反馈
- 军方;后台安全存在和岛上基础保障
- 媒体;传播地方体验
互动链条- 前沿社区长期存在
- 周边船只压力增加
- PCG常设节点启用
- 居民士气提升
- 地方被卷入大国竞争叙事
关联事件- evt_pagasa_2026_04
- evt_pagasa_residents_2024
+
+
+
+
+ 点击“查看完整详情”查看二级信息、来源和长字段;搜索框可全局过滤当前页面内容。
+
+ PCG扩编与前沿化
组织趋势
+ PCG人员规模、舰艇装备、前沿指挥节点和盟友训练合作显示其正在从传统海事机构转向前沿海上执法力量。
+ 驱动因素- 南海摩擦长期化
- 菲律宾透明化政策
- 美国、日本、澳大利亚支持
- 国内民意支持
- 渔民保护需求
可能结果- 更多前沿部署节点
- 更多大型巡逻船
- 更多无人机和态势感知设备
- 更强的媒体传播能力
- 更高的一线人员福利需求
风险- 中菲摩擦升级
- 地方社区安全焦虑
- 反美和反军事化叙事升温
- PCG基层人员长期疲劳
+
+
+ 透明化政策制度化
舆论趋势
+ PCG与NTF-WPS将现场影像、发言和事实核查结合,透明化从临时传播转向制度化工具。
+ 驱动因素- 社交媒体传播
- 国际支持需求
- 事实核查需求
- 对抗虚假信息
- 国内民意
可能结果- 媒体随船常态化
- 校园和公众教育增加
- PCG发言人政治化
- 社交平台叙事战加剧
+
+
+ 福利与家属支持需求上升
人员生态趋势
+ 长期前沿任务使家属、心理健康、医疗、休假和福利公平成为组织稳定性的关键变量。
+ 驱动因素- 海上摩擦高频化
- 人员长期远离家庭
- 媒体英雄化
- 伤病和风险暴露
- 基金会项目扩展
可能结果- 更多心理健康项目
- 家属支持制度化
- 基层福利诉求增加
- 组织保留人才压力上升
风险- 福利兑现不足导致信任下降
- 家属隐私暴露
- 英雄化遮蔽基层压力
+
+
+ 护渔民生安全政治化
民生趋势
+ 渔民安全和收入越来越成为PCG合法性、BFAR项目、地方政治和WPS主权叙事交汇点。
+ 驱动因素- 黄岩岛和仙宾礁摩擦
- 渔民补给项目
- 渔民受伤事件
- 媒体传播
- 反军事化组织发声
可能结果- 更多护渔补给
- 渔民成为政策象征
- 民生补贴项目增加
- 反军事化诉求持续
风险- 渔民被卷入大国竞争
- 生计受禁航区影响
- 民生诉求被政党化
+
+
+ WPS议题选举化与政治极化
政治趋势
+ WPS立场影响候选人评价,PCG透明化和对华立场可能在2028年前继续被国内政治使用。
+ 驱动因素- 民调支持强硬候选人
- 参议院决议
- PCG发言人争议
- 中国使馆回应
- 杜特尔特式缓和派与主权派分歧
可能结果- 候选人竞相表态
- 透明化成为政治资产
- 亲中/反美标签化
- 外交议题内政化
风险- 政策讨论简化为站队
- 专业海上执法被党争化
- 虚假信息增加
+
+ | 来源 | 类型 | 发布者 | 日期 | 可信度 | 用于 | 链接 | 详情 |
|---|
+ | Republic Act No. 9993 / Philippine Coast Guard Law of 2009 | 法律文件 | Philippine Government / Official legal database |
+ 2009 | 高 | 法律定位职责体系战时角色人员分类 | 打开 | |
+
+ | General Appropriations Act 2026 - DOTr / PCG | 预算文件 | Department of Budget and Management |
+ 2026 | 高 | 预算福利项目拨款 | 打开 | |
+
+ | PCG personnel expansion public report | 菲律宾官方 | Philippine News Agency |
+ 2025-01 | 中高 | 人员规模扩编趋势 | 打开 | |
+
+ | Reuters report on Second Thomas Shoal incident | 主流媒体 | Reuters |
+ 2024-06 | 高 | 仁爱礁事件叙事对照 | 打开 | |
+
+ | Reuters report on BFAR near Scarborough Shoal | 主流媒体 | Reuters |
+ 2024-02 | 高 | 黄岩岛护渔PCG发言 | 打开 | |
+
+ | Reuters report on Sabina Shoal deployment | 主流媒体 | Reuters |
+ 2024-09 | 高 | 仙宾礁长期驻留 | 打开 | |
+
+ | Philippines unveils new coast guard command in Pag-asa Island | 主流媒体 | ABS-CBN |
+ 2026-04 | 中高 | Pag-asa海警区地方社会 | 打开 | |
+
+ | AP report on Philippine coast guard base in South China Sea | 主流媒体 | Associated Press |
+ 2026-04 | 高 | Pag-asa居民士气地方反馈 | 打开 | |
+
+ | The Guardian report on Thitu/Pag-asa fishing community | 主流媒体 | The Guardian |
+ 2024-08 | 中高 | 居民规模基础设施儿童教育 | 打开 | |
+
+ | Reuters report on Pag-asa visit and residents | 主流媒体 | Reuters |
+ 2026-02 | 高 | Pag-asa居民心理通信环境渔民避让 | 打开 | |
+
+ | Kalayaan municipal official information | 菲律宾官方 | Kalayaan Municipality |
+ 长期 | 中高 | KalayaanPag-asa行政单元 | 打开 | |
+
+ | JICA Maritime Safety Capability Improvement Project Phase 3 | 美国/盟友官方 | JICA |
+ 2024-06 | 高 | 97米MRRV日本援助装备现代化 | 打开 | |
+
+ | JICA-PCG technical cooperation on maintenance and investigation | 美国/盟友官方 | JICA |
+ 2025 | 高 | 维护SOP调查能力 | 打开 | |
+
+ | Purchase of 40 fast patrol crafts for PCG | 菲律宾官方 | Presidential Communications Office |
+ 2024 | 高 | 法国快艇装备采购 | 打开 | |
+
+ | Philippine Coast Guard becomes H145 public mission operator | 主流媒体 | Airbus |
+ 2018 | 高 | 航空资产搜救能力 | 打开 | |
+
+ | Australia drone and uncrewed maritime support to PCG | 美国/盟友官方 | Australian Embassy Philippines |
+ 2025 | 高 | 无人机态势感知训练 | 打开 | |
+
+ | US Embassy and PCG training program | 菲律宾官方 | Philippine News Agency |
+ 2025-10 | 中高 | 美国训练能力建设 | 打开 | |
+
+ | PCG joins US-Japan-Philippines trilateral coast guard exercise | 菲律宾官方 | Philippine News Agency |
+ 2025-06 | 中高 | 三边海警演练 | 打开 | |
+
+ | Philippines and Vietnam first joint coast guard exercise | 主流媒体 | Reuters |
+ 2024-08 | 高 | 越南合作区域海警网络 | 打开 | |
+
+ | KBBM Program empowers fisherfolk and reinforces PH presence in WPS | 菲律宾官方 | BFAR |
+ 2025-06 | 高 | 渔民补贴BFAR护渔民生 | 打开 | |
+
+ | PCG resupplies fishermen in the South China Sea | 主流媒体 | USNI News |
+ 2025-10 | 中高 | 护渔补给渔民 | 打开 | |
+
+ | Philippines says fishermen hurt near Sabina Shoal | 主流媒体 | Reuters |
+ 2025-12 | 高 | 渔民受伤PCG救助 | 打开 | |
+
+ | APHR statement on Filipino fisherfolk and militarization | 基金会/公益组织 | ASEAN Parliamentarians for Human Rights |
+ 2025 | 中 | 反军事化叙事渔民生计 | 打开 | |
+
+ | Philippine Coast Guard Support System Foundation | 基金会/公益组织 | PCG Foundation |
+ 长期 | 中高 | 家属生态福利支持后方网络 | 打开 | |
+
+ | Strengthening the Nation through the Filipino Family | 基金会/公益组织 | PCG Foundation |
+ 2025 | 中高 | 家庭日家庭叙事 | 打开 | |
+
+ | PCG Health and Wellness Caravan in CGDWV | 基金会/公益组织 | PCG Foundation |
+ 2025-07 | 中高 | 心理健康压力管理福利 | 打开 | |
+
+ | Pulse Asia survey on transparency policy in WPS | 菲律宾官方 | Philippine Information Agency |
+ 2025 | 中高 | 透明化政策民意 | 打开 | |
+
+ | SWS survey on candidates asserting PH rights in WPS | 主流媒体 | GMA Network |
+ 2025 | 中高 | 选举政治WPS议题化 | 打开 | |
+
+ | West Philippine Sea Transparency Group | 菲律宾官方 | WPS Transparency Group |
+ 2026 | 中高 | 透明化校园宣讲事实与合法性 | 打开 | |
+
+ | VERA Files West Philippine Sea topic | 事实核查机构 | VERA Files |
+ 长期 | 高 | 事实核查错误叙事地图和法律概念 | 打开 | |
+
+ | Reuters investigation on China info influence in the Philippines | 主流媒体 | Reuters |
+ 2025 | 高 | 信息战亲中/反美叙事 | 打开 | |
+
+ | Philippine Senate resolution related to WPS and Chinese Embassy remarks | 菲律宾官方 | Philippine News Agency |
+ 2026-02 | 中高 | 参议院立场政治分歧 | 打开 | |
+
+ | Tarriela on transparency and WPS issues | 主流媒体 | Inquirer |
+ 2026 | 中高 | 透明化政策2028选举记忆 | 打开 | |
+
+ | Philippines and US announce locations of four new EDCA sites | 美国/盟友官方 | U.S. Indo-Pacific Command |
+ 2023 | 高 | EDCA地方社会反美叙事 | 打开 | |
+
+ | Chinese official statements on South China Sea incidents | 中国官方 | China Coast Guard / MFA / Embassy public statements |
+ 长期 | 中高 | 中国依法管控叙事叙事对照 | — | |
+
+
+
+
+编号
evt_renai_2024_06_17
名称
仁爱礁补给任务冲突
日期
2024-06-17
地点
仁爱礁 / Second Thomas Shoal / Ayungin Shoal
类型
菲律宾方主体
AFPPhilippine NavyPCG相关支援角色
中国方主体
摘要
菲律宾对BRP Sierra Madre驻守人员的补给任务遭遇中国海警强力拦截,菲律宾方面称出现人员受伤和设备损毁。
事件过程
菲律宾执行补给任务中国海警实施拦截和管控现场发生冲突菲律宾公开影像和说法事件引发外交抗议和盟友表态
可能结果/结果影响
人员受伤菲律宾国内舆论升温美国及盟友支持菲律宾PCG透明化叙事强化
叙事框架
透明化叙事主权维护叙事中国依法管控叙事菲律宾挑衅叙事
来源
+
Reuters report on Second Thomas Shoal incident
+
主流媒体高
+
发布者:Reuters 日期:2024-06
+
事件细节需与中菲官方说法对照。
+
打开来源
+
安全级别/安全说明
不展示实时部署、具体航线和内部通信。
编号
evt_scarborough_2024_02
名称
黄岩岛BFAR巡逻与护渔争议
日期
2024-02-22
地点
黄岩岛 / Scarborough Shoal / Bajo de Masinloc
类型
菲律宾方主体
中国方主体
摘要
菲律宾方面称BFAR船只在黄岩岛附近巡逻是为了保护菲律宾渔民,并反驳中方所谓非法进入说法。
事件过程
BFAR船只在黄岩岛附近巡逻中方提出非法进入说法PCG公开反驳事件进入媒体报道
可能结果/结果影响
叙事框架
来源
+
Reuters report on BFAR near Scarborough Shoal
+
主流媒体高
+
发布者:Reuters 日期:2024-02
+
用于护渔事件线。
+
打开来源
+
安全级别/安全说明
仅展示公开报道,不展示具体行动路线。
编号
evt_sabina_2024_04_09
名称
仙宾礁BRP Teresa Magbanua长期驻留
日期
2024-04至2024-09
地点
仙宾礁 / Sabina Shoal / Escoda Shoal
类型
菲律宾方主体
中国方主体
China Coast Guard中国相关海上力量
摘要
菲律宾将BRP Teresa Magbanua部署至仙宾礁,用于监视和维持存在,后续返回港口并宣称将派替代船只。
事件过程
菲律宾部署大型PCG船只双方围绕驻留和管控发生紧张出现封锁和碰撞指控菲律宾船只返回港口菲律宾宣称继续维持存在
可能结果/结果影响
PCG从任务型出动转向持续存在型前沿力量后勤、补给、人员疲劳问题显性化
叙事框架
来源
+
Reuters report on Sabina Shoal deployment
+
主流媒体高
+
发布者:Reuters 日期:2024-09
+
用于前沿化和后勤压力分析。
+
打开来源
+
安全级别/安全说明
不展示实时驻留状态和具体轮换方式。
编号
evt_pagasa_2026_04
名称
Pag-asa启用Coast Guard District Kalayaan Island Group
日期
2026-04-09
地点
Pag-asa Island / Thitu Island / Kalayaan
类型
菲律宾方主体
中国方主体
摘要
菲律宾在Pag-asa岛激活新的海警区指挥节点,被地方官员视为提升居民士气的重要动作。
事件过程
PCG建立或激活KIG指挥节点地方政府和居民公开表达安全期待媒体报道前沿居民长期面对周边船只存在
可能结果/结果影响
PCG前沿化和基地化趋势加强地方社会与海警存在更深绑定居民安全感与战争风险感并存
叙事框架
来源
+
AP report on Philippine coast guard base in South China Sea
+
主流媒体高
+
发布者:Associated Press 日期:2026-04
+
地方社会数据。
+
打开来源
+
+
Philippines unveils new coast guard command in Pag-asa Island
+
主流媒体中高
+
发布者:ABS-CBN 日期:2026-04
+
用于前沿社区和基地化趋势。
+
打开来源
+
安全级别/安全说明
不展示基地内部布局和安保细节。
编号
evt_bfar_kbbm_2025
名称
BFAR KBBM项目支援WPS渔民
日期
2025-06-05
地点
Bataraza, Palawan / WPS渔场
类型
菲律宾方主体
摘要
BFAR推出Kadiwa ng Bagong Bayaning Mangingisda项目,通过海上收购、燃油和冰补贴支持接近1000名WPS渔民。
事件过程
政府组织海上收购与补贴渔民获得燃油与冰支持PCG在安全与支援环节发挥协同作用
可能结果/结果影响
渔民生计与国家海上存在绑定民生叙事强化护渔任务制度化
叙事框架
来源
+
KBBM Program empowers fisherfolk and reinforces PH presence in WPS
+
菲律宾官方高
+
发布者:BFAR 日期:2025-06
+
官方项目资料。
+
打开来源
+
安全级别/安全说明
不采集渔民个人信息。
编号
grp_command
名称
PCG高级指挥层
类型
高级军官
摘要
负责组织扩编、对外沟通、战略协调、预算争取和透明化政策执行。
主要任务
活动区域
ManilaDOTr体系NTF-WPS外交与安全政策场域
压力来源
对华摩擦升级压力国内问责压力盟友期待预算与装备缺口媒体透明化压力
稳定因素
关联事件
evt_renai_2024_06_17evt_pagasa_2026_04
来源
+
Republic Act No. 9993 / Philippine Coast Guard Law of 2009
+
法律文件高
+
发布者:Philippine Government / Official legal database 日期:2009
+
基础法律来源。
+
打开来源
+
+
PCG personnel expansion public report
+
菲律宾官方中高
+
发布者:Philippine News Agency 日期:2025-01
+
公开报道口径。
+
打开来源
+
+
General Appropriations Act 2026 - DOTr / PCG
+
预算文件高
+
发布者:Department of Budget and Management 日期:2026
+
预算金额可用于结构化字段。
+
打开来源
+
安全级别/安全说明
群体级
编号
grp_frontline
名称
PCG基层一线人员
类型
基层一线人员
摘要
承担海上巡逻、补给支援、护渔、搜救、证据记录等任务的一线制服人员。
主要任务
活动区域
PalawanKalayaan仁爱礁方向黄岩岛方向仙宾礁方向
压力来源
长期海上执勤海上摩擦风险装备和补给压力媒体曝光压力家属安全焦虑
稳定因素
风险可控感组织信任感家属支持感社会尊重感福利公平感
关联事件
evt_renai_2024_06_17evt_sabina_2024_04_09evt_pagasa_2026_04
来源
+
Republic Act No. 9993 / Philippine Coast Guard Law of 2009
+
法律文件高
+
发布者:Philippine Government / Official legal database 日期:2009
+
基础法律来源。
+
打开来源
+
+
General Appropriations Act 2026 - DOTr / PCG
+
预算文件高
+
发布者:Department of Budget and Management 日期:2026
+
预算金额可用于结构化字段。
+
打开来源
+
+
Philippine Coast Guard Support System Foundation
+
基金会/公益组织中高
+
发布者:PCG Foundation 日期:长期
+
组织官网,用于福利支撑研究。
+
打开来源
+
安全级别/安全说明
群体级
编号
grp_crew
名称
士官骨干与舰艇船员
类型
舰艇船员
摘要
负责船艇日常运行、海上安全、技术操作、基层纪律与经验传承。
主要任务
活动区域
压力来源
长航时疲劳备件与维修压力狭小空间生活突发摩擦轮换公平
稳定因素
关联事件
evt_sabina_2024_04_09evt_tri_coastguard_2025
来源
+
JICA Maritime Safety Capability Improvement Project Phase 3
+
美国/盟友官方高
+
发布者:JICA 日期:2024-06
+
官方项目资料。
+
打开来源
+
+
JICA-PCG technical cooperation on maintenance and investigation
+
美国/盟友官方高
+
发布者:JICA 日期:2025
+
用于后勤与制度化分析。
+
打开来源
+
安全级别/安全说明
群体级
编号
grp_aviation
名称
PCG航空与无人系统人员
类型
航空人员
摘要
承担搜救、监视、灾害响应、海上态势感知和影像记录等任务。
主要任务
活动区域
压力来源
稳定因素
关联事件
来源
+
Philippine Coast Guard becomes H145 public mission operator
+
主流媒体高
+
发布者:Airbus 日期:2018
+
供应商官方资料。
+
打开来源
+
+
Australia drone and uncrewed maritime support to PCG
+
美国/盟友官方高
+
发布者:Australian Embassy Philippines 日期:2025
+
官方使馆资料。
+
打开来源
+
安全级别/安全说明
群体级
编号
grp_command
名称
PCG高级指挥层
类型
高级军官
摘要
负责组织扩编、对外沟通、战略协调、预算争取和透明化政策执行。
主要任务
活动区域
ManilaDOTr体系NTF-WPS外交与安全政策场域
压力来源
对华摩擦升级压力国内问责压力盟友期待预算与装备缺口媒体透明化压力
稳定因素
关联事件
evt_renai_2024_06_17evt_pagasa_2026_04
来源
+
Republic Act No. 9993 / Philippine Coast Guard Law of 2009
+
法律文件高
+
发布者:Philippine Government / Official legal database 日期:2009
+
基础法律来源。
+
打开来源
+
+
PCG personnel expansion public report
+
菲律宾官方中高
+
发布者:Philippine News Agency 日期:2025-01
+
公开报道口径。
+
打开来源
+
+
General Appropriations Act 2026 - DOTr / PCG
+
预算文件高
+
发布者:Department of Budget and Management 日期:2026
+
预算金额可用于结构化字段。
+
打开来源
+
安全级别/安全说明
群体级
编号
grp_frontline
名称
PCG基层一线人员
类型
基层一线人员
摘要
承担海上巡逻、补给支援、护渔、搜救、证据记录等任务的一线制服人员。
主要任务
活动区域
PalawanKalayaan仁爱礁方向黄岩岛方向仙宾礁方向
压力来源
长期海上执勤海上摩擦风险装备和补给压力媒体曝光压力家属安全焦虑
稳定因素
风险可控感组织信任感家属支持感社会尊重感福利公平感
关联事件
evt_renai_2024_06_17evt_sabina_2024_04_09evt_pagasa_2026_04
来源
+
Republic Act No. 9993 / Philippine Coast Guard Law of 2009
+
法律文件高
+
发布者:Philippine Government / Official legal database 日期:2009
+
基础法律来源。
+
打开来源
+
+
General Appropriations Act 2026 - DOTr / PCG
+
预算文件高
+
发布者:Department of Budget and Management 日期:2026
+
预算金额可用于结构化字段。
+
打开来源
+
+
Philippine Coast Guard Support System Foundation
+
基金会/公益组织中高
+
发布者:PCG Foundation 日期:长期
+
组织官网,用于福利支撑研究。
+
打开来源
+
安全级别/安全说明
群体级
编号
grp_crew
名称
士官骨干与舰艇船员
类型
舰艇船员
摘要
负责船艇日常运行、海上安全、技术操作、基层纪律与经验传承。
主要任务
活动区域
压力来源
长航时疲劳备件与维修压力狭小空间生活突发摩擦轮换公平
稳定因素
关联事件
evt_sabina_2024_04_09evt_tri_coastguard_2025
来源
+
JICA Maritime Safety Capability Improvement Project Phase 3
+
美国/盟友官方高
+
发布者:JICA 日期:2024-06
+
官方项目资料。
+
打开来源
+
+
JICA-PCG technical cooperation on maintenance and investigation
+
美国/盟友官方高
+
发布者:JICA 日期:2025
+
用于后勤与制度化分析。
+
打开来源
+
安全级别/安全说明
群体级
编号
grp_aviation
名称
PCG航空与无人系统人员
类型
航空人员
摘要
承担搜救、监视、灾害响应、海上态势感知和影像记录等任务。
主要任务
活动区域
压力来源
稳定因素
关联事件
来源
+
Philippine Coast Guard becomes H145 public mission operator
+
主流媒体高
+
发布者:Airbus 日期:2018
+
供应商官方资料。
+
打开来源
+
+
Australia drone and uncrewed maritime support to PCG
+
美国/盟友官方高
+
发布者:Australian Embassy Philippines 日期:2025
+
官方使馆资料。
+
打开来源
+
安全级别/安全说明
群体级
编号
grp_logistics
名称
维修与后勤人员
类型
维修与后勤人员
摘要
支撑舰艇、快艇、直升机、无人系统和前沿行动的维修、备件、燃油、医疗、饮水与补给。
主要任务
活动区域
压力来源
装备数量上升远海任务增加备件周期前沿维修能力灾害任务叠加
稳定因素
关联事件
evt_jica_maintenance_2025evt_pcg_foundation_water
来源
+
JICA-PCG technical cooperation on maintenance and investigation
+
美国/盟友官方高
+
发布者:JICA 日期:2025
+
用于后勤与制度化分析。
+
打开来源
+
安全级别/安全说明
群体级
编号
grp_family
名称
PCG人员家属
类型
家属
摘要
一线人员稳定性的重要后方支撑,关切安全、收入、医疗、教育、探亲和隐私保护。
主要任务
活动区域
家庭社区PCG Foundation项目地方家属支持网络
压力来源
安全焦虑长期分离信息不透明子女教育医疗康复媒体曝光
稳定因素
关联事件
evt_pcg_family_day_2025evt_pcg_health_2025
来源
+
Philippine Coast Guard Support System Foundation
+
基金会/公益组织中高
+
发布者:PCG Foundation 日期:长期
+
组织官网,用于福利支撑研究。
+
打开来源
+
+
Strengthening the Nation through the Filipino Family
+
基金会/公益组织中高
+
发布者:PCG Foundation 日期:2025
+
用于家属生态和组织价值叙事。
+
打开来源
+
+
PCG Health and Wellness Caravan in CGDWV
+
基金会/公益组织中高
+
发布者:PCG Foundation 日期:2025-07
+
用于心理韧性分析。
+
打开来源
+
安全级别/安全说明
敏感群体,仅群体级
编号
grp_fisherfolk
名称
西菲律宾海渔民
类型
渔民
摘要
既是民生主体,也是菲律宾海上存在、护渔叙事和地方社会安全感的重要来源。
主要任务
捕鱼作业海上民生存在接受补给提供地方经验表达安全诉求
活动区域
黄岩岛附近仙宾礁附近ZambalesPalawanBataraza
压力来源
水炮与拦截风险禁航或避让导致收入下降燃油成本冰和市场损耗军演外溢影响
稳定因素
BFAR补贴PCG护航地方政府支持安全通报海上收购项目
关联事件
evt_bfar_kbbm_2025evt_sabina_fishermen_2025evt_scarborough_resupply_2025
来源
+
KBBM Program empowers fisherfolk and reinforces PH presence in WPS
+
菲律宾官方高
+
发布者:BFAR 日期:2025-06
+
官方项目资料。
+
打开来源
+
+
PCG resupplies fishermen in the South China Sea
+
主流媒体中高
+
发布者:USNI News 日期:2025-10
+
需与官方资料对照。
+
打开来源
+
+
APHR statement on Filipino fisherfolk and militarization
+
基金会/公益组织中
+
发布者:ASEAN Parliamentarians for Human Rights 日期:2025
+
代表民生与人权立场,需与其他来源平衡。
+
打开来源
+
安全级别/安全说明
群体级
编号
grp_pagasa_residents
名称
Pag-asa / Thitu前沿居民
类型
地方居民
摘要
生活在南沙前沿社区的平民居民,其安全感、生计、教育、通信和补给状况与PCG存在密切相关。
主要任务
活动区域
Pag-asa IslandKalayaanPalawan
压力来源
周边船只存在通信和漫游争议补给距离远儿童教育安全焦虑
稳定因素
学校与诊所港口和跑道PCG指挥节点地方政府补贴社区韧性
关联事件
evt_pagasa_2026_04evt_pagasa_residents_2024
来源
+
The Guardian report on Thitu/Pag-asa fishing community
+
主流媒体中高
+
发布者:The Guardian 日期:2024-08
+
地方社区公开报道。
+
打开来源
+
+
Reuters report on Pag-asa visit and residents
+
主流媒体高
+
发布者:Reuters 日期:2026-02
+
用于地方安全感分析。
+
打开来源
+
+
AP report on Philippine coast guard base in South China Sea
+
主流媒体高
+
发布者:Associated Press 日期:2026-04
+
地方社会数据。
+
打开来源
+
安全级别/安全说明
社区级
编号
grp_media
名称
菲律宾媒体与透明化传播者
类型
媒体
摘要
通过现场影像、报道、采访和事实核查,将海上事件转化为国内外可理解的政治与舆论材料。
主要任务
活动区域
压力来源
稳定因素
关联事件
evt_transparency_pulse_2025evt_tarriela_embassy_2026
来源
+
Pulse Asia survey on transparency policy in WPS
+
菲律宾官方中高
+
发布者:Philippine Information Agency 日期:2025
+
民调委托背景需在分析中说明。
+
打开来源
+
+
VERA Files West Philippine Sea topic
+
事实核查机构高
+
发布者:VERA Files 日期:长期
+
事实核查机构资料。
+
打开来源
+
+
West Philippine Sea Transparency Group
+
菲律宾官方中高
+
发布者:WPS Transparency Group 日期:2026
+
官方透明化传播平台。
+
打开来源
+
安全级别/安全说明
公开传播级
编号
grp_allies
名称
美国、日本、澳大利亚等外部伙伴
类型
外部盟友
摘要
通过装备、训练、无人系统、联合演训、外交背书和能力建设支持PCG现代化。
主要任务
装备援助训练合作联合演习技术合作外交支持态势感知建设
活动区域
美国本土课程日本海上保安厅合作澳大利亚无人机项目菲律宾训练中心
压力来源
中方反制叙事反美军事化批评地方社区战争风险感合作可持续性
稳定因素
关联事件
evt_us_training_2025evt_jica_mrrv_2024evt_australia_drone_2025evt_tri_coastguard_2025
来源
+
US Embassy and PCG training program
+
菲律宾官方中高
+
发布者:Philippine News Agency 日期:2025-10
+
公开报道口径。
+
打开来源
+
+
JICA Maritime Safety Capability Improvement Project Phase 3
+
美国/盟友官方高
+
发布者:JICA 日期:2024-06
+
官方项目资料。
+
打开来源
+
+
Australia drone and uncrewed maritime support to PCG
+
美国/盟友官方高
+
发布者:Australian Embassy Philippines 日期:2025
+
官方使馆资料。
+
打开来源
+
安全级别/安全说明
机构级
编号
law_ra9993
名称
Republic Act No. 9993 / 菲律宾海警法2009
类型
法律文件
摘要
PCG被定义为armed and uniformed service,承担海上安全、搜救、海上执法、海洋环保、航行安全等职责。
主要职能
海上搜救海上安全海上执法海洋环境保护航行安全港口国监督海事事故调查
战时/危机角色
国会宣战时,PCG整体或部分单位可转隶国防部。
分析价值
解释PCG为什么能够在南海摩擦中作为白船执法前台出现。
来源
+
Republic Act No. 9993 / Philippine Coast Guard Law of 2009
+
法律文件高
+
发布者:Philippine Government / Official legal database 日期:2009
+
基础法律来源。
+
打开来源
+
编号
org_dotr
名称
PCG与DOTr体系
类型
组织关系
摘要
PCG平时位于交通部门体系之中,但承担海上执法、安全、搜救和环保等公共安全职责。
主要职能
战时/危机角色
危机或战时可能与国防部、军方和国家任务力量发生更密切协同。
分析价值
该结构使PCG同时具备民事执法、国家安全和外交叙事三种属性。
来源
+
Republic Act No. 9993 / Philippine Coast Guard Law of 2009
+
法律文件高
+
发布者:Philippine Government / Official legal database 日期:2009
+
基础法律来源。
+
打开来源
+
+
General Appropriations Act 2026 - DOTr / PCG
+
预算文件高
+
发布者:Department of Budget and Management 日期:2026
+
预算金额可用于结构化字段。
+
打开来源
+
编号
org_ntfwps
名称
NTF-WPS与透明化政策
类型
跨部门机制
摘要
NTF-WPS通过官方声明、事件核验和跨部门协调,将海上现场事件转化为外交、媒体和政策材料。
主要职能
战时/危机角色
危机期间可能成为协调官方口径和对外沟通的重要机制。
分析价值
透明化政策使PCG不仅是执法者,也是叙事生产者。
来源
+
Pulse Asia survey on transparency policy in WPS
+
菲律宾官方中高
+
发布者:Philippine Information Agency 日期:2025
+
民调委托背景需在分析中说明。
+
打开来源
+
+
West Philippine Sea Transparency Group
+
菲律宾官方中高
+
发布者:WPS Transparency Group 日期:2026
+
官方透明化传播平台。
+
打开来源
+
编号
org_aux
名称
PCG Auxiliary与社会支撑
类型
辅助组织
摘要
PCG法律体系中包含辅助力量,用于支持搜救、公共服务、训练和民间海上安全协作。
主要职能
战时/危机角色
不用于展示具体部署,仅作为组织支撑生态的一部分。
分析价值
说明PCG并非孤立执法机构,而是有辅助、基金会和社会网络支撑。
来源
+
Republic Act No. 9993 / Philippine Coast Guard Law of 2009
+
法律文件高
+
发布者:Philippine Government / Official legal database 日期:2009
+
基础法律来源。
+
打开来源
+
编号
task_patrol
名称
海上巡逻与存在展示
类型
巡逻
摘要
PCG通过白船巡逻、无线电通联、现场影像和公开发声展示菲律宾在争议海域的执法存在。
参与群体
典型地点
操作要素
社会意义
将海上执法行为转化为主权、规则秩序和国内政治叙事。
风险
容易发生拦截或危险机动可能引发外交争议前线人员被媒体符号化
关联事件
evt_sabina_2024_04_09evt_scarborough_2024_02
编号
task_resupply
名称
补给与轮换支援
类型
补给
摘要
围绕驻守点、渔民和前沿社区开展补给、轮换、护航和医疗支援。
参与群体
典型地点
操作要素
社会意义
补给任务被政治化和国际化,常成为主权争议和外交抗议触发点。
风险
冲撞、水炮、登临或阻挡风险不展示具体航线与实时部署
关联事件
evt_renai_2024_06_17evt_scarborough_resupply_2025
编号
task_fisherfolk
名称
护渔与渔民补给
类型
护渔
摘要
PCG与BFAR协同,为争议海域附近菲律宾渔民提供护航、补给、医疗和信息支持。
参与群体
典型地点
操作要素
社会意义
护渔任务将PCG执法角色与普通渔民生计安全绑定。
风险
渔民可能遭遇拦截、水炮或危险机动容易被国内政治和国际舆论放大
关联事件
evt_scarborough_resupply_2025evt_sabina_fishermen_2025
编号
task_sar
名称
海上搜救
类型
搜救
摘要
PCG传统核心职责,负责遇险人员和船舶搜索、救援和医疗转送。
参与群体
典型地点
操作要素
社会意义
搜救构成PCG最稳定的社会合法性来源。
风险
台风、海况和灾害叠加造成高风险需要快速响应和后勤支撑
编号
task_evidence
名称
证据记录与透明化发布
类型
证据记录
摘要
通过视频、照片、无线电记录、媒体随船和官方说明,把海上事件转化为可传播证据。
参与群体
典型地点
操作要素
社会意义
透明化被菲律宾视为反制虚假叙事和争取国际支持的重要工具。
风险
可能引发舆论极化前线人员可能被个人化攻击需避免未核实信息
关联事件
evt_transparency_pulse_2025evt_tarriela_embassy_2026
编号
task_training
名称
联合训练与能力建设
类型
联合演训
摘要
与美国、日本、澳大利亚、越南等开展海警训练、搜救、消防、通信、无人系统和法证能力建设。
参与群体
典型地点
操作要素
社会意义
PCG正在进入区域海警合作网络。
风险
会被反美和反军事化叙事批评可能加剧中国对外部势力介入的指控
关联事件
evt_tri_coastguard_2025evt_vietnam_joint_2024evt_us_training_2025
编号
evt_renai_2024_06_17
名称
仁爱礁补给任务冲突
日期
2024-06-17
地点
仁爱礁 / Second Thomas Shoal / Ayungin Shoal
类型
菲律宾方主体
AFPPhilippine NavyPCG相关支援角色
中国方主体
摘要
菲律宾对BRP Sierra Madre驻守人员的补给任务遭遇中国海警强力拦截,菲律宾方面称出现人员受伤和设备损毁。
事件过程
菲律宾执行补给任务中国海警实施拦截和管控现场发生冲突菲律宾公开影像和说法事件引发外交抗议和盟友表态
可能结果/结果影响
人员受伤菲律宾国内舆论升温美国及盟友支持菲律宾PCG透明化叙事强化
叙事框架
透明化叙事主权维护叙事中国依法管控叙事菲律宾挑衅叙事
来源
+
Reuters report on Second Thomas Shoal incident
+
主流媒体高
+
发布者:Reuters 日期:2024-06
+
事件细节需与中菲官方说法对照。
+
打开来源
+
安全级别/安全说明
不展示实时部署、具体航线和内部通信。
编号
evt_scarborough_2024_02
名称
黄岩岛BFAR巡逻与护渔争议
日期
2024-02-22
地点
黄岩岛 / Scarborough Shoal / Bajo de Masinloc
类型
菲律宾方主体
中国方主体
摘要
菲律宾方面称BFAR船只在黄岩岛附近巡逻是为了保护菲律宾渔民,并反驳中方所谓非法进入说法。
事件过程
BFAR船只在黄岩岛附近巡逻中方提出非法进入说法PCG公开反驳事件进入媒体报道
可能结果/结果影响
叙事框架
来源
+
Reuters report on BFAR near Scarborough Shoal
+
主流媒体高
+
发布者:Reuters 日期:2024-02
+
用于护渔事件线。
+
打开来源
+
安全级别/安全说明
仅展示公开报道,不展示具体行动路线。
编号
evt_sabina_2024_04_09
名称
仙宾礁BRP Teresa Magbanua长期驻留
日期
2024-04至2024-09
地点
仙宾礁 / Sabina Shoal / Escoda Shoal
类型
菲律宾方主体
中国方主体
China Coast Guard中国相关海上力量
摘要
菲律宾将BRP Teresa Magbanua部署至仙宾礁,用于监视和维持存在,后续返回港口并宣称将派替代船只。
事件过程
菲律宾部署大型PCG船只双方围绕驻留和管控发生紧张出现封锁和碰撞指控菲律宾船只返回港口菲律宾宣称继续维持存在
可能结果/结果影响
PCG从任务型出动转向持续存在型前沿力量后勤、补给、人员疲劳问题显性化
叙事框架
来源
+
Reuters report on Sabina Shoal deployment
+
主流媒体高
+
发布者:Reuters 日期:2024-09
+
用于前沿化和后勤压力分析。
+
打开来源
+
安全级别/安全说明
不展示实时驻留状态和具体轮换方式。
编号
evt_pagasa_2026_04
名称
Pag-asa启用Coast Guard District Kalayaan Island Group
日期
2026-04-09
地点
Pag-asa Island / Thitu Island / Kalayaan
类型
菲律宾方主体
中国方主体
摘要
菲律宾在Pag-asa岛激活新的海警区指挥节点,被地方官员视为提升居民士气的重要动作。
事件过程
PCG建立或激活KIG指挥节点地方政府和居民公开表达安全期待媒体报道前沿居民长期面对周边船只存在
可能结果/结果影响
PCG前沿化和基地化趋势加强地方社会与海警存在更深绑定居民安全感与战争风险感并存
叙事框架
来源
+
AP report on Philippine coast guard base in South China Sea
+
主流媒体高
+
发布者:Associated Press 日期:2026-04
+
地方社会数据。
+
打开来源
+
+
Philippines unveils new coast guard command in Pag-asa Island
+
主流媒体中高
+
发布者:ABS-CBN 日期:2026-04
+
用于前沿社区和基地化趋势。
+
打开来源
+
安全级别/安全说明
不展示基地内部布局和安保细节。
编号
evt_bfar_kbbm_2025
名称
BFAR KBBM项目支援WPS渔民
日期
2025-06-05
地点
Bataraza, Palawan / WPS渔场
类型
菲律宾方主体
摘要
BFAR推出Kadiwa ng Bagong Bayaning Mangingisda项目,通过海上收购、燃油和冰补贴支持接近1000名WPS渔民。
事件过程
政府组织海上收购与补贴渔民获得燃油与冰支持PCG在安全与支援环节发挥协同作用
可能结果/结果影响
渔民生计与国家海上存在绑定民生叙事强化护渔任务制度化
叙事框架
来源
+
KBBM Program empowers fisherfolk and reinforces PH presence in WPS
+
菲律宾官方高
+
发布者:BFAR 日期:2025-06
+
官方项目资料。
+
打开来源
+
安全级别/安全说明
不采集渔民个人信息。
编号
evt_scarborough_resupply_2025
名称
PCG与BFAR为黄岩岛和仙宾礁附近渔民补给
日期
2025-10
地点
黄岩岛与仙宾礁附近
类型
菲律宾方主体
中国方主体
China Coast Guard相关现场压力,媒体报道口径
摘要
PCG与BFAR向约100艘菲律宾渔船提供燃油、食品和其他补给,以延长其在争议海域的作业时间。
事件过程
菲律宾派出PCG和BFAR船只向渔民提供补给媒体报道中提及中方干扰背景护渔和民生叙事强化
可能结果/结果影响
叙事框架
来源
+
PCG resupplies fishermen in the South China Sea
+
主流媒体中高
+
发布者:USNI News 日期:2025-10
+
需与官方资料对照。
+
打开来源
+
安全级别/安全说明
不展示具体航线。
编号
evt_sabina_fishermen_2025
名称
仙宾礁附近渔民受伤与PCG救助
日期
2025-12
地点
仙宾礁 / Sabina Shoal / Escoda Shoal
类型
菲律宾方主体
中国方主体
摘要
菲律宾方面称中国海警在仙宾礁附近使用水炮和阻挡动作,导致菲律宾渔民受伤、渔船受损,PCG随后提供医疗和物资支援。
事件过程
渔民在相关海域作业发生水炮和阻挡事件渔民受伤与船只受损PCG抵达并提供救助事件进入媒体和官方叙事
可能结果/结果影响
PCG护渔合法性强化中菲叙事冲突加剧渔民民生风险凸显
叙事框架
来源
+
Philippines says fishermen hurt near Sabina Shoal
+
主流媒体高
+
发布者:Reuters 日期:2025-12
+
事件应与各方声明对照。
+
打开来源
+
安全级别/安全说明
不采集受伤渔民个人身份信息。
编号
evt_tri_coastguard_2025
名称
美日菲三边海警演训
日期
2025-06
地点
日本鹿儿岛
类型
菲律宾方主体
摘要
PCG参加美日菲三边海警演练,科目包括通信、搜救、消防、拍照和人员转移。
事件过程
PCG派出代表和船只与JCG和USCG进行多科目演练强化互操作性和规则秩序叙事
可能结果/结果影响
PCG区域海警网络深化能力建设提升反美或反军事化叙事可能升温
叙事框架
来源
+
PCG joins US-Japan-Philippines trilateral coast guard exercise
+
菲律宾官方中高
+
发布者:Philippine News Agency 日期:2025-06
+
演训公开报道。
+
打开来源
+
安全级别/安全说明
不展示训练细节和战术操作。
编号
evt_transparency_pulse_2025
名称
菲律宾民众支持WPS透明化政策
日期
2025-09
地点
菲律宾全国民意调查
类型
菲律宾方主体
PCGNTF-WPS菲律宾公众Pulse Asia
摘要
调查显示大量菲律宾民众支持政府公开披露中国对菲律宾船只和渔民的强制行为,透明化成为PCG叙事核心工具。
事件过程
政府持续公开影像和事件信息民调机构采样媒体报道民意结果透明化政策获得政治资源
可能结果/结果影响
PCG透明化叙事合法性增强国内对WPS议题关注升温
叙事框架
来源
+
Pulse Asia survey on transparency policy in WPS
+
菲律宾官方中高
+
发布者:Philippine Information Agency 日期:2025
+
民调委托背景需在分析中说明。
+
打开来源
+
安全级别/安全说明
民调仅代表调查样本,不直接等同全部民意。
编号
eq_97m_mrrv
名称
97米级多用途响应船
类型
大型巡逻舰艇
提供方
日本 / JICA
数量/规模
既有2艘,新增5艘项目
项目
Maritime Safety Capability Improvement Project Phase 3
主要职能
主要任务
压力来源
战略价值
提升PCG在西菲律宾海方向的远海存在能力。
来源
+
JICA Maritime Safety Capability Improvement Project Phase 3
+
美国/盟友官方高
+
发布者:JICA 日期:2024-06
+
官方项目资料。
+
打开来源
+
编号
eq_44m_mrrv
名称
44米级多用途响应船
类型
中型巡逻船
提供方
日本 / JICA
数量/规模
10艘,公开项目口径
项目
早期海上安全能力提升项目
主要职能
主要任务
压力来源
战略价值
构成PCG现代化舰队的中坚基础。
来源
+
JICA Maritime Safety Capability Improvement Project Phase 3
+
美国/盟友官方高
+
发布者:JICA 日期:2024-06
+
官方项目资料。
+
打开来源
+
编号
eq_fastcraft_40
名称
40艘快速巡逻艇
类型
快速巡逻艇
提供方
法国 OCEA
数量/规模
40艘项目
项目
Fast Patrol Crafts for PCG
主要职能
主要任务
压力来源
战略价值
增强PCG分布式快速反应能力,与大型MRRV形成互补。
来源
+
Purchase of 40 fast patrol crafts for PCG
+
菲律宾官方高
+
发布者:Presidential Communications Office 日期:2024
+
官方新闻稿。
+
打开来源
+
编号
eq_h145
名称
Airbus H145直升机
类型
航空资产
提供方
Airbus
数量/规模
公开报道显示PCG成为菲律宾H145公共任务运营方
项目
公共任务航空能力
主要职能
主要任务
压力来源
战略价值
让PCG任务从海面延伸到空中支援和灾害响应。
来源
+
Philippine Coast Guard becomes H145 public mission operator
+
主流媒体高
+
发布者:Airbus 日期:2018
+
供应商官方资料。
+
打开来源
+
编号
eq_drone_australia
名称
澳大利亚无人机与无人海事态势感知技术
类型
无人系统
提供方
澳大利亚
数量/规模
PHP 36M已交付,最高PHP 110M后续承诺
项目
Drone and uncrewed maritime domain awareness support
主要职能
主要任务
压力来源
战略价值
提升PCG低成本感知能力和透明化材料生产能力。
来源
+
Australia drone and uncrewed maritime support to PCG
+
美国/盟友官方高
+
发布者:Australian Embassy Philippines 日期:2025
+
官方使馆资料。
+
打开来源
+
编号
eq_jica_maintenance
名称
JICA 2025—2028运行维护与调查能力合作
类型
维护与制度化项目
提供方
JICA / PCG
数量/规模
2025—2028技术合作
项目
运行维护、刑事调查、SOP与指南建设
主要职能
主要任务
压力来源
战略价值
装备增加后的关键瓶颈从采购转向维护、训练和制度化。
来源
+
JICA-PCG technical cooperation on maintenance and investigation
+
美国/盟友官方高
+
发布者:JICA 日期:2025
+
用于后勤与制度化分析。
+
打开来源
+
编号
budget_2026
名称
2026年PCG公开预算
year
2026
总拨款
PHP 42,514,531,000
人员经费
PHP 26,441,518,000
运营维护费用
PHP 8,953,720,000
资本支出
PHP 7,119,293,000
主要项目
Maritime Security and Law Enforcement:PHP 16.248B
Maritime Search and Rescue:PHP 6.714B
Marine Environmental Protection:PHP 3.411B
Maritime Safety:PHP 3.407B
福利项目
Rice Subsidy:每月20公斤大米形式援助Subsistence Allowance:制服人员口粮津贴Quick Response Fund:灾害、危机和救援行动资金
分析价值
预算结构显示PCG正在从传统海事机构向高频执法和安全存在机构转型。
来源
+
General Appropriations Act 2026 - DOTr / PCG
+
预算文件高
+
发布者:Department of Budget and Management 日期:2026
+
预算金额可用于结构化字段。
+
打开来源
+
编号
welfare_ra9993
名称
RA 9993薪酬、津贴与退休制度
year
长期制度
总拨款
—
人员经费
—
运营维护费用
—
资本支出
—
主要项目
Basic pay and longevity pay:制度性安排
Hazard pay and allowances:制度性安排
Retirement system:按服役年限和年龄适用
福利项目
危险津贴退休制度医疗和伤亡相关保障需与年度预算核验
分析价值
薪酬、津贴和退休制度构成一线人员持续执行高风险任务的制度基础。
来源
+
Republic Act No. 9993 / Philippine Coast Guard Law of 2009
+
法律文件高
+
发布者:Philippine Government / Official legal database 日期:2009
+
基础法律来源。
+
打开来源
+
编号
fam_support
名称
PCG Foundation后方支持体系
支持主体
PCG Foundation
支持领域
压力来源
安全焦虑长期分离信息不透明收入稳定需求子女教育压力医疗康复压力
主要项目
Family DayHealth and Wellness Caravan医疗与心理健康活动
分析价值
家属支持体系是PCG一线稳定性的重要组成部分。
安全限制
不采集家属姓名不采集住址不采集社交账号不建立个人弱点画像
来源
+
Philippine Coast Guard Support System Foundation
+
基金会/公益组织中高
+
发布者:PCG Foundation 日期:长期
+
组织官网,用于福利支撑研究。
+
打开来源
+
+
Strengthening the Nation through the Filipino Family
+
基金会/公益组织中高
+
发布者:PCG Foundation 日期:2025
+
用于家属生态和组织价值叙事。
+
打开来源
+
+
PCG Health and Wellness Caravan in CGDWV
+
基金会/公益组织中高
+
发布者:PCG Foundation 日期:2025-07
+
用于心理韧性分析。
+
打开来源
+
编号
fam_family_day
名称
家庭日与组织价值叙事
支持主体
PCG Foundation
支持领域
压力来源
主要项目
分析价值
PCG把家庭塑造成组织忠诚、服务精神和国家建设的一部分。
安全限制
来源
+
Strengthening the Nation through the Filipino Family
+
基金会/公益组织中高
+
发布者:PCG Foundation 日期:2025
+
用于家属生态和组织价值叙事。
+
打开来源
+
编号
fam_health
名称
健康与心理韧性项目
支持主体
PCG Foundation / Coast Guard District Western Visayas
支持领域
压力来源
主要项目
Health and Wellness Caravan
分析价值
说明PCG已把身心健康和长期承压作为组织福利议题处理。
安全限制
来源
+
PCG Health and Wellness Caravan in CGDWV
+
基金会/公益组织中高
+
发布者:PCG Foundation 日期:2025-07
+
用于心理韧性分析。
+
打开来源
+
编号
local_pagasa
名称
Pag-asa Island / Thitu Island
行政单元
Kalayaan, Palawan
人口公开口径
约387—400名居民,公开报道口径
基础设施
社会议题
居民安全感渔民生计儿童教育补给稳定通信环境大国竞争风险
PCG存在
Coast Guard District Kalayaan Island Group
地方态度
公开报道显示,当地居民和地方官员将PCG常设存在视为士气提升,但也长期面对中国船只存在和安全焦虑。
关联事件
evt_pagasa_2026_04evt_pagasa_residents_2024
来源
+
The Guardian report on Thitu/Pag-asa fishing community
+
主流媒体中高
+
发布者:The Guardian 日期:2024-08
+
地方社区公开报道。
+
打开来源
+
+
Reuters report on Pag-asa visit and residents
+
主流媒体高
+
发布者:Reuters 日期:2026-02
+
用于地方安全感分析。
+
打开来源
+
+
AP report on Philippine coast guard base in South China Sea
+
主流媒体高
+
发布者:Associated Press 日期:2026-04
+
地方社会数据。
+
打开来源
+
编号
local_kalayaan
名称
Kalayaan Municipality
行政单元
Palawan
人口公开口径
公开资料显示其管辖Pag-asa及若干岛礁,具体人口需以地方统计核验
基础设施
社会议题
PCG存在
PCG KIG指挥节点与海警站
地方态度
地方政府将PCG存在视为对居民、渔民和地方行政存在的重要支撑。
关联事件
来源
+
Kalayaan municipal official information
+
菲律宾官方中高
+
发布者:Kalayaan Municipality 日期:长期
+
地方政府公开资料。
+
打开来源
+
+
AP report on Philippine coast guard base in South China Sea
+
主流媒体高
+
发布者:Associated Press 日期:2026-04
+
地方社会数据。
+
打开来源
+
编号
local_bataraza
名称
Bataraza / Buliluyan Port
行政单元
Palawan
人口公开口径
作为KBBM渔民支援项目节点处理
基础设施
社会议题
PCG存在
PCG在渔民支援和护渔场景中提供安全协同
地方态度
该地是将民生补贴、渔业市场和海上存在连接起来的关键节点。
关联事件
来源
+
KBBM Program empowers fisherfolk and reinforces PH presence in WPS
+
菲律宾官方高
+
发布者:BFAR 日期:2025-06
+
官方项目资料。
+
打开来源
+
编号
local_zambales
名称
Zambales沿海渔民社区
行政单元
Zambales
人口公开口径
不采集个人和村级敏感信息,仅作为渔民群体分析
基础设施
社会议题
PCG存在
PCG护渔、救助和透明化叙事关联区域
地方态度
渔民既关注中国海警压力,也关注美菲军演和禁航区对生计的影响。
关联事件
evt_scarborough_2024_02evt_scarborough_resupply_2025
来源
+
APHR statement on Filipino fisherfolk and militarization
+
基金会/公益组织中
+
发布者:ASEAN Parliamentarians for Human Rights 日期:2025
+
代表民生与人权立场,需与其他来源平衡。
+
打开来源
+
+
PCG resupplies fishermen in the South China Sea
+
主流媒体中高
+
发布者:USNI News 日期:2025-10
+
需与官方资料对照。
+
打开来源
+
编号
local_balabac
名称
Balabac方向
行政单元
Palawan
人口公开口径
不采集地方居民个人数据
基础设施
社会议题
安全保障战争风险感地方经济反美/亲美分歧基地化外溢
PCG存在
PCG前沿生态受EDCA和美菲安全架构间接影响
地方态度
该区域体现地方社会、PCG行动和盟友军事/安全存在之间的叠加影响。
关联事件
来源
+
Philippines and US announce locations of four new EDCA sites
+
美国/盟友官方高
+
发布者:U.S. Indo-Pacific Command 日期:2023
+
EDCA不是PCG项目,但影响PCG前沿生态。
+
打开来源
+
编号
nar_transparency
名称
透明化叙事
类型
官方叙事
核心主张
通过公开现场影像、事件经过和官方说明,反制虚假信息并争取国内外支持。
主要行为体
传播渠道
正面效果
提升国际关注增加国内支持强化PCG正面形象支持预算和装备扩充
风险
反向叙事
来源
+
Pulse Asia survey on transparency policy in WPS
+
菲律宾官方中高
+
发布者:Philippine Information Agency 日期:2025
+
民调委托背景需在分析中说明。
+
打开来源
+
+
West Philippine Sea Transparency Group
+
菲律宾官方中高
+
发布者:WPS Transparency Group 日期:2026
+
官方透明化传播平台。
+
打开来源
+
编号
nar_sovereignty
名称
主权维护叙事
类型
官方/民族主义叙事
核心主张
菲律宾在西菲律宾海拥有主权权利,PCG行动是依法维护国家权益。
主要行为体
传播渠道
正面效果
风险
反向叙事
来源
+
Philippine Senate resolution related to WPS and Chinese Embassy remarks
+
菲律宾官方中高
+
发布者:Philippine News Agency 日期:2026-02
+
官方媒体报道口径。
+
打开来源
+
+
SWS survey on candidates asserting PH rights in WPS
+
主流媒体中高
+
发布者:GMA Network 日期:2025
+
委托方与样本需要保留。
+
打开来源
+
编号
nar_fisherfolk
名称
护渔民生叙事
类型
民生叙事
核心主张
PCG与BFAR行动是为了保护普通渔民的生计、安全和尊严。
主要行为体
传播渠道
正面效果
风险
反向叙事
来源
+
KBBM Program empowers fisherfolk and reinforces PH presence in WPS
+
菲律宾官方高
+
发布者:BFAR 日期:2025-06
+
官方项目资料。
+
打开来源
+
+
Philippines says fishermen hurt near Sabina Shoal
+
主流媒体高
+
发布者:Reuters 日期:2025-12
+
事件应与各方声明对照。
+
打开来源
+
编号
nar_anti_us
名称
反美叙事
类型
批评/影响叙事
核心主张
美国利用菲律宾对抗中国,EDCA和联合演习会使菲律宾成为战争前线。
主要行为体
亲中信息网络部分杜特尔特式缓和派反战群体左翼民生组织
传播渠道
正面效果
风险
反向叙事
来源
+
Reuters investigation on China info influence in the Philippines
+
主流媒体高
+
发布者:Reuters 日期:2025
+
需区分媒体调查与司法事实。
+
打开来源
+
+
APHR statement on Filipino fisherfolk and militarization
+
基金会/公益组织中
+
发布者:ASEAN Parliamentarians for Human Rights 日期:2025
+
代表民生与人权立场,需与其他来源平衡。
+
打开来源
+
编号
nar_anti_war
名称
反战与反军事化叙事
类型
民生/左翼/人权叙事
核心主张
菲律宾应保护渔民免受中国压力,同时避免被美菲军事化推向大国冲突前线。
主要行为体
传播渠道
正面效果
风险
反向叙事
来源
+
APHR statement on Filipino fisherfolk and militarization
+
基金会/公益组织中
+
发布者:ASEAN Parliamentarians for Human Rights 日期:2025
+
代表民生与人权立场,需与其他来源平衡。
+
打开来源
+
编号
nar_china_lawful
名称
中国依法管控叙事
类型
中国官方叙事
核心主张
中方在相关海域采取措施是依法管控,菲律宾船只非法进入或挑衅。
主要行为体
传播渠道
正面效果
风险
反向叙事
来源
+
Chinese official statements on South China Sea incidents
+
中国官方中高
+
发布者:China Coast Guard / MFA / Embassy public statements 日期:长期
+
实际使用时应逐事件补充具体官方链接。
+
+
编号
nar_factcheck
名称
事实核查叙事
类型
事实核查/媒体素养叙事
核心主张
WPS争议中存在地图、法律概念、伪造发言、AI错误回答和历史叙事误读,需要事实核查。
主要行为体
传播渠道
正面效果
风险
反向叙事
来源
+
VERA Files West Philippine Sea topic
+
事实核查机构高
+
发布者:VERA Files 日期:长期
+
事实核查机构资料。
+
打开来源
+
编号
info_anti_us
名称
反美叙事样本
平台类型
社交媒体/短视频/伪媒体
类型
反美叙事
核心信息
美国利用菲律宾对抗中国,EDCA和联合演习会使菲律宾成为战争前线。
目标受众
普通菲律宾民众沿海社区反战群体杜特尔特支持者左翼民生组织
社会议题
EDCA基地Balikatan演习渔民禁航区战争风险地方安全焦虑
反制叙事
风险等级
中
核验状态
需结合事实核查机构和平台样本进一步核验
来源
+
Reuters investigation on China info influence in the Philippines
+
主流媒体高
+
发布者:Reuters 日期:2025
+
需区分媒体调查与司法事实。
+
打开来源
+
编号
info_pro_china
名称
亲中缓和叙事样本
平台类型
社交媒体/意见文章
类型
亲中缓和叙事
核心信息
中国是重要发展伙伴,菲律宾应避免对抗并恢复对话。
目标受众
社会议题
反制叙事
风险等级
中
核验状态
需区分真实缓和派意见与协调性影响行动
来源
+
Reuters investigation on China info influence in the Philippines
+
主流媒体高
+
发布者:Reuters 日期:2025
+
需区分媒体调查与司法事实。
+
打开来源
+
+
Philippine Senate resolution related to WPS and Chinese Embassy remarks
+
菲律宾官方中高
+
发布者:Philippine News Agency 日期:2026-02
+
官方媒体报道口径。
+
打开来源
+
编号
info_fake_quotes
名称
伪造政治人物发言样本
平台类型
社交媒体/图文传播
类型
伪造政治人物发言
核心信息
通过伪造或断章取义政治人物发言影响WPS争议认知。
目标受众
社会议题
反制叙事
风险等级
中高
核验状态
事实核查机构已有相关类型案例
来源
+
VERA Files West Philippine Sea topic
+
事实核查机构高
+
发布者:VERA Files 日期:长期
+
事实核查机构资料。
+
打开来源
+
编号
info_map_label
名称
地图标签误读样本
平台类型
地图/截图/AI回答
类型
地图标签误读
核心信息
把Google Maps或AI回答中的WPS标注误读为法律结论。
目标受众
社会议题
反制叙事
风险等级
中
核验状态
需通过事实核查和法律解释纠偏
来源
+
VERA Files West Philippine Sea topic
+
事实核查机构高
+
发布者:VERA Files 日期:长期
+
事实核查机构资料。
+
打开来源
+
编号
info_anti_transparency
名称
反透明化叙事样本
平台类型
社交媒体/政治评论
类型
反透明化叙事
核心信息
PCG公开影像和官员发言是在挑衅中国、制造紧张或服务美国。
目标受众
社会议题
反制叙事
风险等级
中高
核验状态
需要对比官方声明、民调和事实核查
来源
+
Tarriela on transparency and WPS issues
+
主流媒体中高
+
发布者:Inquirer 日期:2026
+
公开发言分析。
+
打开来源
+
+
Pulse Asia survey on transparency policy in WPS
+
菲律宾官方中高
+
发布者:Philippine Information Agency 日期:2025
+
民调委托背景需在分析中说明。
+
打开来源
+
编号
net_pcg_bfar
名称
PCG-BFAR-渔民护渔网络
类型
民生安全网络
节点角色
互动链条
渔民进入争议海域BFAR提供民生支援PCG提供护航和安全支持事件被媒体记录国内舆论形成压力政府扩大护渔与补给项目
关联事件
evt_bfar_kbbm_2025evt_scarborough_resupply_2025evt_sabina_fishermen_2025
编号
net_transparency
名称
PCG-NTF-WPS-媒体透明化网络
类型
舆论与证据网络
节点角色
互动链条
海上事件发生PCG记录影像NTF-WPS核验口径媒体传播DFA与盟友回应民意和政策反馈
关联事件
evt_transparency_pulse_2025evt_tarriela_embassy_2026evt_renai_2024_06_17
编号
net_allies
名称
PCG-盟友训练与能力建设网络
类型
外部能力建设网络
节点角色
日本/JCG/JICA:船艇、维护、海上执法和环保训练
互动链条
南海摩擦长期化菲律宾请求能力建设盟友提供装备训练PCG能力提升中国和反美叙事回应菲律宾继续前出
关联事件
evt_us_training_2025evt_jica_mrrv_2024evt_tri_coastguard_2025evt_vietnam_joint_2024
编号
net_foundation
名称
PCG Foundation后方支持网络
类型
福利与韧性网络
节点角色
PCG Foundation:医疗、教育、生计、福利、救灾项目
互动链条
一线任务压力增加人员与家属福利需求上升基金会组织健康、家庭和物资支持组织稳定性增强
关联事件
evt_pcg_family_day_2025evt_pcg_health_2025
编号
net_local
名称
PCG-地方政府-前沿社区网络
类型
地方社会网络
节点角色
Kalayaan地方政府:地方行政、居民服务和政治表达
Pag-asa居民:平民存在、社区生活和安全感反馈
互动链条
前沿社区长期存在周边船只压力增加PCG常设节点启用居民士气提升地方被卷入大国竞争叙事
关联事件
evt_pagasa_2026_04evt_pagasa_residents_2024
编号
trend_expansion
名称
PCG扩编与前沿化
类型
组织趋势
摘要
PCG人员规模、舰艇装备、前沿指挥节点和盟友训练合作显示其正在从传统海事机构转向前沿海上执法力量。
驱动因素
南海摩擦长期化菲律宾透明化政策美国、日本、澳大利亚支持国内民意支持渔民保护需求
可能结果/结果影响
更多前沿部署节点更多大型巡逻船更多无人机和态势感知设备更强的媒体传播能力更高的一线人员福利需求
风险
中菲摩擦升级地方社区安全焦虑反美和反军事化叙事升温PCG基层人员长期疲劳
可信度
中高
来源
+
JICA Maritime Safety Capability Improvement Project Phase 3
+
美国/盟友官方高
+
发布者:JICA 日期:2024-06
+
官方项目资料。
+
打开来源
+
+
PCG personnel expansion public report
+
菲律宾官方中高
+
发布者:Philippine News Agency 日期:2025-01
+
公开报道口径。
+
打开来源
+
编号
trend_transparency
名称
透明化政策制度化
类型
舆论趋势
摘要
PCG与NTF-WPS将现场影像、发言和事实核查结合,透明化从临时传播转向制度化工具。
驱动因素
社交媒体传播国际支持需求事实核查需求对抗虚假信息国内民意
可能结果/结果影响
媒体随船常态化校园和公众教育增加PCG发言人政治化社交平台叙事战加剧
风险
可信度
中高
来源
+
Pulse Asia survey on transparency policy in WPS
+
菲律宾官方中高
+
发布者:Philippine Information Agency 日期:2025
+
民调委托背景需在分析中说明。
+
打开来源
+
+
West Philippine Sea Transparency Group
+
菲律宾官方中高
+
发布者:WPS Transparency Group 日期:2026
+
官方透明化传播平台。
+
打开来源
+
+
VERA Files West Philippine Sea topic
+
事实核查机构高
+
发布者:VERA Files 日期:长期
+
事实核查机构资料。
+
打开来源
+
编号
trend_family
名称
福利与家属支持需求上升
类型
人员生态趋势
摘要
长期前沿任务使家属、心理健康、医疗、休假和福利公平成为组织稳定性的关键变量。
驱动因素
海上摩擦高频化人员长期远离家庭媒体英雄化伤病和风险暴露基金会项目扩展
可能结果/结果影响
更多心理健康项目家属支持制度化基层福利诉求增加组织保留人才压力上升
风险
福利兑现不足导致信任下降家属隐私暴露英雄化遮蔽基层压力
可信度
中
来源
+
Philippine Coast Guard Support System Foundation
+
基金会/公益组织中高
+
发布者:PCG Foundation 日期:长期
+
组织官网,用于福利支撑研究。
+
打开来源
+
+
PCG Health and Wellness Caravan in CGDWV
+
基金会/公益组织中高
+
发布者:PCG Foundation 日期:2025-07
+
用于心理韧性分析。
+
打开来源
+
+
General Appropriations Act 2026 - DOTr / PCG
+
预算文件高
+
发布者:Department of Budget and Management 日期:2026
+
预算金额可用于结构化字段。
+
打开来源
+
编号
trend_fisherfolk
名称
护渔民生安全政治化
类型
民生趋势
摘要
渔民安全和收入越来越成为PCG合法性、BFAR项目、地方政治和WPS主权叙事交汇点。
驱动因素
黄岩岛和仙宾礁摩擦渔民补给项目渔民受伤事件媒体传播反军事化组织发声
可能结果/结果影响
更多护渔补给渔民成为政策象征民生补贴项目增加反军事化诉求持续
风险
渔民被卷入大国竞争生计受禁航区影响民生诉求被政党化
可信度
中高
来源
+
KBBM Program empowers fisherfolk and reinforces PH presence in WPS
+
菲律宾官方高
+
发布者:BFAR 日期:2025-06
+
官方项目资料。
+
打开来源
+
+
PCG resupplies fishermen in the South China Sea
+
主流媒体中高
+
发布者:USNI News 日期:2025-10
+
需与官方资料对照。
+
打开来源
+
+
APHR statement on Filipino fisherfolk and militarization
+
基金会/公益组织中
+
发布者:ASEAN Parliamentarians for Human Rights 日期:2025
+
代表民生与人权立场,需与其他来源平衡。
+
打开来源
+
编号
trend_polarization
名称
WPS议题选举化与政治极化
类型
政治趋势
摘要
WPS立场影响候选人评价,PCG透明化和对华立场可能在2028年前继续被国内政治使用。
驱动因素
民调支持强硬候选人参议院决议PCG发言人争议中国使馆回应杜特尔特式缓和派与主权派分歧
可能结果/结果影响
候选人竞相表态透明化成为政治资产亲中/反美标签化外交议题内政化
风险
政策讨论简化为站队专业海上执法被党争化虚假信息增加
可信度
中高
来源
+
SWS survey on candidates asserting PH rights in WPS
+
主流媒体中高
+
发布者:GMA Network 日期:2025
+
委托方与样本需要保留。
+
打开来源
+
+
Philippine Senate resolution related to WPS and Chinese Embassy remarks
+
菲律宾官方中高
+
发布者:Philippine News Agency 日期:2026-02
+
官方媒体报道口径。
+
打开来源
+
+
Tarriela on transparency and WPS issues
+
主流媒体中高
+
发布者:Inquirer 日期:2026
+
公开发言分析。
+
打开来源
+
编号
src_ra9993
title
Republic Act No. 9993 / Philippine Coast Guard Law of 2009
类型
法律文件
发布者
Philippine Government / Official legal database
日期
2009
可信度
高
链接
https://elibrary.judiciary.gov.ph/thebookshelf/showdocs/2/17034
用于
备注
基础法律来源。
编号
src_dbm_gaa_2026
title
General Appropriations Act 2026 - DOTr / PCG
类型
预算文件
发布者
Department of Budget and Management
日期
2026
可信度
高
链接
https://www.dbm.gov.ph/wp-content/uploads/GAA/GAA2026/VolumeIB/DOTR/F.pdf
用于
备注
预算金额可用于结构化字段。
编号
src_pna_expansion_2025
title
PCG personnel expansion public report
类型
菲律宾官方
发布者
Philippine News Agency
日期
2025-01
可信度
中高
链接
https://www.pna.gov.ph/articles/1241967
用于
备注
公开报道口径。
编号
src_reuters_renai_2024
title
Reuters report on Second Thomas Shoal incident
类型
主流媒体
发布者
Reuters
日期
2024-06
可信度
高
链接
https://www.reuters.com/world/asia-pacific/china-coast-guard-personnel-band-barbarians-says-philippine-navy-official-2024-06-20/
用于
备注
事件细节需与中菲官方说法对照。
编号
src_reuters_scarborough_2024
title
Reuters report on BFAR near Scarborough Shoal
类型
主流媒体
发布者
Reuters
日期
2024-02
可信度
高
链接
https://www.reuters.com/world/asia-pacific/philippine-coast-guard-says-chinese-claim-intrusion-inaccurate-2024-02-22/
用于
备注
用于护渔事件线。
编号
src_reuters_sabina_2024
title
Reuters report on Sabina Shoal deployment
类型
主流媒体
发布者
Reuters
日期
2024-09
可信度
高
链接
https://www.reuters.com/world/asia-pacific/philippines-continue-deploy-vessels-contested-south-china-sea-shoal-2024-09-16/
用于
备注
用于前沿化和后勤压力分析。
编号
src_abs_pagasa_2026
title
Philippines unveils new coast guard command in Pag-asa Island
类型
主流媒体
发布者
ABS-CBN
日期
2026-04
可信度
中高
链接
https://www.abs-cbn.com/news/regions/2026/4/9/philippines-unveils-new-coast-guard-command-in-pag-asa-island-1328
用于
备注
用于前沿社区和基地化趋势。
编号
src_ap_pagasa_2026
title
AP report on Philippine coast guard base in South China Sea
类型
主流媒体
发布者
Associated Press
日期
2026-04
可信度
高
链接
https://apnews.com/article/0880f88576273345a9efe850e0dbfef6
用于
备注
地方社会数据。
编号
src_guardian_thitu_2024
title
The Guardian report on Thitu/Pag-asa fishing community
类型
主流媒体
发布者
The Guardian
日期
2024-08
可信度
中高
链接
https://www.theguardian.com/world/article/2024/aug/26/thitu-island-philippines-south-china-sea-tensions-pag-asa-fishing-community
用于
备注
地方社区公开报道。
编号
src_reuters_pagasa_2026
title
Reuters report on Pag-asa visit and residents
类型
主流媒体
发布者
Reuters
日期
2026-02
可信度
高
链接
https://www.reuters.com/world/china/welcome-china-greets-philippine-officials-trip-disputed-south-china-sea-2026-02-23/
用于
备注
用于地方安全感分析。
编号
src_kalayaan_official
title
Kalayaan municipal official information
类型
菲律宾官方
发布者
Kalayaan Municipality
日期
长期
可信度
中高
链接
https://www.kalayaanpalawan.gov.ph/mp.html
用于
备注
地方政府公开资料。
编号
src_jica_mrrv_2024
title
JICA Maritime Safety Capability Improvement Project Phase 3
类型
美国/盟友官方
发布者
JICA
日期
2024-06
可信度
高
链接
https://www.jica.go.jp/english/overseas/philippine/information/press/2024/1542369_53492.html
用于
备注
官方项目资料。
编号
src_jica_maintenance_2025
title
JICA-PCG technical cooperation on maintenance and investigation
类型
美国/盟友官方
发布者
JICA
日期
2025
可信度
高
链接
https://www.jica.go.jp/english/overseas/philippine/information/press/2025/PCGTCP20250415.html
用于
备注
用于后勤与制度化分析。
编号
src_pco_france_fastcraft
title
Purchase of 40 fast patrol crafts for PCG
类型
菲律宾官方
发布者
Presidential Communications Office
日期
2024
可信度
高
链接
https://pco.gov.ph/news_releases/pbbm-okays-purchase-of-40-fast-patrol-crafts-for-pcg/
用于
备注
官方新闻稿。
编号
src_airbus_h145
title
Philippine Coast Guard becomes H145 public mission operator
类型
主流媒体
发布者
Airbus
日期
2018
可信度
高
链接
https://www.airbus.com/en/newsroom/press-releases/2018-09-philippine-coast-guard-becomes-philippines-first-h145-operator-for
用于
备注
供应商官方资料。
编号
src_australia_drone_2025
title
Australia drone and uncrewed maritime support to PCG
类型
美国/盟友官方
发布者
Australian Embassy Philippines
日期
2025
可信度
高
链接
https://philippines.embassy.gov.au/mnla/MR%20281125.html
用于
备注
官方使馆资料。
编号
src_pna_us_training_2025
title
US Embassy and PCG training program
类型
菲律宾官方
发布者
Philippine News Agency
日期
2025-10
可信度
中高
链接
https://www.pna.gov.ph/articles/1261234
用于
备注
公开报道口径。
编号
src_pna_tri_2025
title
PCG joins US-Japan-Philippines trilateral coast guard exercise
类型
菲律宾官方
发布者
Philippine News Agency
日期
2025-06
可信度
中高
链接
https://www.pna.gov.ph/articles/1252946
用于
备注
演训公开报道。
编号
src_reuters_vietnam_2024
title
Philippines and Vietnam first joint coast guard exercise
类型
主流媒体
发布者
Reuters
日期
2024-08
可信度
高
链接
https://www.reuters.com/world/asia-pacific/philippines-vietnam-hold-first-ever-joint-coast-guard-exercise-2024-08-09/
用于
备注
用于外部网络分析。
编号
src_bfar_kbbm_2025
title
KBBM Program empowers fisherfolk and reinforces PH presence in WPS
类型
菲律宾官方
发布者
BFAR
日期
2025-06
可信度
高
链接
https://www.bfar.da.gov.ph/2025/06/05/kbbm-program-empowers-fisherfolk-reinforces-ph-presence-in-the-west-philippine-sea/
用于
备注
官方项目资料。
编号
src_usni_resupply_2025
title
PCG resupplies fishermen in the South China Sea
类型
主流媒体
发布者
USNI News
日期
2025-10
可信度
中高
链接
https://news.usni.org/2025/10/08/philippine-coast-guard-resupplies-fishermen-in-the-south-china-sea-despite-chinese-harassment
用于
备注
需与官方资料对照。
编号
src_reuters_fishermen_2025
title
Philippines says fishermen hurt near Sabina Shoal
类型
主流媒体
发布者
Reuters
日期
2025-12
可信度
高
链接
https://www.reuters.com/world/china/philippines-says-fishermen-hurt-boats-damaged-by-china-south-china-sea-2025-12-13/
用于
备注
事件应与各方声明对照。
编号
src_aphr_fisherfolk_2025
title
APHR statement on Filipino fisherfolk and militarization
类型
基金会/公益组织
发布者
ASEAN Parliamentarians for Human Rights
日期
2025
可信度
中
链接
https://aseanmp.org/publications/post/aphr-demands-protection-of-filipino-fisherfolk-calls-for-an-end-to-us-china-militarization-of-the-philippine-seas
用于
备注
代表民生与人权立场,需与其他来源平衡。
编号
src_pcg_foundation
title
Philippine Coast Guard Support System Foundation
类型
基金会/公益组织
发布者
PCG Foundation
日期
长期
可信度
中高
链接
https://pcgfi.org/
用于
备注
组织官网,用于福利支撑研究。
编号
src_pcg_foundation_family_day
title
Strengthening the Nation through the Filipino Family
类型
基金会/公益组织
发布者
PCG Foundation
日期
2025
可信度
中高
链接
https://pcgfi.org/news/strengthening-the-nation-through-the-filipino-family/
用于
备注
用于家属生态和组织价值叙事。
编号
src_pcg_foundation_health_caravan
title
PCG Health and Wellness Caravan in CGDWV
类型
基金会/公益组织
发布者
PCG Foundation
日期
2025-07
可信度
中高
链接
https://pcgfi.org/news/pcg-health-and-wellness-caravan-in-cgdwv/
用于
备注
用于心理韧性分析。
编号
src_pia_transparency_2025
title
Pulse Asia survey on transparency policy in WPS
类型
菲律宾官方
发布者
Philippine Information Agency
日期
2025
可信度
中高
链接
https://pia.gov.ph/press-release/filipinos-overwhelmingly-support-government-transparency-policy-on-the-west-philippine-sea-pulse-asia-survey/
用于
备注
民调委托背景需在分析中说明。
编号
src_sws_wps_2025
title
SWS survey on candidates asserting PH rights in WPS
类型
主流媒体
发布者
GMA Network
日期
2025
可信度
中高
链接
https://www.gmanetwork.com/news/topstories/nation/944186/75-of-filipinos-prefer-candidates-who-assert-ph-rights-in-wps-sws/story/
用于
备注
委托方与样本需要保留。
编号
src_wpstransparency
title
West Philippine Sea Transparency Group
类型
菲律宾官方
发布者
WPS Transparency Group
日期
2026
可信度
中高
链接
https://wpstransparency.ph/facebook-feed-2026/
用于
备注
官方透明化传播平台。
编号
src_verafiles_wps
title
VERA Files West Philippine Sea topic
类型
事实核查机构
发布者
VERA Files
日期
长期
可信度
高
链接
https://verafiles.org/topic/west-philippine-sea
用于
备注
事实核查机构资料。
编号
src_reuters_infowar_2025
title
Reuters investigation on China info influence in the Philippines
类型
主流媒体
发布者
Reuters
日期
2025
可信度
高
链接
https://www.reuters.com/world/asia-pacific/how-china-waged-an-infowar-against-us-interests-philippines-2025-10-06/
用于
备注
需区分媒体调查与司法事实。
编号
src_pna_senate_2026
title
Philippine Senate resolution related to WPS and Chinese Embassy remarks
类型
菲律宾官方
发布者
Philippine News Agency
日期
2026-02
可信度
中高
链接
https://www.pna.gov.ph/articles/1268704
用于
备注
官方媒体报道口径。
编号
src_inquirer_tarriela_2026
title
Tarriela on transparency and WPS issues
类型
主流媒体
发布者
Inquirer
日期
2026
可信度
中高
链接
https://www.inquirer.net/467887/tarriela-on-being-silent-and-not-transparent-on-wps-issues/
用于
备注
公开发言分析。
编号
src_indopacom_edca_2023
title
Philippines and US announce locations of four new EDCA sites
类型
美国/盟友官方
发布者
U.S. Indo-Pacific Command
日期
2023
可信度
高
链接
https://www.pacom.mil/Media/News/Article/3350502/philippines-us-announce-locations-of-four-new-edca-sites/
用于
备注
EDCA不是PCG项目,但影响PCG前沿生态。
编号
src_china_official_general
title
Chinese official statements on South China Sea incidents
类型
中国官方
发布者
China Coast Guard / MFA / Embassy public statements
日期
长期
可信度
中高
用于
备注
实际使用时应逐事件补充具体官方链接。
+
+
+
\ No newline at end of file
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no27/no.27.html b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no27/no.27.html
new file mode 100644
index 00000000..3bc5b9f6
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/templates/no27/no.27.html
@@ -0,0 +1,98 @@
+
+
+
+
+
+菲律宾“不做美国棋子”叙事专题数据库 v0.2
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/skills-data/xdfx-full-flow-skill-guide.md b/skills-data/xdfx-full-flow-skill-guide.md
index 88dd1805..014a6300 100644
--- a/skills-data/xdfx-full-flow-skill-guide.md
+++ b/skills-data/xdfx-full-flow-skill-guide.md
@@ -6,10 +6,10 @@
1. 受众目标分析,选出最优 3 个受众并入库。
2. 关键传播节点分析,跨受众去重后选出最优 3 个节点并入库。
-3. 受众脆弱点分析并入库。
-4. 关键传播节点脆弱点分析并入库。
-5. 受众策略分析并入库。
-6. 关键传播节点策略分析并入库。
+3. 从已入库受众中默认选前 2 个继续,每个生成最多 3 个脆弱点并入库。
+4. 从已入库关键传播节点中默认选前 2 个继续,每个生成最多 3 个脆弱点并入库。
+5. 对上述选中的每个受众默认生成最多 3 个策略并入库。
+6. 对上述选中的每个关键传播节点默认生成最多 3 个策略并入库。
7. 同步前端步骤状态,保证原 `item-aiceh` 页面步骤条可继续识别。
## 技能位置
@@ -41,9 +41,9 @@ skills-data/xdfx-single-agent-full-flow/scripts/xdfx_flow.py
}
```
-默认会自动处理登录态:当命令没有传 `Authorization`,或某个接口返回 401 时,脚本会用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/v1/auth/login/username?password=ch%40user`,请求体为 `{"username":"admin"}`。拿到 `access_token` 后写入 `Authorization: Bearer ...` 并重试原接口一次。
+默认会自动处理登录态:脚本启动时先 `import scripts/xdfx_auth.py`,用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/auth/login`,请求体为 `{"username":"admin","password":"ch@user"}`。拿到 `access_token` 后写入内部 `Authorization: Bearer ...`,再调用第一步业务接口;不要通过任何命令行参数传 token。
-默认关闭 HTTPS 证书校验,用于兼容 UAT/内网自签证书;如果部署环境证书链正常,可以追加 `--verify-ssl`。如果已经有可用 token,也可以继续追加 `--auth-token TOKEN` 或 `--header "Authorization: Bearer TOKEN"`,脚本会优先使用传入 token。
+默认关闭 HTTPS 证书校验,用于兼容 UAT/内网自签证书;如果部署环境证书链正常,可以追加 `--verify-ssl`。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次。
## 执行命令
@@ -60,30 +60,44 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
`--mode full` 只作为无人值守或排障兜底,不作为默认执行方式;外层工具通常会等长命令结束后才返回输出,无法保证真实逐步反馈。逐步命令共享同一个 `xdfx-flow-state.json`,不会影响 id 传递、断点续跑和最终入库。
+如果需要一条命令完成逐步编排,可以使用包装脚本。它内部仍按 step1-step6 调用 `xdfx_flow.py`,并逐行转发反馈:
+
+```bash
+python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
+```
+
每完成一步都会输出一行:
```text
-STEP_FEEDBACK {...}
+步骤反馈 {...}
```
-调用技能的智能体必须在每个 step 命令结束后读取 `STEP_FEEDBACK`,给用户一次反馈,再执行下一条 step 命令。
+调用技能的智能体必须在每个 step 命令结束后读取 `步骤反馈`,给用户一次反馈,再执行下一条 step 命令。
每次执行具体操作或调用接口前,脚本会输出:
```text
-OP_FEEDBACK {...}
+操作反馈 {...}
```
-调用技能的智能体必须把 `operation`、`method` 和 `url` 转述给用户,例如:
+调用技能的智能体必须把 `操作`、`请求方式` 和 `接口地址` 转述给用户,例如:
```text
正在保存受众目标,调用 POST https://.../api/task/task-action-design。
```
+每次保存接口成功拿到 `id` 后,脚本会输出:
+
+```text
+入库反馈 {...}
+```
+
+调用技能的智能体必须把 `入库对象类型`、`入库对象名称` 和 `返回ID` 转述给用户,例如“已入库受众目标:xxx,designId=123”或“已入库受众脆弱点:xxx,id=456”。
+
如果报错,脚本会立即输出:
```text
-ERROR_FEEDBACK {...}
+错误反馈 {...}
```
调用技能的智能体必须立刻告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
@@ -91,10 +105,10 @@ ERROR_FEEDBACK {...}
如果模型 JSON 经过自动提取和修复仍无法解析,脚本会输出:
```text
-JSON_PARSE_REVIEW {...}
+JSON解析复核 {...}
```
-调用技能的智能体必须查看 `responseExcerpt`,判断是否能识别真正 JSON;无法确认时重跑当前步骤或重新调用对应模型接口。
+调用技能的智能体必须查看 `响应片段`,判断是否能识别真正 JSON;无法确认时重跑当前步骤或重新调用对应模型接口。
只检查脚本与参数、不调用真实接口:
@@ -130,6 +144,8 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
- `task-action-design-strategy-relation`
- `task/task-action/{actionId}` 的 `step` 字段
+策略信息形式/模态会按前端 `modelType` 字典保存为 value 逗号串,例如模型输出“图片,视频”时,脚本先查 `sys/sys-dict-data` 再写入 `task-action-message.modelType`,不会直接保存中文文字。
+
## 输出文件
执行后查看:
@@ -139,27 +155,29 @@ xdfx-flow-state.json
xdfx-flow-summary.md
```
-`xdfx-flow-state.json` 保存断点状态、接口返回 id、模型解析结果与错误明细。
+`xdfx-flow-state.json` 保存断点状态、接口返回 ID、模型解析结果与错误明细。
`xdfx-flow-summary.md` 是给用户看的执行摘要。
## 每步反馈
-脚本会输出三类反馈行:
+脚本会输出五类反馈行:
-- `OP_FEEDBACK`:当前正在做什么、调用哪个接口。
-- `ERROR_FEEDBACK`:当前错误的阶段、接口、状态码、请求摘要和响应摘要。
-- `JSON_PARSE_REVIEW`:模型返回内容无法稳定解析为 JSON,需要执行智能体复核是否重试。
-- `STEP_FEEDBACK`:当前步骤最终状态和累计入库数量。
+- `操作反馈`:当前正在做什么、调用哪个接口。
+- `入库反馈`:当前已入库的对象类型、名称和返回 ID。
+- `错误反馈`:当前错误的阶段、接口、状态码、请求摘要和响应摘要。
+- `JSON解析复核`:模型返回内容无法稳定解析为 JSON,需要执行智能体复核是否重试。
+- `步骤反馈`:当前步骤最终状态和累计入库数量。
-`STEP_FEEDBACK` 中包含:
+`步骤反馈` 中包含:
-- `step`:步骤编号。
-- `label`:步骤名称。
-- `status`:`ok`、`partial` 或 `skipped`。
-- `counts`:当前累计入库数量。
-- `newErrors`:本步骤新增错误数。
-- `totalErrors`:累计错误数。
+- `步骤`:步骤编号。
+- `步骤名称`:步骤名称。
+- `状态`:`成功`、`部分完成` 或 `已跳过`。
+- `执行状态`:中文执行状态。
+- `累计入库数量`:当前累计入库数量。
+- `本步骤新增错误数`:本步骤新增错误数。
+- `累计错误数`:累计错误数。
智能体反馈用户时建议使用这种格式:
@@ -167,20 +185,23 @@ xdfx-flow-summary.md
Step1 受众目标分析完成:当前已入库受众 X 个,本步骤新增错误 Y 个。
```
-如果 `status=partial`,要补一句“该步骤部分完成”,并说明稍后最终汇总会列出错误明细。
+如果 `状态` 为 `部分完成`,要补一句“该步骤部分完成”,并说明稍后最终汇总会列出错误明细。
-如果 `status=skipped`,要说明跳过原因。
+如果 `状态` 为 `已跳过`,要说明跳过原因。
## 容错规则
- 单个受众、账号、传播节点、脆弱点或策略失败,不中断整个流程。
- 保存接口必须返回 `id`,否则不会进入下游步骤。
+- 受众目标默认入库 3 个,关键传播节点默认入库 3 个。
+- 下游脆弱点/策略默认只选择前 2 个受众和前 2 个传播节点继续,可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。
+- 每个被选中的对象默认最多生成 3 个脆弱点、3 个策略。
- 模型流式 `answer` 会自动拼接。
- 流式模型接口第一次返回空响应、`y模型没有响应`、`模型没有响应` 或超时类字符串时,会自动再调用一次;仍失败才写入 `errors`。
- 模型输出会自动去掉 ``,从前后混杂文本、多个 JSON 代码块、无换行流式文本、连续 JSON 对象、非工整 JSON 中提取多个候选,并按当前步骤所需字段选择最像的 JSON。
- 模型 JSON 会尝试修复中文引号、全角冒号/逗号、尾随逗号、未加引号的键、Python 风格 `True/False/None`。
- 模型 JSON 解析失败会自动重试 1 次。
-- 每一步完成都会写入 state/summary 并输出 `STEP_FEEDBACK`。
+- 每一步完成都会写入 state/summary 并输出 `步骤反馈`。
- 接口失败会写入 `errors`,包含阶段、URL、状态码、请求摘要、响应正文和异常类型。
## 回复用户规则
@@ -190,3 +211,4 @@ Step1 受众目标分析完成:当前已入库受众 X 个,本步骤新增
如果 `errors` 为空,可以说明“全流程完成”。
如果 `errors` 非空,只能说明“部分完成”,并列出失败阶段、接口 URL、状态码和响应摘要。
+
diff --git a/skills-data/xdfx-single-agent-full-flow/SKILL.md b/skills-data/xdfx-single-agent-full-flow/SKILL.md
index 3c8a2656..2cdce48e 100644
--- a/skills-data/xdfx-single-agent-full-flow/SKILL.md
+++ b/skills-data/xdfx-single-agent-full-flow/SKILL.md
@@ -26,20 +26,20 @@ scripts/xdfx_flow.py
}
```
-默认会在缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 调用:
+鉴权不能通过命令行传递 token。脚本会在启动阶段先 `import scripts/xdfx_auth.py`,用 `admin / ch@user` 获取访问令牌,并在第一步业务接口调用前写入请求头。
+
+默认调用:
```text
-POST /api/v1/auth/login/username?password=ch%40user
-body: {"username":"admin"}
+POST /api/auth/login
+body: {"username":"admin","password":"ch@user"}
```
-拿到 `access_token` 后会写入 `Authorization: Bearer ...` 并重试原接口一次。登录接口和 401 重试都会输出 `OP_FEEDBACK` / `ERROR_FEEDBACK`,但反馈中的 `password`、`token` 会脱敏。默认关闭 HTTPS 证书校验,适配 UAT/内网自签证书;如果环境证书链正常,可以追加 `--verify-ssl`。
-
-如果已经有可用登录态,也可以在命令里追加 `--auth-token TOKEN` 或 `--header "Authorization: Bearer TOKEN"`;此时脚本优先使用传入的 token。
+拿到 `access_token` 后会写入内部 `Authorization: Bearer ...`。不要通过任何命令行参数传 token;脚本会忽略外部 Authorization,统一由 `xdfx_auth.py` 获取和刷新。登录接口和 401 重试都会输出 `操作反馈` / `错误反馈`,但反馈中的 `password`、`token` 会脱敏。默认关闭 HTTPS 证书校验,适配 UAT/内网自签证书;如果环境证书链正常,可以追加 `--verify-ssl`。
## 执行命令
-从本 Skill 目录逐步执行。虽然这是“一个技能做完整流程”的版本,但为了给用户真实的逐步反馈,智能体必须按 step1-step6 分六个命令运行,每个命令结束后先把 `STEP_FEEDBACK` / 新增错误反馈给用户,再继续下一步:
+从本 Skill 目录逐步执行。虽然这是“一个技能做完整流程”的版本,但为了给用户真实的逐步反馈,智能体必须按 step1-step6 分六个命令运行,每个命令结束后先把 `步骤反馈` / 新增错误反馈给用户,再继续下一步:
```bash
python scripts/xdfx_flow.py --mode step1 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
@@ -52,13 +52,29 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
`--mode full` 仅作为无人值守或本地排障兜底,不作为默认执行方式;因为外层工具通常会等长命令结束才返回输出,无法保证用户看到实时反馈。逐步命令共享同一个 `xdfx-flow-state.json`,id 传递和断点续跑不受影响。
-每一步完成后都会输出一行 `STEP_FEEDBACK {...}`。智能体必须在每个 step 命令结束后给用户一次简短反馈,说明该步骤状态、已入库数量和新增错误数,然后再执行下一步。
+如果确实想用一条命令编排完整流程,优先使用轻量包装脚本,而不是 `--mode full`。它会在内部逐个启动 step 命令并转发每一行反馈:
-脚本每次调用接口前还会输出 `OP_FEEDBACK {...}`,包含当前操作、HTTP 方法和 URL。智能体必须用简短中文告诉用户“正在做什么、调用哪个接口”。
+```bash
+python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
+```
-脚本出现错误时会立即输出 `ERROR_FEEDBACK {...}`,包含阶段、接口 URL、状态码、请求摘要、响应摘要和异常类型。智能体必须立刻告诉用户错误细节,不能等到最后才说。
+每一步完成后都会输出一行 `步骤反馈 {...}`。智能体必须在每个 step 命令结束后给用户一次简短反馈,说明该步骤状态、已入库数量和新增错误数,然后再执行下一步。
-如果模型返回内容经过多候选 JSON 提取和修复后仍无法解析,脚本会输出 `JSON_PARSE_REVIEW {...}`。智能体必须查看其中的 `responseExcerpt`,判断是否能识别出可用 JSON;无法确认时,重跑当前步骤或重新调用对应接口。
+脚本每次调用接口前还会输出 `操作反馈 {...}`,包含当前操作、HTTP 方法和 URL。智能体必须用简短中文告诉用户“正在做什么、调用哪个接口”。
+
+每次保存接口成功拿到返回 ID 后会输出 `入库反馈 {...}`,包含 `入库对象类型`、`入库对象名称`、`返回ID` 和 `接口路径`。智能体必须把它转述成“已入库 xxx 受众 / xxx 脆弱点 / xxx 策略”等明确日志。
+
+脚本出现错误时会立即输出 `错误反馈 {...}`,包含阶段、接口 URL、状态码、请求摘要、响应摘要和异常类型。智能体必须立刻告诉用户错误细节,不能等到最后才说。
+
+如果模型返回内容经过多候选 JSON 提取和修复后仍无法解析,脚本会输出 `JSON解析复核 {...}`。智能体必须查看其中的 `响应片段`,判断是否能识别出可用 JSON;无法确认时,重跑当前步骤或重新调用对应接口。
+
+## 失败重试规则
+
+- 任意步骤出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前失败的同一条 `--mode stepN` 命令。
+- 重试前必须告诉用户失败阶段、接口 URL、状态码、响应摘要和“正在重试当前步骤”,不能静默重试。
+- 当前步骤未重试成功前,不允许继续执行下一步;不要因为某一步失败就跳过进入后续分析或入库。
+- 同一步重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
+- 只有用户明确要求忽略该失败,才可以继续后续步骤;回复中必须说明这是用户确认后的带风险继续。
只做参数和脚本检查、不调用接口:
@@ -68,36 +84,44 @@ python scripts/xdfx_flow.py --mode full --input xdfx-input.json --dry-run
## 脚本保证
-- 受众只取最优 3 个继续。
-- 关键传播节点全局去重后只取最优 3 个继续。
+- 受众目标默认入库最优 3 个,可用 `--max-audiences` 调整。
+- 关键传播节点全局去重后默认入库最优 3 个,可用 `--max-pathways` 调整。
+- 下游脆弱点/策略默认只从已入库结果中选择前 2 个受众、前 2 个传播节点继续,可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。
+- 每个被选中的受众/传播节点默认生成最多 3 个脆弱点,可用 `--max-vulns` 调整。
+- 每个被选中的受众/传播节点默认生成最多 3 个策略,可用 `--max-strategies` 调整。
- 每个保存接口必须拿到返回 `id` 才进入下游。
- 保存后会同步前端 `task/task-action.step` 步骤状态;如只想入库不更新步骤条,追加 `--no-step-status`。
- 策略保存会同步写入 `task-action-channel`、`base-narrate`、`task-action-message`、`task-action-design-strategy-relation`,兼容策略表格/编排页面读取。
+- 策略 `信息形式/模态` 入库前会读取 `sys/sys-dict-data` 的 `modelType` 字典,把模型返回的“图片,视频/文本,图片”等文字转成前端需要的 value 逗号串;字典读取失败时使用内置兜底映射。
+- Step1 的受众目标抽取结果里,`用户编号列表` / `编号id` / `id` 必须对应上一轮 `zhanghao` 账号数组的索引值;脚本会按这个索引依次找到账号并保存受众账号关系。
- 模型流式 `{"answer":"..."}` 会自动拼接。
- 流式模型接口第一次返回空响应、`y模型没有响应`、`模型没有响应` 或超时类字符串时,会自动再调用一次;仍失败才写入 `errors`。
- 模型输出会自动去掉 ``,从前后混杂文本、多个 JSON 代码块、无换行流式文本、连续 JSON 对象、非工整 JSON 中提取多个候选,并按当前步骤所需字段选择最像的 JSON。
- 模型 JSON 会尝试修复中文引号、全角冒号/逗号、尾随逗号、未加引号的键、Python 风格 `True/False/None`。
- 模型 JSON 解析失败会自动重试 1 次。
-- 单个受众、账号、节点或策略失败不会拖垮全流程。
-- 每完成一步都会输出 `STEP_FEEDBACK`,包含 `step`、`label`、`status`、累计入库计数和新增错误数。
-- 每次接口调用前都会输出 `OP_FEEDBACK`,说明当前操作和接口 URL。
-- 每个错误都会立即输出 `ERROR_FEEDBACK`,说明失败阶段、接口、状态码、请求摘要和响应摘要。
+- 单个受众、账号、节点或策略失败会被脚本记录;智能体仍必须按“失败重试规则”先重跑当前步骤,不能直接跳到下一步。
+- 每完成一步都会输出 `步骤反馈`,包含 `步骤`、`步骤名称`、`状态`、`累计入库数量` 和 `本步骤新增错误数`。
+- 每次接口调用前都会输出 `操作反馈`,说明当前操作和接口 URL。
+- 每次保存成功都会输出 `入库反馈`,说明已入库对象类型、名称和返回 ID。
+- 每个错误都会立即输出 `错误反馈`,说明失败阶段、接口、状态码、请求摘要和响应摘要。
- 所有接口错误都会写入 `xdfx-flow-state.json.errors`,包含阶段、URL、状态码、请求摘要、响应正文和异常类型。
## 回复用户
-执行过程中,每出现一行 `STEP_FEEDBACK`,都要立刻向用户反馈一次:
+执行过程中,每出现一行 `步骤反馈`,都要立刻向用户反馈一次:
- 当前完成的步骤名称。
-- 状态:`ok`、`partial` 或 `skipped`。
+- 状态:`成功`、`部分完成` 或 `已跳过`。
- 当前累计入库数量。
- 本步骤新增错误数。
-每出现一行 `OP_FEEDBACK`,都要简短告诉用户当前正在执行的操作和接口,例如“正在保存受众目标,调用 POST /api/task/task-action-design”。
+每出现一行 `操作反馈`,都要简短告诉用户当前正在执行的操作和接口,例如“正在保存受众目标,调用 POST /api/task/task-action-design”。
-每出现一行 `ERROR_FEEDBACK`,都要立刻告诉用户失败阶段、接口 URL、状态码和响应摘要。
+每出现一行 `入库反馈`,都要简短告诉用户当前已入库的对象,例如“已入库受众目标:xxx,designId=123”或“已入库受众脆弱点:xxx,id=456”。
-每出现一行 `JSON_PARSE_REVIEW`,都要查看 `responseExcerpt`。如果能明显识别出目标 JSON,说明需要人工复核;如果不能识别,重跑当前 step 或重新调用对应模型接口,并告诉用户“模型 JSON 不工整,正在复核并重试”。
+每出现一行 `错误反馈`,都要立刻告诉用户失败阶段、接口 URL、状态码和响应摘要。
+
+每出现一行 `JSON解析复核`,都要查看 `响应片段`。如果能明显识别出目标 JSON,说明需要人工复核;如果不能识别,重跑当前 step 或重新调用对应模型接口,并告诉用户“模型 JSON 不工整,正在复核并重试”。
全部执行结束后再读取 `xdfx-flow-summary.md` 和命令输出,向用户说明:
@@ -109,3 +133,4 @@ python scripts/xdfx_flow.py --mode full --input xdfx-input.json --dry-run
- 失败明细
只要 `errors` 不为空,就说“部分完成”,不要说“全部完成”。
+
diff --git a/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_auth.py b/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_auth.py
new file mode 100644
index 00000000..4003e55d
--- /dev/null
+++ b/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_auth.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+"""Authentication helper for xdfx skill scripts.
+
+The caller should not pass tokens through command-line arguments. This module
+owns the fixed admin login flow and returns a Bearer token before business
+interfaces are called.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import ssl
+import urllib.error
+import urllib.request
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass
+class AuthResult:
+ token: str
+ url: str
+ status: int | None
+ raw: str
+
+
+class AuthError(RuntimeError):
+ def __init__(
+ self,
+ message: str,
+ *,
+ url: str,
+ status: int | None = None,
+ response: str = "",
+ exception: BaseException | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.url = url
+ self.status = status
+ self.response = response
+ self.exception = exception
+
+
+def auth_header_value(token: str) -> str:
+ token = token.strip()
+ return token if token.lower().startswith("bearer ") else f"Bearer {token}"
+
+
+def redact_url(url: str) -> str:
+ return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
+
+
+def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
+ if verify_ssl:
+ return None
+ return ssl._create_unverified_context()
+
+
+def build_login_url(api_base: str, path: str) -> str:
+ return path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
+
+
+def extract_access_token(data: Any) -> str | None:
+ if isinstance(data, dict):
+ for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
+ value = data.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ for value in data.values():
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ elif isinstance(data, list):
+ for value in data:
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ return None
+
+
+def parse_json_loose(text: str) -> Any:
+ try:
+ return json.loads(text)
+ except Exception:
+ pass
+ match = re.search(r"[\[{]", text or "")
+ if not match:
+ return {}
+ start = match.start()
+ for end in range(len(text), start, -1):
+ try:
+ return json.loads(text[start:end])
+ except Exception:
+ continue
+ return {}
+
+
+def fetch_access_token(
+ *,
+ api_base: str,
+ username: str = "admin",
+ password: str = "ch@user",
+ login_path: str = "/api/auth/login",
+ timeout: float = 90,
+ verify_ssl: bool = False,
+) -> AuthResult:
+ """Login once and return the extracted access token."""
+ url = build_login_url(api_base, login_path)
+ payload = {"username": username, "password": password}
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ parsed = parse_json_loose(raw)
+ token = extract_access_token(parsed)
+ if not token:
+ raise AuthError(
+ "登录接口已响应但未找到 access_token",
+ url=redact_url(url),
+ status=getattr(resp, "status", None),
+ response=raw,
+ )
+ return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
+ raise AuthError(
+ "登录接口返回错误",
+ url=redact_url(url),
+ status=exc.code,
+ response=body,
+ exception=exc,
+ ) from exc
+ except AuthError:
+ raise
+ except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
+ raise AuthError(
+ "登录接口调用异常",
+ url=redact_url(url),
+ response=str(exc),
+ exception=exc,
+ ) from exc
diff --git a/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_flow.py b/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_flow.py
index 52880e82..d9580e2f 100644
--- a/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_flow.py
+++ b/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_flow.py
@@ -26,7 +26,10 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
+import xdfx_auth
+
+# Keep every backend path in one table so endpoint drift is easy to audit.
ENDPOINTS = {
"aiuser": "/gpt/chat/chat_aiuser",
"group": "/gpt/chat/chat_qianqian_group_chat",
@@ -44,10 +47,12 @@ ENDPOINTS = {
"save_narrate": "/api/base/base-narrate",
"save_message": "/api/task/task-action-message",
"save_strategy_relation": "/api/task/task-action-design-strategy-relation",
+ "dict_data": "/api/sys/sys-dict-data",
}
+# Human-readable names are used only in the Chinese feedback logs.
ENDPOINT_LABELS = {
- "login": "登录获取访问 token",
+ "login": "登录获取访问令牌",
"aiuser": "调用候选账号/受众分析模型接口",
"group": "调用受众目标结构化抽取接口",
"spread": "调用关键传播节点结构化抽取接口",
@@ -64,8 +69,90 @@ ENDPOINT_LABELS = {
"save_narrate": "保存策略信息主题",
"save_message": "保存策略消息配置",
"save_strategy_relation": "保存策略与受众/传播节点关系",
+ "dict_data": "读取前端字典数据",
}
+LOG_PREFIXES = {
+ "operation": "操作反馈",
+ "error": "错误反馈",
+ "json_review": "JSON解析复核",
+ "save": "入库反馈",
+ "step": "步骤反馈",
+ "done": "执行完成",
+}
+
+STATUS_LABELS = {
+ "ok": "成功",
+ "partial": "部分完成",
+ "skipped": "已跳过",
+}
+
+STEP_NUMBER_LABELS = {
+ "step1": "第一步",
+ "step2": "第二步",
+ "step3": "第三步",
+ "step4": "第四步",
+ "step5": "第五步",
+ "step6": "第六步",
+}
+
+HTTP_METHOD_LABELS = {
+ "GET": "读取(GET)",
+ "POST": "提交(POST)",
+ "PUT": "更新(PUT)",
+ "PATCH": "局部更新(PATCH)",
+ "DELETE": "删除(DELETE)",
+}
+
+STAGE_SEGMENT_LABELS = {
+ "analyze": "模型分析",
+ "extract": "结构化抽取",
+ "save": "保存数据",
+ "read": "读取数据",
+ "status": "同步状态",
+}
+
+REQUEST_FIELD_LABELS = {
+ "history": "历史消息",
+ "max_tokens": "最大生成令牌数",
+ "model_name": "模型名称",
+ "otherinfo": "其他信息",
+ "prompt_name": "提示词名称",
+ "stream": "是否流式",
+ "temperature": "温度",
+ "usermessage": "用户消息",
+ "user_message": "用户消息",
+ "username": "登录用户名",
+ "password": "登录密码",
+ "mubiao": "行动目标",
+ "zhanghao": "账号列表",
+ "taskId": "任务ID",
+ "actionId": "行动ID",
+ "taskActionId": "行动ID",
+ "designId": "受众ID",
+ "pathwayId": "传播节点ID",
+ "strategyId": "策略ID",
+ "accountId": "账号ID",
+ "accountName": "账号名称",
+ "accountType": "账号类型",
+ "targetAudience": "受众目标",
+ "vulnerabilities": "脆弱点",
+ "topic": "信息标题",
+ "narrate": "叙事主题",
+ "platform": "平台",
+ "persona": "发布人设",
+ "modelType": "信息形式",
+ "quantity": "数量",
+ "publishTime": "发布时间段",
+ "reason": "理由",
+ "ids": "ID列表",
+ "id": "ID",
+ "step": "步骤状态",
+}
+
+SENSITIVE_KEY_PARTS = ("password", "token", "authorization", "access_token", "accesstoken", "authtoken")
+
+# Platform values mirror the frontend dictionaries used by xdfx strategy pages.
PLATFORM_MAP = {
"facebook": 1,
"fb": 1,
@@ -78,6 +165,8 @@ PLATFORM_MAP = {
"yt": 4,
}
+# The model service sometimes returns a successful HTTP response with these
+# strings instead of useful content. Treat them like transient failures.
MODEL_RETRY_MARKERS = (
"y模型没有响应",
"模型没有响应",
@@ -118,6 +207,12 @@ class FlowError(RuntimeError):
@dataclass
class FlowContext:
+ """Runtime options shared by every step.
+
+ Keep this object boring and serializable-ish: it is passed through every
+ helper, while durable progress belongs in the state JSON file.
+ """
+
api_base: str
headers: dict[str, str]
timeout: float
@@ -129,13 +224,15 @@ class FlowContext:
max_pathways: int = 3
max_vulns: int = 3
max_strategies: int = 3
+ downstream_audiences: int = 2
+ downstream_pathways: int = 2
sync_step_status: bool = True
model_retries: int = 1
verify_ssl: bool = False
auto_login: bool = True
login_username: str = "admin"
login_password: str = "ch@user"
- login_path: str = "/api/v1/auth/login/username"
+ login_path: str = "/api/auth/login"
login_attempt_count: int = 0
@@ -153,7 +250,38 @@ def compact(value: Any, limit: int = 1400) -> str:
text = repr(value)
if len(text) <= limit:
return text
- return text[:limit] + f"... "
+ return text[:limit] + f"... <已截断 {len(text) - limit} 个字符>"
+
+
+def is_sensitive_key(key: Any) -> bool:
+ normalized = re.sub(r"[^a-z0-9_]", "", str(key or "").lower())
+ return any(part in normalized for part in SENSITIVE_KEY_PARTS)
+
+
+def redact_sensitive(value: Any) -> Any:
+ if isinstance(value, dict):
+ return {key: "***" if is_sensitive_key(key) else redact_sensitive(item) for key, item in value.items()}
+ if isinstance(value, list):
+ return [redact_sensitive(item) for item in value]
+ if isinstance(value, tuple):
+ return tuple(redact_sensitive(item) for item in value)
+ if isinstance(value, str):
+ text = re.sub(r"(?i)(Bearer\s+)[^\s\",}]+", r"\1***", value)
+ text = re.sub(
+ r"(?i)(\"(?:password|token|authToken|access_token|accessToken|authorization)\"\s*:\s*)\"[^\"]*\"",
+ r'\1"***"',
+ text,
+ )
+ return re.sub(
+ r"(?i)((?:password|token|authToken|access_token|accessToken|authorization)=)[^&\s]+",
+ r"\1***",
+ text,
+ )
+ return value
+
+
+def safe_compact(value: Any, limit: int = 1400) -> str:
+ return compact(redact_sensitive(value), limit)
def stable_json(value: Any) -> str:
@@ -223,6 +351,11 @@ def save_json_file(path: str | Path, value: Any) -> None:
def default_state() -> dict[str, Any]:
+ """Create a resumable state container.
+
+ The script can be run as six independent commands. IDs returned by one step
+ are persisted here so later steps can resume without redoing earlier saves.
+ """
return {
"api_base": "",
"taskId": "",
@@ -247,6 +380,7 @@ def default_state() -> dict[str, Any]:
def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> None:
+ # Accept both frontend-style camelCase and CLI-friendly snake_case inputs.
aliases = {
"task_id": "taskId",
"action_id": "actionId",
@@ -260,16 +394,12 @@ def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> No
continue
if dest in {"taskId", "actionId"}:
state[dest] = str(value)
- elif dest in state or dest in {
- "api_base",
- "headers",
- "auth_token",
- "authorization",
- }:
+ elif dest in state or dest in {"api_base", "headers"}:
state[dest] = value
def record_event(state: dict[str, Any], stage: str, message: str, data: Any | None = None) -> None:
+ """Append a compact diagnostic event to the state file."""
state.setdefault("events", []).append(
{
"time": utc_now(),
@@ -286,9 +416,13 @@ def feedback_step(stage: str) -> str:
return match.group(1) if match else ""
+def feedback_step_label(stage: str) -> str:
+ return STEP_NUMBER_LABELS.get(feedback_step(stage), "")
+
+
def request_keys(payload: Any) -> list[str]:
if isinstance(payload, dict):
- return sorted(str(key) for key in payload.keys())
+ return sorted(REQUEST_FIELD_LABELS.get(str(key), str(key)) for key in payload.keys())
return []
@@ -306,6 +440,77 @@ def endpoint_label(endpoint_key: str | None, method: str, url: str) -> str:
return f"调用接口 {method}"
+def method_label(method: str) -> str:
+ return HTTP_METHOD_LABELS.get(str(method).upper(), str(method))
+
+
+def yes_no(value: bool) -> str:
+ return "是" if value else "否"
+
+
+def stage_label(stage: Any) -> str:
+ text = str(stage or "")
+ if not text:
+ return ""
+ if text in STEP_LABELS:
+ return STEP_LABELS[text]
+ if text == "runner":
+ return "流程执行器"
+ parts = text.split("/")
+ base = parts[0]
+ match = re.search(r"(step[1-6])", base)
+ labels: list[str] = []
+ if match:
+ labels.append(STEP_LABELS.get(match.group(1), match.group(1)))
+ elif base == "runner":
+ labels.append("流程执行器")
+ else:
+ labels.append(base)
+ for segment in parts[1:]:
+ normalized = segment.replace("-", "_")
+ attempt = re.fullmatch(r"model-call-attempt-(\d+)", segment)
+ json_attempt = re.fullmatch(r"model-json-attempt-(\d+)", segment)
+ if attempt:
+ labels.append(f"模型接口第 {attempt.group(1)} 次尝试")
+ elif json_attempt:
+ labels.append(f"JSON解析第 {json_attempt.group(1)} 次尝试")
+ elif segment == "auto-login":
+ labels.append("自动登录")
+ elif segment in STAGE_SEGMENT_LABELS:
+ labels.append(STAGE_SEGMENT_LABELS[segment])
+ elif normalized in ENDPOINT_LABELS:
+ labels.append(ENDPOINT_LABELS[normalized])
+ else:
+ labels.append(segment)
+ return " / ".join(labels)
+
+
+def status_label(status: Any) -> str:
+ if status is None:
+ return ""
+ return STATUS_LABELS.get(str(status), str(status))
+
+
+def mode_label(mode: str) -> str:
+ if mode == "full":
+ return "全流程"
+ return STEP_LABELS.get(mode, mode)
+
+
+def exception_type_label(exception_type: Any) -> str:
+ name = str(exception_type or "")
+ labels = {
+ "HTTPError": "HTTP 调用异常",
+ "URLError": "网络地址异常",
+ "TimeoutError": "请求超时异常",
+ "FlowError": "流程异常",
+ "ValueError": "数据格式异常",
+ "KeyError": "字段缺失异常",
+ "RuntimeError": "运行异常",
+ }
+ return labels.get(name, "程序异常" if name else "")
+
+
def emit_operation_feedback(
state: dict[str, Any],
stage: str,
@@ -317,39 +522,41 @@ def emit_operation_feedback(
attempt: int = 1,
dry_run: bool = False,
) -> None:
+ # Feedback lines are a mini protocol consumed by the calling agent. They
+ # must stay single-line JSON so weak models/tools can relay them reliably.
payload_obj = {
- "type": "OP_FEEDBACK",
- "step": feedback_step(stage),
- "stage": stage,
- "operation": endpoint_label(endpoint_key, method, url),
- "method": method,
- "url": url,
- "endpoint": endpoint_key or "",
- "attempt": attempt,
- "dryRun": dry_run,
- "requestKeys": request_keys(payload),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "操作反馈",
+ "步骤": feedback_step_label(stage),
+ "阶段": stage_label(stage),
+ "操作": endpoint_label(endpoint_key, method, url),
+ "请求方式": method_label(method),
+ "接口地址": url,
+ "接口名称": endpoint_label(endpoint_key, method, url),
+ "第几次尝试": attempt,
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("OP_FEEDBACK " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["operation"] + " " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
def emit_error_feedback(error: dict[str, Any]) -> None:
payload = {
- "type": "ERROR_FEEDBACK",
- "stage": error.get("stage"),
- "step": feedback_step(str(error.get("stage") or "")),
- "message": error.get("message"),
- "url": error.get("url"),
- "status": error.get("status"),
- "request": error.get("request"),
- "response": error.get("response"),
- "exceptionType": error.get("exceptionType"),
- "exception": error.get("exception"),
- "time": error.get("time"),
+ "日志类型": "错误反馈",
+ "阶段": stage_label(error.get("stage")),
+ "步骤": feedback_step_label(str(error.get("stage") or "")),
+ "错误信息": error.get("message"),
+ "接口地址": error.get("url"),
+ "状态码": error.get("status"),
+ "请求摘要": error.get("request"),
+ "响应摘要": error.get("response"),
+ "异常类型": exception_type_label(error.get("exceptionType")),
+ "异常信息": error.get("exception"),
+ "时间": error.get("time"),
}
- print("ERROR_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["error"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def emit_json_parse_review(
@@ -360,18 +567,89 @@ def emit_json_parse_review(
candidate_count: int,
) -> None:
payload = {
- "type": "JSON_PARSE_REVIEW",
- "stage": stage,
- "step": feedback_step(stage),
- "endpoint": endpoint_key,
- "message": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
- "candidateCount": candidate_count,
- "responseExcerpt": compact(text, 3000),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "JSON解析复核",
+ "阶段": stage_label(stage),
+ "步骤": feedback_step_label(stage),
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, endpoint_key),
+ "提示": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
+ "候选数量": candidate_count,
+ "响应片段": compact(text, 3000),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("JSON_PARSE_REVIEW " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["json_review"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
+
+
+SAVE_ENTITY_LABELS = {
+ "save_design": "受众目标",
+ "save_design_account": "受众目标账号",
+ "save_pathway": "关键传播节点",
+ "save_relation": "受众-传播节点关系",
+ "save_design_vuln": "受众脆弱点",
+ "save_pathway_vuln": "传播节点脆弱点",
+ "save_strategy": "策略",
+ "save_channel": "策略平台与人设",
+ "save_narrate": "策略信息主题",
+ "save_message": "策略消息配置",
+ "save_strategy_relation": "策略关联关系",
+}
+
+
+def saved_entity_name(endpoint_key: str, payload: dict[str, Any]) -> str:
+ """Return the most user-readable name for an inserted row."""
+ candidates = [
+ payload.get("targetAudience"),
+ payload.get("accountName"),
+ payload.get("vulnerabilities"),
+ payload.get("topic"),
+ payload.get("narrate"),
+ payload.get("platform"),
+ payload.get("strategyId"),
+ payload.get("designId"),
+ payload.get("pathwayId"),
+ ]
+ for value in candidates:
+ if value is not None and str(value).strip():
+ return str(value).strip()
+ return endpoint_key
+
+
+def emit_save_feedback(
+ state: dict[str, Any],
+ stage: str,
+ endpoint_key: str,
+ payload: dict[str, Any],
+ row_id: int,
+ *,
+ dry_run: bool,
+) -> None:
+ # Emit only after an id is known. Downstream steps use these ids and should
+ # fail loudly rather than inventing relations against missing rows.
+ label = SAVE_ENTITY_LABELS.get(endpoint_key, endpoint_key)
+ name = saved_entity_name(endpoint_key, payload)
+ feedback = {
+ "日志类型": "入库反馈",
+ "步骤": feedback_step(stage),
+ "阶段": stage,
+ "入库对象类型": label,
+ "入库对象名称": name,
+ "返回ID": row_id,
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, label),
+ "接口路径": ENDPOINTS.get(endpoint_key, ""),
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
+ }
+ print(LOG_PREFIXES["save"] + " " + json.dumps(feedback, ensure_ascii=False), flush=True)
+ record_event(
+ state,
+ stage,
+ f"入库{label}成功:{name}",
+ {"id": row_id, "endpoint": endpoint_key, "dryRun": dry_run},
+ )
def record_error(
@@ -385,16 +663,18 @@ def record_error(
response: Any | None = None,
exception: BaseException | None = None,
) -> None:
+ # Persist full-enough error context for post-run diagnosis, then immediately
+ # emit a short line the agent can show to the user.
item = {
"time": utc_now(),
"stage": stage,
"message": message,
"url": url,
"status": status,
- "request": compact(request, 1200) if request is not None else None,
- "response": compact(response, 2000) if response is not None else None,
+ "request": safe_compact(request, 1200) if request is not None else None,
+ "response": safe_compact(response, 2000) if response is not None else None,
"exceptionType": type(exception).__name__ if exception else None,
- "exception": str(exception) if exception else None,
+ "exception": safe_compact(str(exception), 1000) if exception else None,
}
state.setdefault("errors", []).append(item)
state["updatedAt"] = utc_now()
@@ -430,69 +710,29 @@ def url_for_path(ctx: FlowContext, path: str) -> str:
return ctx.api_base.rstrip("/") + "/" + path.lstrip("/")
-def parse_headers(header_values: list[str], auth_token: str | None, authorization: str | None) -> dict[str, str]:
+def parse_headers(header_values: list[str]) -> dict[str, str]:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
- if auth_token:
- headers["Authorization"] = auth_token if auth_token.lower().startswith("bearer ") else f"Bearer {auth_token}"
- if authorization:
- headers["Authorization"] = authorization
for raw in header_values:
if ":" not in raw:
raise FlowError(f"header 格式错误,应为 Key: Value,实际:{raw}")
key, value = raw.split(":", 1)
- headers[key.strip()] = value.strip()
+ normalized = key.strip()
+ if normalized.lower() == "authorization":
+ continue
+ headers[normalized] = value.strip()
return headers
-def has_authorization(headers: dict[str, str]) -> bool:
- return any(key.lower() == "authorization" and str(value).strip() for key, value in headers.items())
-
-
def ssl_context(ctx: FlowContext) -> ssl.SSLContext | None:
if ctx.verify_ssl:
return None
return ssl._create_unverified_context()
-def auth_header_value(token: str) -> str:
- token = token.strip()
- return token if token.lower().startswith("bearer ") else f"Bearer {token}"
-
-
-def extract_access_token(data: Any) -> str | None:
- if isinstance(data, dict):
- for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
- value = data.get(key)
- if isinstance(value, str) and value.strip():
- return value.strip()
- for value in data.values():
- nested = extract_access_token(value)
- if nested:
- return nested
- elif isinstance(data, list):
- for value in data:
- nested = extract_access_token(value)
- if nested:
- return nested
- return None
-
-
-def redact_url(url: str) -> str:
- return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
-
-
-def build_login_url(ctx: FlowContext, path: str) -> str:
- base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else url_for_path(ctx, path)
- if ctx.login_password:
- separator = "&" if "?" in base else "?"
- base = f"{base}{separator}password={urllib.parse.quote(ctx.login_password, safe='')}"
- return base
-
-
def login_candidate_paths(ctx: FlowContext) -> list[str]:
paths = [
- ctx.login_path or "/api/v1/auth/login/username",
- "/api/v1/auth/login/username",
+ ctx.login_path or "/api/auth/login",
+ "/api/auth/login",
]
seen: set[str] = set()
unique: list[str] = []
@@ -506,6 +746,7 @@ def login_candidate_paths(ctx: FlowContext) -> list[str]:
def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force: bool = False) -> bool:
+ """Fetch a fresh token through the local auth package before business calls."""
if not ctx.auto_login:
return False
if ctx.dry_run:
@@ -515,18 +756,17 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
record_error(state, stage, "自动登录失败:缺少 login_username")
return False
if not force and ctx.login_attempt_count > 0:
- return has_authorization(ctx.headers)
+ return any(key.lower() == "authorization" and str(value).strip() for key, value in ctx.headers.items())
if force and ctx.login_attempt_count >= 2:
return False
ctx.login_attempt_count += 1
- payload = {"username": ctx.login_username}
+ payload = {"username": ctx.login_username, "password": ctx.login_password}
last_status: int | None = None
last_response = ""
last_exc: BaseException | None = None
for attempt, path in enumerate(login_candidate_paths(ctx), start=1):
- url = build_login_url(ctx, path)
- display_url = redact_url(url)
+ display_url = xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, path))
emit_operation_feedback(
state,
stage,
@@ -537,63 +777,39 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
attempt=attempt,
)
try:
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- headers = {
- "Content-Type": "application/json",
- "Accept": "application/json",
- }
- req = urllib.request.Request(url, data=data, headers=headers, method="POST")
- with urllib.request.urlopen(req, timeout=ctx.timeout, context=ssl_context(ctx)) as resp:
- raw = resp.read().decode("utf-8", errors="replace")
- parsed = parse_json_lenient(raw)
- token = extract_access_token(parsed)
- if not token:
- last_status = getattr(resp, "status", None)
- last_response = raw
- record_event(
- state,
- stage,
- "登录接口已响应但未找到 access_token",
- {"url": display_url, "status": last_status, "response": compact(raw, 1000)},
- )
- continue
- ctx.headers["Authorization"] = auth_header_value(token)
- record_event(
- state,
- stage,
- "自动登录成功,已写入 Authorization Bearer token",
- {
- "url": display_url,
- "status": getattr(resp, "status", None),
- "username": ctx.login_username,
- },
- )
- return True
- except urllib.error.HTTPError as exc:
- last_status = exc.code
- last_response = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
- last_exc = exc
- record_event(
- state,
- stage,
- "登录接口返回错误,准备尝试下一个候选登录路径",
- {"url": display_url, "status": exc.code, "response": compact(last_response, 1000)},
+ result = xdfx_auth.fetch_access_token(
+ api_base=ctx.api_base,
+ username=ctx.login_username,
+ password=ctx.login_password,
+ login_path=path,
+ timeout=ctx.timeout,
+ verify_ssl=ctx.verify_ssl,
)
- except Exception as exc: # noqa: BLE001 - auto-login must report every failure.
- last_exc = exc
- last_response = traceback.format_exc()
+ ctx.headers["Authorization"] = xdfx_auth.auth_header_value(result.token)
+ state["_tokenSource"] = "xdfx_auth"
record_event(
state,
stage,
- "登录接口调用异常,准备尝试下一个候选登录路径",
- {"url": display_url, "exceptionType": type(exc).__name__, "exception": str(exc)},
+ "启动阶段已通过 xdfx_auth 包获取 token,并写入 Authorization",
+ {"url": result.url, "status": result.status, "username": ctx.login_username},
+ )
+ return True
+ except xdfx_auth.AuthError as exc:
+ last_status = exc.status
+ last_response = exc.response
+ last_exc = exc
+ record_event(
+ state,
+ stage,
+ "xdfx_auth 获取 token 失败,准备尝试下一个候选登录路径",
+ {"url": exc.url, "status": exc.status, "response": safe_compact(exc.response, 1000), "exception": str(exc)},
)
record_error(
state,
stage,
- "自动登录失败,未能获取 access_token",
- url=redact_url(build_login_url(ctx, ctx.login_path or "/api/v1/auth/login/username")),
+ "自动登录失败,未能通过 xdfx_auth 获取 access_token",
+ url=xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, ctx.login_path or "/api/auth/login")),
status=last_status,
request=payload,
response=last_response,
@@ -612,6 +828,7 @@ def http_request_json_url(
endpoint_key: str | None = None,
allow_auto_login: bool = True,
) -> tuple[Any | None, str]:
+ """Call an HTTP JSON endpoint with logging, retries, and 401 recovery."""
method = method.upper()
if ctx.dry_run:
emit_operation_feedback(state, stage, method, url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
@@ -640,6 +857,7 @@ def http_request_json_url(
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
if exc.code == 401 and allow_auto_login and ctx.auto_login:
+ # Refresh once on 401, then replay the original request exactly.
refreshed = try_auto_login(ctx, state, f"{stage}/auto-login", force=True)
if refreshed:
record_event(
@@ -709,6 +927,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
emit_operation_feedback(state, stage, "POST", url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
record_event(state, stage, f"DRY RUN POST {url}", {"payload": payload})
if endpoint_key.startswith("save_"):
+ # Synthetic ids let downstream dry-run code exercise relation wiring.
synthetic_id = abs(hash(stage + stable_json(payload))) % 100000000
return {"id": synthetic_id, "dryRun": True}, json.dumps({"id": synthetic_id, "dryRun": True})
return {"dryRun": True}, json.dumps({"dryRun": True})
@@ -716,6 +935,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
def parse_json_lenient(text: str) -> Any | None:
+ """Parse normal JSON plus adjacent/SSE-like JSON objects."""
stripped = text.strip()
if not stripped:
return None
@@ -730,6 +950,7 @@ def parse_json_lenient(text: str) -> Any | None:
def parse_concatenated_json_objects(text: str) -> list[Any]:
+ """Read streams such as {"answer": "..."}{"answer": "..."} or data: lines."""
decoder = json.JSONDecoder()
objects: list[Any] = []
i = 0
@@ -766,6 +987,7 @@ def parse_concatenated_json_objects(text: str) -> list[Any]:
def model_text_from_response(parsed: Any, raw: str) -> tuple[str, list[Any]]:
+ """Flatten streamed model chunks into one text buffer."""
objects: list[Any]
if isinstance(parsed, list):
objects = parsed
@@ -824,6 +1046,7 @@ def extract_json_from_text(text: str) -> Any | None:
def extract_json_candidates_from_text(text: str) -> list[Any]:
+ """Collect every plausible JSON object/array from messy model text."""
cleaned = strip_think(text)
candidates: list[Any] = []
seen: set[str] = set()
@@ -860,6 +1083,7 @@ def extract_json_candidates_from_text(text: str) -> list[Any]:
def select_json_candidate(candidates: list[Any], endpoint_key: str, stage: str) -> Any | None:
+ """Pick the candidate whose keys best match the current step."""
if not candidates:
return None
scored = [(json_candidate_score(candidate, endpoint_key, stage), idx, candidate) for idx, candidate in enumerate(candidates)]
@@ -1010,6 +1234,7 @@ def extract_first_balanced(text: str, open_ch: str, close_ch: str) -> str | None
def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[str, list[Any], Any | None]:
+ """Call a model endpoint and retry only known empty/timeout/no-response cases."""
max_attempts = max(ctx.model_retries, 0) + 1
last_text = ""
last_objects: list[Any] = []
@@ -1048,6 +1273,11 @@ def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str,
def call_model_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[dict[str, Any], str, list[Any]]:
+ """Return the best structured JSON for a model step.
+
+ If the first parse fails, the second attempt includes an explicit repair
+ instruction so weak models can re-emit only the required JSON.
+ """
last_text = ""
last_objects: list[Any] = []
last_candidate_count = 0
@@ -1202,23 +1432,85 @@ def profile_info(account: dict[str, Any]) -> str:
def parse_index_list(value: Any) -> list[str]:
- if value is None:
- return []
- if isinstance(value, list):
- return [str(v).strip() for v in value if str(v).strip()]
- text = str(value)
- return [part.strip().strip("<>").strip() for part in re.split(r"[,,;;、\s]+", text) if part.strip()]
+ refs: list[str] = []
+
+ def add(raw: Any) -> None:
+ if raw is None:
+ return
+ if isinstance(raw, dict):
+ keys = (
+ "编号id",
+ "编号ID",
+ "用户编号id",
+ "用户编号ID",
+ "用户编号",
+ "账号编号",
+ "引用序号",
+ "索引",
+ "index",
+ "id",
+ "ids",
+ "accountIndex",
+ "accountIndexes",
+ "userIndex",
+ "userIndexes",
+ )
+ for key in keys:
+ if key in raw:
+ add(raw.get(key))
+ return
+ if isinstance(raw, (list, tuple, set)):
+ for item in raw:
+ add(item)
+ return
+ text = str(raw).strip()
+ if not text:
+ return
+ angle_refs = re.findall(r"<<\s*([^<>]+?)\s*>>", text)
+ if angle_refs:
+ for token in angle_refs:
+ add(token)
+ return
+ for part in re.split(r"[,,;;、\s]+", text):
+ token = part.strip().strip("<>").strip()
+ if token:
+ refs.append(token)
+
+ add(value)
+ deduped: list[str] = []
+ seen: set[str] = set()
+ for ref in refs:
+ if ref in seen:
+ continue
+ seen.add(ref)
+ deduped.append(ref)
+ return deduped
+
+
+def account_ref_numbers(account: dict[str, Any]) -> set[int]:
+ numbers: set[int] = set()
+ meta = account.get("metadata") if isinstance(account.get("metadata"), dict) else {}
+ for key in ("编号id", "编号ID", "用户编号id", "用户编号ID", "用户编号", "账号编号", "引用序号", "索引", "index", "accountIndex"):
+ for container in (account, meta):
+ number = as_int(container.get(key)) if isinstance(container, dict) else None
+ if number is not None:
+ numbers.add(number)
+ return numbers
def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, Any] | None:
+ """Resolve model references like <<3>>, index numbers, names, or account ids."""
if not accounts:
return None
text = str(ref).strip().strip("<>").strip()
number = as_int(text)
if number is not None:
for account in accounts:
- if any(as_int(account.get(key)) == number for key in ("引用序号", "索引", "index")):
+ if number in account_ref_numbers(account):
return account
+ # Step1 group extraction returns 编号/id values that refer to the
+ # previous zhanghao array index. Prefer exact index fields above, then
+ # fall back to zero-based and one-based array positions.
if 0 <= number < len(accounts):
return accounts[number]
if 1 <= number <= len(accounts):
@@ -1232,11 +1524,25 @@ def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, A
def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Normalize audience JSON whether the model used numbered fields or items[]."""
items: list[dict[str, Any]] = []
for i in range(1, 21):
group = first_present(data.get(f"群体{i}"), data.get(f"受众{i}"), data.get(f"targetAudience{i}"))
reason = first_present(data.get(f"理由{i}"), data.get(f"推荐理由{i}"), data.get(f"recReason{i}"))
- users = first_present(data.get(f"用户编号列表{i}"), data.get(f"用户列表{i}"), data.get(f"账号编号列表{i}"))
+ users = first_present(
+ data.get(f"用户编号列表{i}"),
+ data.get(f"编号id列表{i}"),
+ data.get(f"编号ID列表{i}"),
+ data.get(f"用户编号id列表{i}"),
+ data.get(f"用户编号ID列表{i}"),
+ data.get(f"用户列表{i}"),
+ data.get(f"账号编号列表{i}"),
+ data.get(f"索引列表{i}"),
+ data.get(f"编号{i}"),
+ data.get(f"id{i}"),
+ data.get(f"ids{i}"),
+ data.get(f"索引{i}"),
+ )
if group:
items.append(
{
@@ -1255,7 +1561,27 @@ def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
{
"targetAudience": str(group).strip(),
"recReason": str(first_present(item.get("理由"), item.get("recReason"), item.get("reason")) or "").strip(),
- "userIndexes": parse_index_list(first_present(item.get("用户编号列表"), item.get("users"), item.get("accounts"))),
+ "userIndexes": parse_index_list(
+ first_present(
+ item.get("用户编号列表"),
+ item.get("编号id列表"),
+ item.get("编号ID列表"),
+ item.get("用户编号id列表"),
+ item.get("用户编号ID列表"),
+ item.get("编号id"),
+ item.get("编号ID"),
+ item.get("用户编号id"),
+ item.get("用户编号ID"),
+ item.get("编号"),
+ item.get("索引"),
+ item.get("id"),
+ item.get("ids"),
+ item.get("users"),
+ item.get("accounts"),
+ item.get("userIndexes"),
+ item.get("accountIndexes"),
+ )
+ ),
"rank": i,
}
)
@@ -1272,6 +1598,7 @@ def audience_score(item: dict[str, Any]) -> tuple[int, int]:
def select_top_audiences(items: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
+ """Deduplicate audience names and keep the strongest candidates."""
unique: dict[str, dict[str, Any]] = {}
for item in items:
key = item.get("targetAudience", "")
@@ -1301,6 +1628,7 @@ def pathway_score(item: dict[str, Any]) -> tuple[int, int, int]:
def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Merge the same account found from different audiences before saving top 3."""
merged: dict[str, dict[str, Any]] = {}
for item in candidates:
account = item.get("account") or {}
@@ -1325,6 +1653,7 @@ def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str,
def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
+ """Normalize weak-point model output into a capped list."""
vulns: list[dict[str, str]] = []
for i in range(1, 21):
vuln = first_present(data.get(f"脆弱点{i}"), data.get(f"vulnerability{i}"), data.get(f"vuln{i}"))
@@ -1339,6 +1668,7 @@ def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
def extract_strategies(data: dict[str, Any], limit: int) -> list[dict[str, Any]]:
+ """Normalize strategy output and preserve the modal text for later mapping."""
strategies: list[dict[str, Any]] = []
for i in range(1, 21):
title = first_present(data.get(f"信息标题{i}"), data.get(f"主题{i}"), data.get(f"topic{i}"))
@@ -1395,11 +1725,29 @@ def response_id(response: Any) -> int | None:
return None
+def response_rows(response: Any) -> list[Any]:
+ """Extract list rows from common CRUD response shapes."""
+ if isinstance(response, list):
+ return response
+ if not isinstance(response, dict):
+ return []
+ for key in ("data", "rows", "items", "records", "list"):
+ value = response.get(key)
+ if isinstance(value, list):
+ return value
+ rows = response_rows(value)
+ if rows:
+ return rows
+ return []
+
+
def save_with_id(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[int | None, Any | None]:
response, _ = http_post_json(ctx, state, endpoint_key, payload, stage)
rid = response_id(response)
if rid is None:
- record_error(state, stage, "保存接口未返回 id,视为失败", request=payload, response=response)
+ record_error(state, stage, "保存接口未返回 ID,视为失败", request=payload, response=response)
+ else:
+ emit_save_feedback(state, stage, endpoint_key, payload, rid, dry_run=ctx.dry_run)
return rid, response
@@ -1429,6 +1777,7 @@ def parse_step_flags(raw: Any) -> dict[str, Any]:
def mark_step_status(ctx: FlowContext, state: dict[str, Any], links: list[str], stage: str) -> None:
+ """Update the frontend step bar without overwriting unrelated step flags."""
if not ctx.sync_step_status:
record_event(state, stage, "跳过 task-action.step 状态同步")
return
@@ -1477,12 +1826,126 @@ def strategy_platform(value: Any) -> Any:
return platform_to_number(value) or 1
-def strategy_model_type(value: Any) -> str:
- # Frontend defaults to text model type "1" when no modal type is selected.
- raw = first_present(value, "1")
- if isinstance(raw, list):
- return ",".join(str(v) for v in raw if str(v).strip()) or "1"
- return str(raw or "1")
+def choice_tokens(value: Any) -> list[Any]:
+ """Split frontend choice values from arrays, JSON strings, and mixed delimiters."""
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return choice_tokens(
+ first_present(value.get("value"), value.get("dictValue"), value.get("label"), value.get("dictLabel"))
+ )
+ if isinstance(value, (list, tuple, set)):
+ tokens: list[Any] = []
+ for item in value:
+ tokens.extend(choice_tokens(item))
+ return tokens
+ text = str(value).strip()
+ if not text:
+ return []
+ if text[0] in "[{":
+ try:
+ parsed = json.loads(text)
+ if parsed != value:
+ return choice_tokens(parsed)
+ except Exception:
+ pass
+ return [part.strip() for part in re.split(r"[,,、;/|]+", text) if part.strip()]
+
+
+def dict_choices(
+ ctx: FlowContext,
+ state: dict[str, Any],
+ dict_type: str,
+ stage: str,
+) -> list[dict[str, Any]]:
+ """Fetch and cache frontend dictionary choices such as modelType."""
+ cache = state.setdefault("_dictChoiceCache", {})
+ if dict_type in cache:
+ cached = cache[dict_type]
+ return cached if isinstance(cached, list) else []
+ if ctx.dry_run:
+ cache[dict_type] = []
+ return []
+
+ query = urllib.parse.urlencode({"filter": f"dictType||$eq||{dict_type}", "limit": 999, "page": 1})
+ url = f"{url_for(ctx, 'dict_data')}?{query}"
+ response, _ = http_request_json_url(ctx, state, "GET", url, None, stage, endpoint_key="dict_data")
+ choices: list[dict[str, Any]] = []
+ for row in response_rows(response):
+ if not isinstance(row, dict):
+ continue
+ label = str(first_present(row.get("dictLabel"), row.get("label"), "") or "").strip()
+ raw_value = first_present(row.get("dictValue"), row.get("value"))
+ if not label or raw_value is None:
+ continue
+ number = as_int(raw_value)
+ choices.append({"label": label, "value": number if number is not None else str(raw_value).strip()})
+ for alias in choice_tokens(row.get("alias")):
+ alias_label = str(alias).strip()
+ if alias_label:
+ choices.append({"label": alias_label, "value": number if number is not None else str(raw_value).strip()})
+ cache[dict_type] = choices
+ return choices
+
+
+def choice_value_by_label(choices: list[dict[str, Any]] | None, label: str) -> Any | None:
+ needle = re.sub(r"\s+", "", str(label or "").lower())
+ if not needle:
+ return None
+ for item in choices or []:
+ item_label = re.sub(r"\s+", "", str(item.get("label") or "").lower())
+ if item_label and (needle == item_label or needle in item_label or item_label in needle):
+ return item.get("value")
+ return None
+
+
+def model_type_token_value(token: Any, choices: list[dict[str, Any]] | None = None) -> Any:
+ """Convert modal labels like 图片/视频 into frontend dictionary values."""
+ number = as_int(token)
+ if number is not None:
+ return number
+
+ text = str(token or "").strip()
+ lower = text.lower()
+ exact = choice_value_by_label(choices, text)
+ if exact is not None:
+ return exact
+
+ label = ""
+ fallback_value = 2
+ if "视" in text or "video" in lower:
+ label, fallback_value = "视频", 3
+ elif "图" in text or "image" in lower or "picture" in lower or "photo" in lower:
+ label, fallback_value = "图片", 2
+ elif "音" in text or "audio" in lower or "sound" in lower:
+ label, fallback_value = "音频", 4
+ elif "发文" in text or "文本" in text or "文字" in text or "text" in lower:
+ label, fallback_value = "文本", 1
+
+ if label:
+ matched = choice_value_by_label(choices, label)
+ if matched is not None:
+ return matched
+ return fallback_value
+
+ # Mirrors the frontend helper's unknown-token default.
+ return 2
+
+
+def strategy_model_type(value: Any, choices: list[dict[str, Any]] | None = None) -> str:
+ """Return the comma-separated value string expected by task-action-message."""
+ tokens = choice_tokens(value)
+ if not tokens:
+ # Frontend save paths fall back to "1" when no modal type is selected.
+ return "1"
+ values: list[str] = []
+ seen: set[str] = set()
+ for token in tokens:
+ normalized = str(model_type_token_value(token, choices)).strip()
+ if normalized and normalized not in seen:
+ values.append(normalized)
+ seen.add(normalized)
+ return ",".join(values) or "1"
def strategy_language(value: Any) -> int:
@@ -1501,11 +1964,28 @@ def save_strategy_details(
*,
audience: bool,
) -> None:
- platform = strategy_platform(first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})))
+ """Save all frontend-readable strategy child rows.
+
+ The strategy table and arrangement page do not read only the main strategy
+ row; they also expect channel, narrate, message, and relation rows.
+ """
+ platform = strategy_platform(
+ first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
+ )
+ account_type = str(
+ first_present(
+ strategy.get("accountType"),
+ item.get("accountType"),
+ item.get("type"),
+ "目标受众账号" if audience else "关键传播节点账号",
+ )
+ or ""
+ ).strip()
persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
title = str(strategy.get("title") or "").strip()
+ model_type_choices = dict_choices(ctx, state, "modelType", stage + "/dict-modelType")
channel_id, _ = save_with_id(
ctx,
@@ -1516,6 +1996,7 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"platform": platform,
+ "accountType": account_type,
"personaJson": json.dumps([{"persona": persona, "size": persona_size}], ensure_ascii=False),
},
stage + "/save-channel",
@@ -1531,7 +2012,10 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"channelId": channel_id,
- "modelType": strategy_model_type(first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType"))),
+ "modelType": strategy_model_type(
+ first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType")),
+ model_type_choices,
+ ),
"language": strategy_language(first_present(strategy.get("language"), strategy.get("语种"))),
"respondingCount": responding_count,
"messageCount": responding_count,
@@ -1564,7 +2048,46 @@ def object_for_audience(audience: dict[str, Any]) -> dict[str, Any]:
}
+def selected_accounts_for_item(item: dict[str, Any], *, audience: bool) -> list[Any]:
+ if audience:
+ accounts = item.get("accounts") or []
+ return accounts or [object_for_audience(item)]
+ return [item.get("account") or item]
+
+
+def downstream_limit(ctx: FlowContext, *, audience: bool) -> int:
+ """Limit expensive downstream analysis while keeping 3 upstream rows stored."""
+ raw = ctx.downstream_audiences if audience else ctx.downstream_pathways
+ return max(1, raw)
+
+
+def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, audience: bool) -> list[dict[str, Any]]:
+ """Select the 1-2 best stored rows for vulnerability/strategy generation."""
+ source_key = "topAudiences" if audience else "topPathways"
+ id_key = "designId" if audience else "pathwayAccountId"
+ selected_key = "selectedDownstreamAudiences" if audience else "selectedDownstreamPathways"
+ items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)]
+ limit = downstream_limit(ctx, audience=audience)
+ selected = items[:limit]
+ state[selected_key] = [
+ {
+ "id": item.get(id_key),
+ "name": item.get("targetAudience") if audience else item.get("accountName"),
+ }
+ for item in selected
+ ]
+ if items:
+ record_event(
+ state,
+ stage,
+ f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}",
+ {"limit": limit, "selected": state[selected_key]},
+ )
+ return selected
+
+
def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze audiences, save top 3, and attach any referenced accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step1-audience"
if state.get("topAudiences") and not ctx.dry_run:
@@ -1582,12 +2105,15 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
state["audienceRawText"] = text
+ # The first model call may return raw text plus embedded account objects.
accounts = accounts_from_response(parsed, objects, text)
state["audienceAccounts"] = accounts
+ # The second model call converts the long audience analysis into numbered
+ # groups that can be saved as task-action-design rows.
group_payload = {
"mubiao": state["mubiao"],
- "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
"zhanghao": accounts,
"history": [],
"stream": True,
@@ -1602,6 +2128,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
top = select_top_audiences(candidates, ctx.max_audiences)
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the audience first; account rows and downstream analyses depend
+ # on the returned designId.
design_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
@@ -1614,6 +2142,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
continue
item = {**item, "designId": design_id, "saveResponse": resp, "accounts": [], "savedAccountIds": []}
for ref in item.get("userIndexes") or []:
+ # Model references are intentionally fuzzy, so resolve them against
+ # both explicit indexes and account names/ids.
account = resolve_account_ref(ref, accounts)
if account is None:
record_error(state, stage + "/save-design-account", f"用户编号 {ref} 未匹配到候选账号")
@@ -1645,6 +2175,7 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze key pathway accounts for saved audiences and store top 3."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step2-pathway"
audiences = [a for a in state.get("topAudiences") or [] if a.get("designId")]
@@ -1656,6 +2187,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
return
all_candidates: list[dict[str, Any]] = []
for audience in audiences:
+ # Analyze pathway candidates in the context of each saved audience.
payload = {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。",
"otherinfo": {
@@ -1673,6 +2205,8 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
accounts = accounts_from_response(parsed, objects, text)
+ # Ask the model to point back to candidate accounts, then resolve those
+ # references to concrete account objects.
spread_payload = {
"mubiao": state["mubiao"],
"user_message": "分析其中关键信息。请从候选账号中抽取核心传播节点,字段必须是:账号1、理由1、账号2、理由2 ...,最多输出5个。账号字段优先返回候选数组的引用编号或索引,只返回JSON。",
@@ -1707,6 +2241,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways]
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the account row before writing the design-pathway relation.
account = item["account"]
aid = account_id(account)
name = account_name(account)
@@ -1757,18 +2292,20 @@ def run_step4(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate and save weak points for selected audiences or pathway accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step3-audience-vulns" if audience else "step4-pathway-vulns"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
output_key = "audienceVulns" if audience else "pathwayVulns"
state.setdefault(output_key, {})
for item in items:
+ # owner_id is designId for audiences and pathwayAccountId for accounts.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
if not owner_id:
continue
if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run:
continue
- zhanghao = item.get("accounts") or [object_for_audience(item)] if audience else [item.get("account") or item]
+ zhanghao = selected_accounts_for_item(item, audience=audience)
prompt = (
"你是中国对台湾方向的认知战专家,你要传播行动要求的叙事主题。请基于该受众目标的画像、关注议题、社会分工定位、利益诉求与上一轮账号佐证,分析该受众目标有哪些脆弱点可以成为后续策略设计的关键要素。请输出理由、脆弱点1、理由1、脆弱点2、理由2、脆弱点3、理由3,只返回JSON。"
if audience
@@ -1789,6 +2326,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) ->
vulns = extract_vulns(data, ctx.max_vulns)
saved: list[dict[str, Any]] = []
for vuln in vulns:
+ # The two weak-point tables have nearly identical payloads but
+ # different foreign-key names.
if audience:
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
@@ -1835,14 +2374,16 @@ def run_step6(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate strategies from saved weak points and persist frontend child rows."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step5-audience-strategy" if audience else "step6-pathway-strategy"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {}
output_key = "audienceStrategies" if audience else "pathwayStrategies"
state.setdefault(output_key, [])
existing_topics = {s.get("topic") for s in state[output_key] if isinstance(s, dict)}
for item in items:
+ # Strategies are only meaningful after the matching weak points exist.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
vulns = vulns_by_owner.get(str(owner_id)) or []
vuln_texts = [v.get("vulnerabilities") for v in vulns if v.get("vulnerabilities")]
@@ -1850,7 +2391,9 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
record_error(state, stage, "没有可用脆弱点,跳过策略生成", request=item)
continue
target_names = [item.get("targetAudience")] if audience else (item.get("relatedAudiences") or [item.get("accountName")])
- pingtai = state.get("platform") or (platform_label(item.get("account") or {}) if not audience else "")
+ zhanghao = selected_accounts_for_item(item, audience=audience)
+ first_account = zhanghao[0] if zhanghao and isinstance(zhanghao[0], dict) else {}
+ pingtai = state.get("platform") or platform_label(first_account)
payload = {
"user_message": DEFAULT_STRATEGY_PROMPT_AUDIENCE if audience else DEFAULT_STRATEGY_PROMPT_PATHWAY,
"cuiruodian": vuln_texts,
@@ -1858,6 +2401,7 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
"shouzhong": [name for name in target_names if name],
"mudi": extract_goal(state["mubiao"]),
"heiliao": [],
+ "zhanghao": zhanghao,
"history": [],
"stream": True,
"model_name": "OpenAI3",
@@ -1876,16 +2420,20 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
continue
if topic in existing_topics:
continue
+ # Save the main row first; channel/narrate/message/relation rows all
+ # require the returned strategy id.
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
"targetAudience": target,
"accountType": account_type,
"topic": topic,
- "strategyTitle": "",
+ "strategyTitle": str(strategy.get("title") or "").strip(),
}
strategy_id, resp = save_with_id(ctx, state, "save_strategy", save_payload, stage + "/save")
if strategy_id is not None:
+ # Frontend strategy pages read these child tables, so a strategy
+ # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience)
existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp})
@@ -1914,13 +2462,14 @@ STEP_LABELS = {
def state_counts(state: dict[str, Any]) -> dict[str, int]:
+ """Summarize stored objects for step feedback."""
return {
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceVulns": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
- "pathwayVulns": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "受众脆弱点数量": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
+ "传播节点脆弱点数量": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
}
@@ -1932,23 +2481,26 @@ def emit_step_feedback(
status: str | None = None,
note: str = "",
) -> None:
+ # One feedback line per step keeps the single-skill and split-skill versions
+ # equally transparent to the user.
total_errors = len(state.get("errors") or [])
new_errors = max(0, total_errors - before_error_count)
final_status = status or ("partial" if new_errors else "ok")
payload = {
- "type": "STEP_FEEDBACK",
- "step": step,
- "label": STEP_LABELS.get(step, step),
- "status": final_status,
- "note": note,
- "newErrors": new_errors,
- "totalErrors": total_errors,
- "counts": state_counts(state),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "updatedAt": state.get("updatedAt"),
+ "日志类型": "步骤反馈",
+ "步骤": STEP_NUMBER_LABELS.get(step, step),
+ "步骤名称": STEP_LABELS.get(step, step),
+ "状态": STATUS_LABELS.get(final_status, final_status),
+ "执行状态": status_label(final_status),
+ "备注": note,
+ "本步骤新增错误数": new_errors,
+ "累计错误数": total_errors,
+ "累计入库数量": state_counts(state),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "更新时间": state.get("updatedAt"),
}
- print("STEP_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["step"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def finish_step(
@@ -1960,6 +2512,8 @@ def finish_step(
status: str | None = None,
note: str = "",
) -> None:
+ # Always persist before emitting step feedback so the user can inspect files
+ # immediately after a reported step completes.
state["updatedAt"] = utc_now()
save_json_file(ctx.state_path, state)
if ctx.summary_path:
@@ -1980,6 +2534,11 @@ def skip_step_with_feedback(ctx: FlowContext, state: dict[str, Any], step: str,
def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Legacy full runner.
+
+ The wrapper script is preferred for real-time logs, but this remains useful
+ for dry-runs and unattended local troubleshooting.
+ """
run_step_with_feedback(ctx, state, "step1")
run_step_with_feedback(ctx, state, "step2")
run_step_with_feedback(ctx, state, "step3")
@@ -1995,6 +2554,7 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
def write_summary(path: Path, state: dict[str, Any]) -> None:
+ """Write a human-readable checkpoint beside the machine state JSON."""
audience_vuln_count = sum(len(v) for v in (state.get("audienceVulns") or {}).values())
pathway_vuln_count = sum(len(v) for v in (state.get("pathwayVulns") or {}).values())
lines = [
@@ -2005,6 +2565,8 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
f"- actionId:{state.get('actionId')}",
f"- 受众入库:{len(state.get('topAudiences') or [])}",
f"- 传播节点入库:{len(state.get('topPathways') or [])}",
+ f"- 下游受众选择:{len(state.get('selectedDownstreamAudiences') or [])}",
+ f"- 下游传播节点选择:{len(state.get('selectedDownstreamPathways') or [])}",
f"- 受众脆弱点入库:{audience_vuln_count}",
f"- 传播节点脆弱点入库:{pathway_vuln_count}",
f"- 受众策略入库:{len(state.get('audienceStrategies') or [])}",
@@ -2021,9 +2583,9 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
if state.get("errors"):
lines += ["", "## 失败明细"]
for err in state["errors"]:
- lines.append(f"- [{err.get('stage')}] {err.get('message')} status={err.get('status')} url={err.get('url')}")
+ lines.append(f"- [{stage_label(err.get('stage'))}] {err.get('message')} 状态码={err.get('status')} 接口={err.get('url')}")
if err.get("response"):
- lines.append(f" - response: {err.get('response')}")
+ lines.append(f" - 响应摘要:{err.get('response')}")
text = "\n".join(lines) + "\n"
try:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -2034,34 +2596,34 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
def build_arg_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(description="Run xdfx analysis/storage flow.")
+ parser = argparse.ArgumentParser(description="运行 xdfx 分析与入库流程。")
parser.add_argument("--mode", choices=["full", *STEP_FUNCS.keys()], default="full")
- parser.add_argument("--input", help="JSON config file. Keys: api_base, taskId, actionId, mubiao, direction, platform, headers.")
- parser.add_argument("--state", default="xdfx-flow-state.json", help="State JSON path for resume and step handoff.")
- parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown summary output path.")
+ parser.add_argument("--input", help="JSON 配置文件。常用字段:api_base、taskId、actionId、mubiao、direction、platform、headers。")
+ parser.add_argument("--state", default="xdfx-flow-state.json", help="用于断点续跑和步骤交接的状态 JSON 文件路径。")
+ parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown 执行摘要输出路径。")
parser.add_argument("--api-base")
parser.add_argument("--task-id")
parser.add_argument("--action-id")
parser.add_argument("--mubiao")
parser.add_argument("--direction", default=None)
parser.add_argument("--platform", default=None)
- parser.add_argument("--auth-token", default=os.getenv("XDFX_AUTH_TOKEN"))
- parser.add_argument("--authorization", default=os.getenv("AUTHORIZATION"))
- parser.add_argument("--header", action="append", default=[], help="Extra HTTP header, format 'Key: Value'.")
- parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="Username for automatic login when Authorization is missing or stale.")
- parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="Password query parameter for automatic username login.")
- parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="Login endpoint path or absolute URL.")
- parser.add_argument("--no-auto-login", action="store_true", help="Disable automatic username login and 401 token refresh.")
- parser.add_argument("--verify-ssl", action="store_true", help="Verify HTTPS certificates. Default is disabled for internal/UAT certificates.")
+ parser.add_argument("--header", action="append", default=[], help="附加非鉴权 HTTP 请求头,格式为 'Key: Value';Authorization 会被忽略。")
+ parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="xdfx_auth 启动阶段自动登录使用的用户名。")
+ parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="登录接口 JSON 请求体中的 password。")
+ parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/auth/login"), help="登录接口路径或完整 URL。")
+ parser.add_argument("--no-auto-login", action="store_true", help="关闭 xdfx_auth 自动登录和 401 刷新。")
+ parser.add_argument("--verify-ssl", action="store_true", help="校验 HTTPS 证书。默认不校验,以兼容内网/UAT 自签证书。")
parser.add_argument("--timeout", type=float, default=90)
parser.add_argument("--retries", type=int, default=2)
parser.add_argument("--dry-run", action="store_true")
- parser.add_argument("--no-step-status", action="store_true", help="Do not update task/task-action.step after completed steps.")
- parser.add_argument("--model-retries", type=int, default=1, help="Retry model/stream endpoints when the first response is empty, timeout-like, or says the model did not respond.")
+ parser.add_argument("--no-step-status", action="store_true", help="步骤完成后不更新 task/task-action.step。")
+ parser.add_argument("--model-retries", type=int, default=1, help="模型/流式接口首次空响应、超时或提示模型无响应时的重试次数。")
parser.add_argument("--max-audiences", type=int, default=3)
parser.add_argument("--max-pathways", type=int, default=3)
parser.add_argument("--max-vulns", type=int, default=3)
parser.add_argument("--max-strategies", type=int, default=3)
+ parser.add_argument("--downstream-audiences", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库受众数量,默认 2。")
+ parser.add_argument("--downstream-pathways", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库传播节点数量,默认 2。")
return parser
@@ -2070,6 +2632,8 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
state_path = Path(args.state)
state = default_state()
+ # Load previous state first, then overlay input JSON and finally CLI flags.
+ # That ordering makes resume safe while still allowing explicit overrides.
if state_path.exists():
loaded = load_json_file(state_path)
if isinstance(loaded, dict):
@@ -2081,9 +2645,7 @@ def main(argv: list[str] | None = None) -> int:
merge_config_into_state(state, config)
input_headers = config.get("headers")
if isinstance(input_headers, dict):
- args.header.extend(f"{k}: {v}" for k, v in input_headers.items())
- args.auth_token = args.auth_token or config.get("auth_token")
- args.authorization = args.authorization or config.get("authorization")
+ args.header.extend(f"{k}: {v}" for k, v in input_headers.items() if str(k).lower() != "authorization")
if config.get("login_username") is not None:
args.login_username = str(config.get("login_username") or "")
if config.get("login_password") is not None:
@@ -2094,6 +2656,10 @@ def main(argv: list[str] | None = None) -> int:
args.no_auto_login = True
if config.get("verify_ssl") is not None:
args.verify_ssl = bool(config.get("verify_ssl"))
+ if config.get("downstream_audiences") is not None:
+ args.downstream_audiences = as_int(config.get("downstream_audiences")) or args.downstream_audiences
+ if config.get("downstream_pathways") is not None:
+ args.downstream_pathways = as_int(config.get("downstream_pathways")) or args.downstream_pathways
cli_config = {
"api_base": args.api_base,
@@ -2105,7 +2671,8 @@ def main(argv: list[str] | None = None) -> int:
}
merge_config_into_state(state, {k: v for k, v in cli_config.items() if v is not None})
api_base = state.get("api_base") or "https://ch1.b.uat.4.cn"
- headers = parse_headers(args.header, args.auth_token, args.authorization)
+ headers = parse_headers(args.header)
+ # ctx owns runtime knobs; state owns business data and resumable ids.
ctx = FlowContext(
api_base=api_base,
headers=headers,
@@ -2118,6 +2685,8 @@ def main(argv: list[str] | None = None) -> int:
max_pathways=max(args.max_pathways, 1),
max_vulns=max(args.max_vulns, 1),
max_strategies=max(args.max_strategies, 1),
+ downstream_audiences=max(args.downstream_audiences, 1),
+ downstream_pathways=max(args.downstream_pathways, 1),
sync_step_status=not args.no_step_status,
model_retries=max(args.model_retries, 0),
verify_ssl=bool(args.verify_ssl),
@@ -2131,11 +2700,17 @@ def main(argv: list[str] | None = None) -> int:
state["_summaryPath"] = str(ctx.summary_path) if ctx.summary_path else ""
state["_autoLogin"] = ctx.auto_login
state["_verifySsl"] = ctx.verify_ssl
+ state["_downstreamLimits"] = {
+ "audiences": ctx.downstream_audiences,
+ "pathways": ctx.downstream_pathways,
+ }
record_event(state, "runner", f"start mode={args.mode} dry_run={ctx.dry_run}")
try:
require_context(state, "taskId", "actionId", "mubiao")
- if ctx.auto_login and not has_authorization(ctx.headers):
- try_auto_login(ctx, state, "runner/auto-login")
+ if ctx.auto_login and not ctx.dry_run:
+ # Always acquire a token through the imported auth package before the first business call.
+ if not try_auto_login(ctx, state, "runner/initial-login", force=True):
+ raise FlowError("启动阶段获取 token 失败,已停止执行,避免无 token 调用业务接口")
if args.mode == "full":
run_full(ctx, state)
else:
@@ -2145,13 +2720,14 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(f"ERROR: {exc}", file=sys.stderr)
+ print(f"错误:{exc}", file=sys.stderr)
return 2
except Exception as exc: # noqa: BLE001
record_error(state, "runner", "未捕获异常", response=traceback.format_exc(), exception=exc)
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
+ print("未捕获异常堆栈:", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
return 1
@@ -2159,20 +2735,23 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(json.dumps(
- {
- "ok": True,
- "mode": args.mode,
- "state": str(state_path),
- "summary": str(ctx.summary_path) if ctx.summary_path else "",
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
- "errors": len(state.get("errors") or []),
- },
- ensure_ascii=False,
- ))
+ # Final JSON is intentionally compact; detailed per-step status has already
+ # been emitted as Chinese step feedback.
+ final_payload = {
+ "日志类型": "执行完成",
+ "是否成功": "是",
+ "执行模式": mode_label(args.mode),
+ "状态文件": str(state_path),
+ "摘要文件": str(ctx.summary_path) if ctx.summary_path else "",
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "已选择受众数": len(state.get("selectedDownstreamAudiences") or []),
+ "已选择传播节点数": len(state.get("selectedDownstreamPathways") or []),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
+ "错误数": len(state.get("errors") or []),
+ }
+ print(LOG_PREFIXES["done"] + " " + json.dumps(final_payload, ensure_ascii=False))
return 0
diff --git a/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_run_full_steps.py b/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_run_full_steps.py
new file mode 100644
index 00000000..d6468b51
--- /dev/null
+++ b/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_run_full_steps.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+"""Run the xdfx flow one step at a time.
+
+The heavy business logic intentionally stays in xdfx_flow.py. This wrapper only
+keeps "full flow" orchestration readable and preserves per-step feedback: every
+subprocess line is flushed immediately, so OP/SAVE/ERROR/STEP feedback remains
+visible to the calling agent.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+
+STEPS = ("step1", "step2", "step3", "step4", "step5", "step6")
+
+STEP_LABELS = {
+ "step1": "受众目标分析与入库",
+ "step2": "关键传播节点分析与入库",
+ "step3": "受众脆弱点分析与入库",
+ "step4": "传播节点脆弱点分析与入库",
+ "step5": "受众策略分析与入库",
+ "step6": "传播节点策略分析与入库",
+}
+
+
+def build_parser() -> argparse.ArgumentParser:
+ """Parse only wrapper-owned flags; pass all other flags to xdfx_flow.py."""
+ parser = argparse.ArgumentParser(
+ description="按六个步骤运行 xdfx_flow.py,并实时转发中文反馈日志。",
+ add_help=True,
+ )
+ parser.add_argument("--from-step", choices=STEPS, default="step1")
+ parser.add_argument("--to-step", choices=STEPS, default="step6")
+ parser.add_argument(
+ "--continue-on-error",
+ action="store_true",
+ help="某一步返回非零退出码时仍继续执行后续步骤。",
+ )
+ return parser
+
+
+def selected_steps(first: str, last: str) -> list[str]:
+ """Return an inclusive step slice for resume/partial reruns."""
+ start = STEPS.index(first)
+ end = STEPS.index(last)
+ if start > end:
+ raise SystemExit("起始步骤必须小于或等于结束步骤")
+ return list(STEPS[start : end + 1])
+
+
+def stream_command(step: str, cmd: list[str]) -> int:
+ """Run one step and forward stdout/stderr lines as they are produced."""
+ print(f"运行步骤:{STEP_LABELS.get(step, step)};执行命令:" + " ".join(cmd), flush=True)
+ env = os.environ.copy()
+ env["PYTHONIOENCODING"] = "utf-8"
+ process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ env=env,
+ )
+ assert process.stdout is not None
+ for line in process.stdout:
+ sys.stdout.buffer.write(line.encode("utf-8", errors="replace"))
+ sys.stdout.buffer.flush()
+ return process.wait()
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args, passthrough = parser.parse_known_args(argv)
+ flow_script = Path(__file__).with_name("xdfx_flow.py")
+ exit_code = 0
+ for step in selected_steps(args.from_step, args.to_step):
+ # Keep all business flags unchanged so the wrapper behaves like a thin
+ # streaming version of repeated xdfx_flow.py --mode stepN calls.
+ cmd = [sys.executable, str(flow_script), "--mode", step, *passthrough]
+ code = stream_command(step, cmd)
+ if code:
+ exit_code = code
+ if not args.continue_on_error:
+ return code
+ return exit_code
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/skills-data/xdfx-split-controller/SKILL.md b/skills-data/xdfx-split-controller/SKILL.md
index f9444d5b..1ee180ab 100644
--- a/skills-data/xdfx-split-controller/SKILL.md
+++ b/skills-data/xdfx-split-controller/SKILL.md
@@ -28,7 +28,7 @@ scripts/xdfx_flow.py
## 推荐执行顺序
-默认会自动处理登录态:当命令没有传 `Authorization`,或某个接口返回 401 时,脚本会用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/v1/auth/login/username?password=ch%40user`,请求体为 `{"username":"admin"}`。拿到 `access_token` 后写入 `Authorization: Bearer ...` 并重试原接口一次。默认关闭 HTTPS 证书校验以兼容 UAT/内网自签证书;如果环境证书链正常,可以追加 `--verify-ssl`。如果已有可用 token,可追加 `--auth-token TOKEN` 或 `--header "Authorization: Bearer TOKEN"`。
+默认会自动处理登录态:脚本启动时先 `import scripts/xdfx_auth.py`,用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/auth/login`,请求体为 `{"username":"admin","password":"ch@user"}`。拿到 `access_token` 后写入内部 `Authorization: Bearer ...`,再调用第一步业务接口;不要通过任何命令行参数传 token。401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次。默认关闭 HTTPS 证书校验以兼容 UAT/内网自签证书;如果环境证书链正常,可以追加 `--verify-ssl`。
总控按顺序执行下面 6 条命令,所有步骤共用同一个 `xdfx-flow-state.json`:
@@ -41,13 +41,23 @@ python scripts/xdfx_flow.py --mode step5 --input xdfx-input.json --state xdfx-fl
python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
-每条命令结束都会输出一行 `STEP_FEEDBACK {...}`。总控必须在每个 step 命令结束后立即反馈用户,再继续下一步。
+每条命令结束都会输出一行 `步骤反馈 {...}`。总控必须在每个 step 命令结束后立即反馈用户,再继续下一步。
-每次接口调用前会输出 `OP_FEEDBACK {...}`。总控必须告诉用户当前正在做什么、调用哪个接口。
+每次接口调用前会输出 `操作反馈 {...}`。总控必须告诉用户当前正在做什么、调用哪个接口。
-如果接口或模型处理报错,会立即输出 `ERROR_FEEDBACK {...}`。总控必须立刻把失败阶段、URL、状态码、请求摘要和响应摘要告诉用户。
+每次保存接口成功拿到返回 ID 后会输出 `入库反馈 {...}`。总控必须告诉用户已入库的对象类型、名称和返回 ID,例如“已入库关键传播节点:xxx,pathwayAccountId=123”。
-如果模型返回文本经过自动修复仍无法解析,会输出 `JSON_PARSE_REVIEW {...}`。总控必须查看响应片段,判断是否需要重跑当前 step 或重新调用对应接口。
+如果接口或模型处理报错,会立即输出 `错误反馈 {...}`。总控必须立刻把失败阶段、URL、状态码、请求摘要和响应摘要告诉用户。
+
+如果模型返回文本经过自动修复仍无法解析,会输出 `JSON解析复核 {...}`。总控必须查看响应片段,判断是否需要重跑当前 step 或重新调用对应接口。
+
+## 失败重试规则
+
+- 任意 step 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,总控必须先重跑当前失败的同一条 `--mode stepN` 命令。
+- 重试前必须告诉用户失败阶段、接口 URL、状态码、响应摘要和“正在重试当前步骤”,不能静默重试。
+- 当前 step 未重试成功前,不允许继续执行下一 step;不要因为某一步失败就跳过进入后续分析或入库。
+- 同一 step 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
+- 只有用户明确要求忽略该失败,才可以继续后续 step;回复中必须说明这是用户确认后的带风险继续。
如果只想一条命令跑完,也可以使用下面命令做无人值守兜底,但不建议作为默认方式,因为外层工具通常会等命令结束后才返回输出,无法保证用户看到实时反馈:
@@ -55,21 +65,32 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
python scripts/xdfx_flow.py --mode full --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
+更推荐的一条命令方式是使用包装脚本;它内部仍按 step1-step6 分步调用,并逐行转发反馈:
+
+```bash
+python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
+```
+
## 断点续跑
- Step1 成功后会写入 `topAudiences`。
+- Step1 的受众目标抽取结果里,`用户编号列表` / `编号id` / `id` 必须对应上一轮 `zhanghao` 账号数组的索引值;脚本会按这个索引依次找到账号并保存受众账号关系。
- Step2 成功后会写入 `topPathways`。
-- Step3/Step4 会写入两类脆弱点。
-- Step5/Step6 会写入两类策略。
+- Step1 默认入库最优 3 个受众;Step2 默认入库最优 3 个传播节点。
+- Step3/Step4 默认只选择前 2 个已入库受众/传播节点继续,每个生成最多 3 个脆弱点;可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。
+- Step5/Step6 默认对上述选中的每个受众/传播节点生成最多 3 个策略。
- 各步骤会同步前端 `task/task-action.step`;需要禁用时每条命令追加 `--no-step-status`。
- 策略步骤会同步写入 channel、narrate、message、策略与受众/传播节点关系表,保证原前端策略表格/编排页可继续读取。
-- 每个步骤完成都会输出 `STEP_FEEDBACK`,总控必须逐步转述给用户。
-- 每次操作和接口调用都会输出 `OP_FEEDBACK`,总控必须逐条转述当前操作。
-- 每次错误都会输出 `ERROR_FEEDBACK`,总控必须立即报告错误细节。
-- 每次 JSON 彻底解析失败都会输出 `JSON_PARSE_REVIEW`,总控必须复核响应片段并判断是否重试当前 step。
+- 策略步骤保存 `信息形式/模态` 时,必须使用脚本读取 `sys/sys-dict-data` 的 `modelType` 字典并把文字转成 value 逗号串,不能把“图片,视频”等文字直接写入 `task-action-message.modelType`。
+- 每个步骤完成都会输出 `步骤反馈`,总控必须逐步转述给用户。
+- 每次操作和接口调用都会输出 `操作反馈`,总控必须逐条转述当前操作。
+- 每次保存成功都会输出 `入库反馈`,总控必须逐条转述入库对象。
+- 每次错误都会输出 `错误反馈`,总控必须立即报告错误细节。
+- 每次 JSON 彻底解析失败都会输出 `JSON解析复核`,总控必须复核响应片段并判断是否重试当前 step。
- 每个步骤里的流式模型接口第一次空响应、返回 `y模型没有响应`/`模型没有响应` 或超时类字符串时,会自动再调用一次;仍失败才写入 `errors`。
-- 某一步失败后,修正问题再重跑同一个 `--mode stepN`。
+- 某一步失败后,必须先重跑同一个 `--mode stepN`;重试仍失败再等待用户处理或确认,不能自动跳到后续步骤。
## 汇报规则
-执行过程中按 `OP_FEEDBACK` 告诉用户当前操作和接口;按 `ERROR_FEEDBACK` 立即报告错误;每个 step 结束后再根据 `STEP_FEEDBACK` 回复用户一次。全部步骤结束后,读取 `xdfx-flow-summary.md` 和 `xdfx-flow-state.json.errors` 再做最终汇总。只要存在错误,就明确说“部分完成”,并列出失败阶段、接口 URL、状态码和响应摘要。
+执行过程中按 `操作反馈` 告诉用户当前操作和接口;按 `入库反馈` 告诉用户已入库什么对象和返回 ID;按 `错误反馈` 立即报告错误;每个步骤结束后再根据 `步骤反馈` 回复用户一次。全部步骤结束后,读取 `xdfx-flow-summary.md` 和 `xdfx-flow-state.json.errors` 再做最终汇总。只要存在错误,就明确说“部分完成”,并列出失败阶段、接口 URL、状态码和响应摘要。
+
diff --git a/skills-data/xdfx-split-controller/scripts/xdfx_auth.py b/skills-data/xdfx-split-controller/scripts/xdfx_auth.py
new file mode 100644
index 00000000..4003e55d
--- /dev/null
+++ b/skills-data/xdfx-split-controller/scripts/xdfx_auth.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+"""Authentication helper for xdfx skill scripts.
+
+The caller should not pass tokens through command-line arguments. This module
+owns the fixed admin login flow and returns a Bearer token before business
+interfaces are called.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import ssl
+import urllib.error
+import urllib.request
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass
+class AuthResult:
+ token: str
+ url: str
+ status: int | None
+ raw: str
+
+
+class AuthError(RuntimeError):
+ def __init__(
+ self,
+ message: str,
+ *,
+ url: str,
+ status: int | None = None,
+ response: str = "",
+ exception: BaseException | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.url = url
+ self.status = status
+ self.response = response
+ self.exception = exception
+
+
+def auth_header_value(token: str) -> str:
+ token = token.strip()
+ return token if token.lower().startswith("bearer ") else f"Bearer {token}"
+
+
+def redact_url(url: str) -> str:
+ return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
+
+
+def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
+ if verify_ssl:
+ return None
+ return ssl._create_unverified_context()
+
+
+def build_login_url(api_base: str, path: str) -> str:
+ return path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
+
+
+def extract_access_token(data: Any) -> str | None:
+ if isinstance(data, dict):
+ for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
+ value = data.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ for value in data.values():
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ elif isinstance(data, list):
+ for value in data:
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ return None
+
+
+def parse_json_loose(text: str) -> Any:
+ try:
+ return json.loads(text)
+ except Exception:
+ pass
+ match = re.search(r"[\[{]", text or "")
+ if not match:
+ return {}
+ start = match.start()
+ for end in range(len(text), start, -1):
+ try:
+ return json.loads(text[start:end])
+ except Exception:
+ continue
+ return {}
+
+
+def fetch_access_token(
+ *,
+ api_base: str,
+ username: str = "admin",
+ password: str = "ch@user",
+ login_path: str = "/api/auth/login",
+ timeout: float = 90,
+ verify_ssl: bool = False,
+) -> AuthResult:
+ """Login once and return the extracted access token."""
+ url = build_login_url(api_base, login_path)
+ payload = {"username": username, "password": password}
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ parsed = parse_json_loose(raw)
+ token = extract_access_token(parsed)
+ if not token:
+ raise AuthError(
+ "登录接口已响应但未找到 access_token",
+ url=redact_url(url),
+ status=getattr(resp, "status", None),
+ response=raw,
+ )
+ return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
+ raise AuthError(
+ "登录接口返回错误",
+ url=redact_url(url),
+ status=exc.code,
+ response=body,
+ exception=exc,
+ ) from exc
+ except AuthError:
+ raise
+ except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
+ raise AuthError(
+ "登录接口调用异常",
+ url=redact_url(url),
+ response=str(exc),
+ exception=exc,
+ ) from exc
diff --git a/skills-data/xdfx-split-controller/scripts/xdfx_flow.py b/skills-data/xdfx-split-controller/scripts/xdfx_flow.py
index 52880e82..d9580e2f 100644
--- a/skills-data/xdfx-split-controller/scripts/xdfx_flow.py
+++ b/skills-data/xdfx-split-controller/scripts/xdfx_flow.py
@@ -26,7 +26,10 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
+import xdfx_auth
+
+# Keep every backend path in one table so endpoint drift is easy to audit.
ENDPOINTS = {
"aiuser": "/gpt/chat/chat_aiuser",
"group": "/gpt/chat/chat_qianqian_group_chat",
@@ -44,10 +47,12 @@ ENDPOINTS = {
"save_narrate": "/api/base/base-narrate",
"save_message": "/api/task/task-action-message",
"save_strategy_relation": "/api/task/task-action-design-strategy-relation",
+ "dict_data": "/api/sys/sys-dict-data",
}
+# Human-readable names are used only in the Chinese feedback logs.
ENDPOINT_LABELS = {
- "login": "登录获取访问 token",
+ "login": "登录获取访问令牌",
"aiuser": "调用候选账号/受众分析模型接口",
"group": "调用受众目标结构化抽取接口",
"spread": "调用关键传播节点结构化抽取接口",
@@ -64,8 +69,90 @@ ENDPOINT_LABELS = {
"save_narrate": "保存策略信息主题",
"save_message": "保存策略消息配置",
"save_strategy_relation": "保存策略与受众/传播节点关系",
+ "dict_data": "读取前端字典数据",
}
+LOG_PREFIXES = {
+ "operation": "操作反馈",
+ "error": "错误反馈",
+ "json_review": "JSON解析复核",
+ "save": "入库反馈",
+ "step": "步骤反馈",
+ "done": "执行完成",
+}
+
+STATUS_LABELS = {
+ "ok": "成功",
+ "partial": "部分完成",
+ "skipped": "已跳过",
+}
+
+STEP_NUMBER_LABELS = {
+ "step1": "第一步",
+ "step2": "第二步",
+ "step3": "第三步",
+ "step4": "第四步",
+ "step5": "第五步",
+ "step6": "第六步",
+}
+
+HTTP_METHOD_LABELS = {
+ "GET": "读取(GET)",
+ "POST": "提交(POST)",
+ "PUT": "更新(PUT)",
+ "PATCH": "局部更新(PATCH)",
+ "DELETE": "删除(DELETE)",
+}
+
+STAGE_SEGMENT_LABELS = {
+ "analyze": "模型分析",
+ "extract": "结构化抽取",
+ "save": "保存数据",
+ "read": "读取数据",
+ "status": "同步状态",
+}
+
+REQUEST_FIELD_LABELS = {
+ "history": "历史消息",
+ "max_tokens": "最大生成令牌数",
+ "model_name": "模型名称",
+ "otherinfo": "其他信息",
+ "prompt_name": "提示词名称",
+ "stream": "是否流式",
+ "temperature": "温度",
+ "usermessage": "用户消息",
+ "user_message": "用户消息",
+ "username": "登录用户名",
+ "password": "登录密码",
+ "mubiao": "行动目标",
+ "zhanghao": "账号列表",
+ "taskId": "任务ID",
+ "actionId": "行动ID",
+ "taskActionId": "行动ID",
+ "designId": "受众ID",
+ "pathwayId": "传播节点ID",
+ "strategyId": "策略ID",
+ "accountId": "账号ID",
+ "accountName": "账号名称",
+ "accountType": "账号类型",
+ "targetAudience": "受众目标",
+ "vulnerabilities": "脆弱点",
+ "topic": "信息标题",
+ "narrate": "叙事主题",
+ "platform": "平台",
+ "persona": "发布人设",
+ "modelType": "信息形式",
+ "quantity": "数量",
+ "publishTime": "发布时间段",
+ "reason": "理由",
+ "ids": "ID列表",
+ "id": "ID",
+ "step": "步骤状态",
+}
+
+SENSITIVE_KEY_PARTS = ("password", "token", "authorization", "access_token", "accesstoken", "authtoken")
+
+# Platform values mirror the frontend dictionaries used by xdfx strategy pages.
PLATFORM_MAP = {
"facebook": 1,
"fb": 1,
@@ -78,6 +165,8 @@ PLATFORM_MAP = {
"yt": 4,
}
+# The model service sometimes returns a successful HTTP response with these
+# strings instead of useful content. Treat them like transient failures.
MODEL_RETRY_MARKERS = (
"y模型没有响应",
"模型没有响应",
@@ -118,6 +207,12 @@ class FlowError(RuntimeError):
@dataclass
class FlowContext:
+ """Runtime options shared by every step.
+
+ Keep this object boring and serializable-ish: it is passed through every
+ helper, while durable progress belongs in the state JSON file.
+ """
+
api_base: str
headers: dict[str, str]
timeout: float
@@ -129,13 +224,15 @@ class FlowContext:
max_pathways: int = 3
max_vulns: int = 3
max_strategies: int = 3
+ downstream_audiences: int = 2
+ downstream_pathways: int = 2
sync_step_status: bool = True
model_retries: int = 1
verify_ssl: bool = False
auto_login: bool = True
login_username: str = "admin"
login_password: str = "ch@user"
- login_path: str = "/api/v1/auth/login/username"
+ login_path: str = "/api/auth/login"
login_attempt_count: int = 0
@@ -153,7 +250,38 @@ def compact(value: Any, limit: int = 1400) -> str:
text = repr(value)
if len(text) <= limit:
return text
- return text[:limit] + f"... "
+ return text[:limit] + f"... <已截断 {len(text) - limit} 个字符>"
+
+
+def is_sensitive_key(key: Any) -> bool:
+ normalized = re.sub(r"[^a-z0-9_]", "", str(key or "").lower())
+ return any(part in normalized for part in SENSITIVE_KEY_PARTS)
+
+
+def redact_sensitive(value: Any) -> Any:
+ if isinstance(value, dict):
+ return {key: "***" if is_sensitive_key(key) else redact_sensitive(item) for key, item in value.items()}
+ if isinstance(value, list):
+ return [redact_sensitive(item) for item in value]
+ if isinstance(value, tuple):
+ return tuple(redact_sensitive(item) for item in value)
+ if isinstance(value, str):
+ text = re.sub(r"(?i)(Bearer\s+)[^\s\",}]+", r"\1***", value)
+ text = re.sub(
+ r"(?i)(\"(?:password|token|authToken|access_token|accessToken|authorization)\"\s*:\s*)\"[^\"]*\"",
+ r'\1"***"',
+ text,
+ )
+ return re.sub(
+ r"(?i)((?:password|token|authToken|access_token|accessToken|authorization)=)[^&\s]+",
+ r"\1***",
+ text,
+ )
+ return value
+
+
+def safe_compact(value: Any, limit: int = 1400) -> str:
+ return compact(redact_sensitive(value), limit)
def stable_json(value: Any) -> str:
@@ -223,6 +351,11 @@ def save_json_file(path: str | Path, value: Any) -> None:
def default_state() -> dict[str, Any]:
+ """Create a resumable state container.
+
+ The script can be run as six independent commands. IDs returned by one step
+ are persisted here so later steps can resume without redoing earlier saves.
+ """
return {
"api_base": "",
"taskId": "",
@@ -247,6 +380,7 @@ def default_state() -> dict[str, Any]:
def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> None:
+ # Accept both frontend-style camelCase and CLI-friendly snake_case inputs.
aliases = {
"task_id": "taskId",
"action_id": "actionId",
@@ -260,16 +394,12 @@ def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> No
continue
if dest in {"taskId", "actionId"}:
state[dest] = str(value)
- elif dest in state or dest in {
- "api_base",
- "headers",
- "auth_token",
- "authorization",
- }:
+ elif dest in state or dest in {"api_base", "headers"}:
state[dest] = value
def record_event(state: dict[str, Any], stage: str, message: str, data: Any | None = None) -> None:
+ """Append a compact diagnostic event to the state file."""
state.setdefault("events", []).append(
{
"time": utc_now(),
@@ -286,9 +416,13 @@ def feedback_step(stage: str) -> str:
return match.group(1) if match else ""
+def feedback_step_label(stage: str) -> str:
+ return STEP_NUMBER_LABELS.get(feedback_step(stage), "")
+
+
def request_keys(payload: Any) -> list[str]:
if isinstance(payload, dict):
- return sorted(str(key) for key in payload.keys())
+ return sorted(REQUEST_FIELD_LABELS.get(str(key), str(key)) for key in payload.keys())
return []
@@ -306,6 +440,77 @@ def endpoint_label(endpoint_key: str | None, method: str, url: str) -> str:
return f"调用接口 {method}"
+def method_label(method: str) -> str:
+ return HTTP_METHOD_LABELS.get(str(method).upper(), str(method))
+
+
+def yes_no(value: bool) -> str:
+ return "是" if value else "否"
+
+
+def stage_label(stage: Any) -> str:
+ text = str(stage or "")
+ if not text:
+ return ""
+ if text in STEP_LABELS:
+ return STEP_LABELS[text]
+ if text == "runner":
+ return "流程执行器"
+ parts = text.split("/")
+ base = parts[0]
+ match = re.search(r"(step[1-6])", base)
+ labels: list[str] = []
+ if match:
+ labels.append(STEP_LABELS.get(match.group(1), match.group(1)))
+ elif base == "runner":
+ labels.append("流程执行器")
+ else:
+ labels.append(base)
+ for segment in parts[1:]:
+ normalized = segment.replace("-", "_")
+ attempt = re.fullmatch(r"model-call-attempt-(\d+)", segment)
+ json_attempt = re.fullmatch(r"model-json-attempt-(\d+)", segment)
+ if attempt:
+ labels.append(f"模型接口第 {attempt.group(1)} 次尝试")
+ elif json_attempt:
+ labels.append(f"JSON解析第 {json_attempt.group(1)} 次尝试")
+ elif segment == "auto-login":
+ labels.append("自动登录")
+ elif segment in STAGE_SEGMENT_LABELS:
+ labels.append(STAGE_SEGMENT_LABELS[segment])
+ elif normalized in ENDPOINT_LABELS:
+ labels.append(ENDPOINT_LABELS[normalized])
+ else:
+ labels.append(segment)
+ return " / ".join(labels)
+
+
+def status_label(status: Any) -> str:
+ if status is None:
+ return ""
+ return STATUS_LABELS.get(str(status), str(status))
+
+
+def mode_label(mode: str) -> str:
+ if mode == "full":
+ return "全流程"
+ return STEP_LABELS.get(mode, mode)
+
+
+def exception_type_label(exception_type: Any) -> str:
+ name = str(exception_type or "")
+ labels = {
+ "HTTPError": "HTTP 调用异常",
+ "URLError": "网络地址异常",
+ "TimeoutError": "请求超时异常",
+ "FlowError": "流程异常",
+ "ValueError": "数据格式异常",
+ "KeyError": "字段缺失异常",
+ "RuntimeError": "运行异常",
+ }
+ return labels.get(name, "程序异常" if name else "")
+
+
def emit_operation_feedback(
state: dict[str, Any],
stage: str,
@@ -317,39 +522,41 @@ def emit_operation_feedback(
attempt: int = 1,
dry_run: bool = False,
) -> None:
+ # Feedback lines are a mini protocol consumed by the calling agent. They
+ # must stay single-line JSON so weak models/tools can relay them reliably.
payload_obj = {
- "type": "OP_FEEDBACK",
- "step": feedback_step(stage),
- "stage": stage,
- "operation": endpoint_label(endpoint_key, method, url),
- "method": method,
- "url": url,
- "endpoint": endpoint_key or "",
- "attempt": attempt,
- "dryRun": dry_run,
- "requestKeys": request_keys(payload),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "操作反馈",
+ "步骤": feedback_step_label(stage),
+ "阶段": stage_label(stage),
+ "操作": endpoint_label(endpoint_key, method, url),
+ "请求方式": method_label(method),
+ "接口地址": url,
+ "接口名称": endpoint_label(endpoint_key, method, url),
+ "第几次尝试": attempt,
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("OP_FEEDBACK " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["operation"] + " " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
def emit_error_feedback(error: dict[str, Any]) -> None:
payload = {
- "type": "ERROR_FEEDBACK",
- "stage": error.get("stage"),
- "step": feedback_step(str(error.get("stage") or "")),
- "message": error.get("message"),
- "url": error.get("url"),
- "status": error.get("status"),
- "request": error.get("request"),
- "response": error.get("response"),
- "exceptionType": error.get("exceptionType"),
- "exception": error.get("exception"),
- "time": error.get("time"),
+ "日志类型": "错误反馈",
+ "阶段": stage_label(error.get("stage")),
+ "步骤": feedback_step_label(str(error.get("stage") or "")),
+ "错误信息": error.get("message"),
+ "接口地址": error.get("url"),
+ "状态码": error.get("status"),
+ "请求摘要": error.get("request"),
+ "响应摘要": error.get("response"),
+ "异常类型": exception_type_label(error.get("exceptionType")),
+ "异常信息": error.get("exception"),
+ "时间": error.get("time"),
}
- print("ERROR_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["error"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def emit_json_parse_review(
@@ -360,18 +567,89 @@ def emit_json_parse_review(
candidate_count: int,
) -> None:
payload = {
- "type": "JSON_PARSE_REVIEW",
- "stage": stage,
- "step": feedback_step(stage),
- "endpoint": endpoint_key,
- "message": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
- "candidateCount": candidate_count,
- "responseExcerpt": compact(text, 3000),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "JSON解析复核",
+ "阶段": stage_label(stage),
+ "步骤": feedback_step_label(stage),
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, endpoint_key),
+ "提示": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
+ "候选数量": candidate_count,
+ "响应片段": compact(text, 3000),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("JSON_PARSE_REVIEW " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["json_review"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
+
+
+SAVE_ENTITY_LABELS = {
+ "save_design": "受众目标",
+ "save_design_account": "受众目标账号",
+ "save_pathway": "关键传播节点",
+ "save_relation": "受众-传播节点关系",
+ "save_design_vuln": "受众脆弱点",
+ "save_pathway_vuln": "传播节点脆弱点",
+ "save_strategy": "策略",
+ "save_channel": "策略平台与人设",
+ "save_narrate": "策略信息主题",
+ "save_message": "策略消息配置",
+ "save_strategy_relation": "策略关联关系",
+}
+
+
+def saved_entity_name(endpoint_key: str, payload: dict[str, Any]) -> str:
+ """Return the most user-readable name for an inserted row."""
+ candidates = [
+ payload.get("targetAudience"),
+ payload.get("accountName"),
+ payload.get("vulnerabilities"),
+ payload.get("topic"),
+ payload.get("narrate"),
+ payload.get("platform"),
+ payload.get("strategyId"),
+ payload.get("designId"),
+ payload.get("pathwayId"),
+ ]
+ for value in candidates:
+ if value is not None and str(value).strip():
+ return str(value).strip()
+ return endpoint_key
+
+
+def emit_save_feedback(
+ state: dict[str, Any],
+ stage: str,
+ endpoint_key: str,
+ payload: dict[str, Any],
+ row_id: int,
+ *,
+ dry_run: bool,
+) -> None:
+ # Emit only after an id is known. Downstream steps use these ids and should
+ # fail loudly rather than inventing relations against missing rows.
+ label = SAVE_ENTITY_LABELS.get(endpoint_key, endpoint_key)
+ name = saved_entity_name(endpoint_key, payload)
+ feedback = {
+ "日志类型": "入库反馈",
+ "步骤": feedback_step(stage),
+ "阶段": stage,
+ "入库对象类型": label,
+ "入库对象名称": name,
+ "返回ID": row_id,
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, label),
+ "接口路径": ENDPOINTS.get(endpoint_key, ""),
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
+ }
+ print(LOG_PREFIXES["save"] + " " + json.dumps(feedback, ensure_ascii=False), flush=True)
+ record_event(
+ state,
+ stage,
+ f"入库{label}成功:{name}",
+ {"id": row_id, "endpoint": endpoint_key, "dryRun": dry_run},
+ )
def record_error(
@@ -385,16 +663,18 @@ def record_error(
response: Any | None = None,
exception: BaseException | None = None,
) -> None:
+ # Persist full-enough error context for post-run diagnosis, then immediately
+ # emit a short line the agent can show to the user.
item = {
"time": utc_now(),
"stage": stage,
"message": message,
"url": url,
"status": status,
- "request": compact(request, 1200) if request is not None else None,
- "response": compact(response, 2000) if response is not None else None,
+ "request": safe_compact(request, 1200) if request is not None else None,
+ "response": safe_compact(response, 2000) if response is not None else None,
"exceptionType": type(exception).__name__ if exception else None,
- "exception": str(exception) if exception else None,
+ "exception": safe_compact(str(exception), 1000) if exception else None,
}
state.setdefault("errors", []).append(item)
state["updatedAt"] = utc_now()
@@ -430,69 +710,29 @@ def url_for_path(ctx: FlowContext, path: str) -> str:
return ctx.api_base.rstrip("/") + "/" + path.lstrip("/")
-def parse_headers(header_values: list[str], auth_token: str | None, authorization: str | None) -> dict[str, str]:
+def parse_headers(header_values: list[str]) -> dict[str, str]:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
- if auth_token:
- headers["Authorization"] = auth_token if auth_token.lower().startswith("bearer ") else f"Bearer {auth_token}"
- if authorization:
- headers["Authorization"] = authorization
for raw in header_values:
if ":" not in raw:
raise FlowError(f"header 格式错误,应为 Key: Value,实际:{raw}")
key, value = raw.split(":", 1)
- headers[key.strip()] = value.strip()
+ normalized = key.strip()
+ if normalized.lower() == "authorization":
+ continue
+ headers[normalized] = value.strip()
return headers
-def has_authorization(headers: dict[str, str]) -> bool:
- return any(key.lower() == "authorization" and str(value).strip() for key, value in headers.items())
-
-
def ssl_context(ctx: FlowContext) -> ssl.SSLContext | None:
if ctx.verify_ssl:
return None
return ssl._create_unverified_context()
-def auth_header_value(token: str) -> str:
- token = token.strip()
- return token if token.lower().startswith("bearer ") else f"Bearer {token}"
-
-
-def extract_access_token(data: Any) -> str | None:
- if isinstance(data, dict):
- for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
- value = data.get(key)
- if isinstance(value, str) and value.strip():
- return value.strip()
- for value in data.values():
- nested = extract_access_token(value)
- if nested:
- return nested
- elif isinstance(data, list):
- for value in data:
- nested = extract_access_token(value)
- if nested:
- return nested
- return None
-
-
-def redact_url(url: str) -> str:
- return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
-
-
-def build_login_url(ctx: FlowContext, path: str) -> str:
- base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else url_for_path(ctx, path)
- if ctx.login_password:
- separator = "&" if "?" in base else "?"
- base = f"{base}{separator}password={urllib.parse.quote(ctx.login_password, safe='')}"
- return base
-
-
def login_candidate_paths(ctx: FlowContext) -> list[str]:
paths = [
- ctx.login_path or "/api/v1/auth/login/username",
- "/api/v1/auth/login/username",
+ ctx.login_path or "/api/auth/login",
+ "/api/auth/login",
]
seen: set[str] = set()
unique: list[str] = []
@@ -506,6 +746,7 @@ def login_candidate_paths(ctx: FlowContext) -> list[str]:
def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force: bool = False) -> bool:
+ """Fetch a fresh token through the local auth package before business calls."""
if not ctx.auto_login:
return False
if ctx.dry_run:
@@ -515,18 +756,17 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
record_error(state, stage, "自动登录失败:缺少 login_username")
return False
if not force and ctx.login_attempt_count > 0:
- return has_authorization(ctx.headers)
+ return any(key.lower() == "authorization" and str(value).strip() for key, value in ctx.headers.items())
if force and ctx.login_attempt_count >= 2:
return False
ctx.login_attempt_count += 1
- payload = {"username": ctx.login_username}
+ payload = {"username": ctx.login_username, "password": ctx.login_password}
last_status: int | None = None
last_response = ""
last_exc: BaseException | None = None
for attempt, path in enumerate(login_candidate_paths(ctx), start=1):
- url = build_login_url(ctx, path)
- display_url = redact_url(url)
+ display_url = xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, path))
emit_operation_feedback(
state,
stage,
@@ -537,63 +777,39 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
attempt=attempt,
)
try:
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- headers = {
- "Content-Type": "application/json",
- "Accept": "application/json",
- }
- req = urllib.request.Request(url, data=data, headers=headers, method="POST")
- with urllib.request.urlopen(req, timeout=ctx.timeout, context=ssl_context(ctx)) as resp:
- raw = resp.read().decode("utf-8", errors="replace")
- parsed = parse_json_lenient(raw)
- token = extract_access_token(parsed)
- if not token:
- last_status = getattr(resp, "status", None)
- last_response = raw
- record_event(
- state,
- stage,
- "登录接口已响应但未找到 access_token",
- {"url": display_url, "status": last_status, "response": compact(raw, 1000)},
- )
- continue
- ctx.headers["Authorization"] = auth_header_value(token)
- record_event(
- state,
- stage,
- "自动登录成功,已写入 Authorization Bearer token",
- {
- "url": display_url,
- "status": getattr(resp, "status", None),
- "username": ctx.login_username,
- },
- )
- return True
- except urllib.error.HTTPError as exc:
- last_status = exc.code
- last_response = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
- last_exc = exc
- record_event(
- state,
- stage,
- "登录接口返回错误,准备尝试下一个候选登录路径",
- {"url": display_url, "status": exc.code, "response": compact(last_response, 1000)},
+ result = xdfx_auth.fetch_access_token(
+ api_base=ctx.api_base,
+ username=ctx.login_username,
+ password=ctx.login_password,
+ login_path=path,
+ timeout=ctx.timeout,
+ verify_ssl=ctx.verify_ssl,
)
- except Exception as exc: # noqa: BLE001 - auto-login must report every failure.
- last_exc = exc
- last_response = traceback.format_exc()
+ ctx.headers["Authorization"] = xdfx_auth.auth_header_value(result.token)
+ state["_tokenSource"] = "xdfx_auth"
record_event(
state,
stage,
- "登录接口调用异常,准备尝试下一个候选登录路径",
- {"url": display_url, "exceptionType": type(exc).__name__, "exception": str(exc)},
+ "启动阶段已通过 xdfx_auth 包获取 token,并写入 Authorization",
+ {"url": result.url, "status": result.status, "username": ctx.login_username},
+ )
+ return True
+ except xdfx_auth.AuthError as exc:
+ last_status = exc.status
+ last_response = exc.response
+ last_exc = exc
+ record_event(
+ state,
+ stage,
+ "xdfx_auth 获取 token 失败,准备尝试下一个候选登录路径",
+ {"url": exc.url, "status": exc.status, "response": safe_compact(exc.response, 1000), "exception": str(exc)},
)
record_error(
state,
stage,
- "自动登录失败,未能获取 access_token",
- url=redact_url(build_login_url(ctx, ctx.login_path or "/api/v1/auth/login/username")),
+ "自动登录失败,未能通过 xdfx_auth 获取 access_token",
+ url=xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, ctx.login_path or "/api/auth/login")),
status=last_status,
request=payload,
response=last_response,
@@ -612,6 +828,7 @@ def http_request_json_url(
endpoint_key: str | None = None,
allow_auto_login: bool = True,
) -> tuple[Any | None, str]:
+ """Call an HTTP JSON endpoint with logging, retries, and 401 recovery."""
method = method.upper()
if ctx.dry_run:
emit_operation_feedback(state, stage, method, url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
@@ -640,6 +857,7 @@ def http_request_json_url(
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
if exc.code == 401 and allow_auto_login and ctx.auto_login:
+ # Refresh once on 401, then replay the original request exactly.
refreshed = try_auto_login(ctx, state, f"{stage}/auto-login", force=True)
if refreshed:
record_event(
@@ -709,6 +927,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
emit_operation_feedback(state, stage, "POST", url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
record_event(state, stage, f"DRY RUN POST {url}", {"payload": payload})
if endpoint_key.startswith("save_"):
+ # Synthetic ids let downstream dry-run code exercise relation wiring.
synthetic_id = abs(hash(stage + stable_json(payload))) % 100000000
return {"id": synthetic_id, "dryRun": True}, json.dumps({"id": synthetic_id, "dryRun": True})
return {"dryRun": True}, json.dumps({"dryRun": True})
@@ -716,6 +935,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
def parse_json_lenient(text: str) -> Any | None:
+ """Parse normal JSON plus adjacent/SSE-like JSON objects."""
stripped = text.strip()
if not stripped:
return None
@@ -730,6 +950,7 @@ def parse_json_lenient(text: str) -> Any | None:
def parse_concatenated_json_objects(text: str) -> list[Any]:
+ """Read streams such as {"answer": "..."}{"answer": "..."} or data: lines."""
decoder = json.JSONDecoder()
objects: list[Any] = []
i = 0
@@ -766,6 +987,7 @@ def parse_concatenated_json_objects(text: str) -> list[Any]:
def model_text_from_response(parsed: Any, raw: str) -> tuple[str, list[Any]]:
+ """Flatten streamed model chunks into one text buffer."""
objects: list[Any]
if isinstance(parsed, list):
objects = parsed
@@ -824,6 +1046,7 @@ def extract_json_from_text(text: str) -> Any | None:
def extract_json_candidates_from_text(text: str) -> list[Any]:
+ """Collect every plausible JSON object/array from messy model text."""
cleaned = strip_think(text)
candidates: list[Any] = []
seen: set[str] = set()
@@ -860,6 +1083,7 @@ def extract_json_candidates_from_text(text: str) -> list[Any]:
def select_json_candidate(candidates: list[Any], endpoint_key: str, stage: str) -> Any | None:
+ """Pick the candidate whose keys best match the current step."""
if not candidates:
return None
scored = [(json_candidate_score(candidate, endpoint_key, stage), idx, candidate) for idx, candidate in enumerate(candidates)]
@@ -1010,6 +1234,7 @@ def extract_first_balanced(text: str, open_ch: str, close_ch: str) -> str | None
def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[str, list[Any], Any | None]:
+ """Call a model endpoint and retry only known empty/timeout/no-response cases."""
max_attempts = max(ctx.model_retries, 0) + 1
last_text = ""
last_objects: list[Any] = []
@@ -1048,6 +1273,11 @@ def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str,
def call_model_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[dict[str, Any], str, list[Any]]:
+ """Return the best structured JSON for a model step.
+
+ If the first parse fails, the second attempt includes an explicit repair
+ instruction so weak models can re-emit only the required JSON.
+ """
last_text = ""
last_objects: list[Any] = []
last_candidate_count = 0
@@ -1202,23 +1432,85 @@ def profile_info(account: dict[str, Any]) -> str:
def parse_index_list(value: Any) -> list[str]:
- if value is None:
- return []
- if isinstance(value, list):
- return [str(v).strip() for v in value if str(v).strip()]
- text = str(value)
- return [part.strip().strip("<>").strip() for part in re.split(r"[,,;;、\s]+", text) if part.strip()]
+ refs: list[str] = []
+
+ def add(raw: Any) -> None:
+ if raw is None:
+ return
+ if isinstance(raw, dict):
+ keys = (
+ "编号id",
+ "编号ID",
+ "用户编号id",
+ "用户编号ID",
+ "用户编号",
+ "账号编号",
+ "引用序号",
+ "索引",
+ "index",
+ "id",
+ "ids",
+ "accountIndex",
+ "accountIndexes",
+ "userIndex",
+ "userIndexes",
+ )
+ for key in keys:
+ if key in raw:
+ add(raw.get(key))
+ return
+ if isinstance(raw, (list, tuple, set)):
+ for item in raw:
+ add(item)
+ return
+ text = str(raw).strip()
+ if not text:
+ return
+ angle_refs = re.findall(r"<<\s*([^<>]+?)\s*>>", text)
+ if angle_refs:
+ for token in angle_refs:
+ add(token)
+ return
+ for part in re.split(r"[,,;;、\s]+", text):
+ token = part.strip().strip("<>").strip()
+ if token:
+ refs.append(token)
+
+ add(value)
+ deduped: list[str] = []
+ seen: set[str] = set()
+ for ref in refs:
+ if ref in seen:
+ continue
+ seen.add(ref)
+ deduped.append(ref)
+ return deduped
+
+
+def account_ref_numbers(account: dict[str, Any]) -> set[int]:
+ numbers: set[int] = set()
+ meta = account.get("metadata") if isinstance(account.get("metadata"), dict) else {}
+ for key in ("编号id", "编号ID", "用户编号id", "用户编号ID", "用户编号", "账号编号", "引用序号", "索引", "index", "accountIndex"):
+ for container in (account, meta):
+ number = as_int(container.get(key)) if isinstance(container, dict) else None
+ if number is not None:
+ numbers.add(number)
+ return numbers
def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, Any] | None:
+ """Resolve model references like <<3>>, index numbers, names, or account ids."""
if not accounts:
return None
text = str(ref).strip().strip("<>").strip()
number = as_int(text)
if number is not None:
for account in accounts:
- if any(as_int(account.get(key)) == number for key in ("引用序号", "索引", "index")):
+ if number in account_ref_numbers(account):
return account
+ # Step1 group extraction returns 编号/id values that refer to the
+ # previous zhanghao array index. Prefer exact index fields above, then
+ # fall back to zero-based and one-based array positions.
if 0 <= number < len(accounts):
return accounts[number]
if 1 <= number <= len(accounts):
@@ -1232,11 +1524,25 @@ def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, A
def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Normalize audience JSON whether the model used numbered fields or items[]."""
items: list[dict[str, Any]] = []
for i in range(1, 21):
group = first_present(data.get(f"群体{i}"), data.get(f"受众{i}"), data.get(f"targetAudience{i}"))
reason = first_present(data.get(f"理由{i}"), data.get(f"推荐理由{i}"), data.get(f"recReason{i}"))
- users = first_present(data.get(f"用户编号列表{i}"), data.get(f"用户列表{i}"), data.get(f"账号编号列表{i}"))
+ users = first_present(
+ data.get(f"用户编号列表{i}"),
+ data.get(f"编号id列表{i}"),
+ data.get(f"编号ID列表{i}"),
+ data.get(f"用户编号id列表{i}"),
+ data.get(f"用户编号ID列表{i}"),
+ data.get(f"用户列表{i}"),
+ data.get(f"账号编号列表{i}"),
+ data.get(f"索引列表{i}"),
+ data.get(f"编号{i}"),
+ data.get(f"id{i}"),
+ data.get(f"ids{i}"),
+ data.get(f"索引{i}"),
+ )
if group:
items.append(
{
@@ -1255,7 +1561,27 @@ def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
{
"targetAudience": str(group).strip(),
"recReason": str(first_present(item.get("理由"), item.get("recReason"), item.get("reason")) or "").strip(),
- "userIndexes": parse_index_list(first_present(item.get("用户编号列表"), item.get("users"), item.get("accounts"))),
+ "userIndexes": parse_index_list(
+ first_present(
+ item.get("用户编号列表"),
+ item.get("编号id列表"),
+ item.get("编号ID列表"),
+ item.get("用户编号id列表"),
+ item.get("用户编号ID列表"),
+ item.get("编号id"),
+ item.get("编号ID"),
+ item.get("用户编号id"),
+ item.get("用户编号ID"),
+ item.get("编号"),
+ item.get("索引"),
+ item.get("id"),
+ item.get("ids"),
+ item.get("users"),
+ item.get("accounts"),
+ item.get("userIndexes"),
+ item.get("accountIndexes"),
+ )
+ ),
"rank": i,
}
)
@@ -1272,6 +1598,7 @@ def audience_score(item: dict[str, Any]) -> tuple[int, int]:
def select_top_audiences(items: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
+ """Deduplicate audience names and keep the strongest candidates."""
unique: dict[str, dict[str, Any]] = {}
for item in items:
key = item.get("targetAudience", "")
@@ -1301,6 +1628,7 @@ def pathway_score(item: dict[str, Any]) -> tuple[int, int, int]:
def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Merge the same account found from different audiences before saving top 3."""
merged: dict[str, dict[str, Any]] = {}
for item in candidates:
account = item.get("account") or {}
@@ -1325,6 +1653,7 @@ def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str,
def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
+ """Normalize weak-point model output into a capped list."""
vulns: list[dict[str, str]] = []
for i in range(1, 21):
vuln = first_present(data.get(f"脆弱点{i}"), data.get(f"vulnerability{i}"), data.get(f"vuln{i}"))
@@ -1339,6 +1668,7 @@ def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
def extract_strategies(data: dict[str, Any], limit: int) -> list[dict[str, Any]]:
+ """Normalize strategy output and preserve the modal text for later mapping."""
strategies: list[dict[str, Any]] = []
for i in range(1, 21):
title = first_present(data.get(f"信息标题{i}"), data.get(f"主题{i}"), data.get(f"topic{i}"))
@@ -1395,11 +1725,29 @@ def response_id(response: Any) -> int | None:
return None
+def response_rows(response: Any) -> list[Any]:
+ """Extract list rows from common CRUD response shapes."""
+ if isinstance(response, list):
+ return response
+ if not isinstance(response, dict):
+ return []
+ for key in ("data", "rows", "items", "records", "list"):
+ value = response.get(key)
+ if isinstance(value, list):
+ return value
+ rows = response_rows(value)
+ if rows:
+ return rows
+ return []
+
+
def save_with_id(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[int | None, Any | None]:
response, _ = http_post_json(ctx, state, endpoint_key, payload, stage)
rid = response_id(response)
if rid is None:
- record_error(state, stage, "保存接口未返回 id,视为失败", request=payload, response=response)
+ record_error(state, stage, "保存接口未返回 ID,视为失败", request=payload, response=response)
+ else:
+ emit_save_feedback(state, stage, endpoint_key, payload, rid, dry_run=ctx.dry_run)
return rid, response
@@ -1429,6 +1777,7 @@ def parse_step_flags(raw: Any) -> dict[str, Any]:
def mark_step_status(ctx: FlowContext, state: dict[str, Any], links: list[str], stage: str) -> None:
+ """Update the frontend step bar without overwriting unrelated step flags."""
if not ctx.sync_step_status:
record_event(state, stage, "跳过 task-action.step 状态同步")
return
@@ -1477,12 +1826,126 @@ def strategy_platform(value: Any) -> Any:
return platform_to_number(value) or 1
-def strategy_model_type(value: Any) -> str:
- # Frontend defaults to text model type "1" when no modal type is selected.
- raw = first_present(value, "1")
- if isinstance(raw, list):
- return ",".join(str(v) for v in raw if str(v).strip()) or "1"
- return str(raw or "1")
+def choice_tokens(value: Any) -> list[Any]:
+ """Split frontend choice values from arrays, JSON strings, and mixed delimiters."""
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return choice_tokens(
+ first_present(value.get("value"), value.get("dictValue"), value.get("label"), value.get("dictLabel"))
+ )
+ if isinstance(value, (list, tuple, set)):
+ tokens: list[Any] = []
+ for item in value:
+ tokens.extend(choice_tokens(item))
+ return tokens
+ text = str(value).strip()
+ if not text:
+ return []
+ if text[0] in "[{":
+ try:
+ parsed = json.loads(text)
+ if parsed != value:
+ return choice_tokens(parsed)
+ except Exception:
+ pass
+ return [part.strip() for part in re.split(r"[,,、;/|]+", text) if part.strip()]
+
+
+def dict_choices(
+ ctx: FlowContext,
+ state: dict[str, Any],
+ dict_type: str,
+ stage: str,
+) -> list[dict[str, Any]]:
+ """Fetch and cache frontend dictionary choices such as modelType."""
+ cache = state.setdefault("_dictChoiceCache", {})
+ if dict_type in cache:
+ cached = cache[dict_type]
+ return cached if isinstance(cached, list) else []
+ if ctx.dry_run:
+ cache[dict_type] = []
+ return []
+
+ query = urllib.parse.urlencode({"filter": f"dictType||$eq||{dict_type}", "limit": 999, "page": 1})
+ url = f"{url_for(ctx, 'dict_data')}?{query}"
+ response, _ = http_request_json_url(ctx, state, "GET", url, None, stage, endpoint_key="dict_data")
+ choices: list[dict[str, Any]] = []
+ for row in response_rows(response):
+ if not isinstance(row, dict):
+ continue
+ label = str(first_present(row.get("dictLabel"), row.get("label"), "") or "").strip()
+ raw_value = first_present(row.get("dictValue"), row.get("value"))
+ if not label or raw_value is None:
+ continue
+ number = as_int(raw_value)
+ choices.append({"label": label, "value": number if number is not None else str(raw_value).strip()})
+ for alias in choice_tokens(row.get("alias")):
+ alias_label = str(alias).strip()
+ if alias_label:
+ choices.append({"label": alias_label, "value": number if number is not None else str(raw_value).strip()})
+ cache[dict_type] = choices
+ return choices
+
+
+def choice_value_by_label(choices: list[dict[str, Any]] | None, label: str) -> Any | None:
+ needle = re.sub(r"\s+", "", str(label or "").lower())
+ if not needle:
+ return None
+ for item in choices or []:
+ item_label = re.sub(r"\s+", "", str(item.get("label") or "").lower())
+ if item_label and (needle == item_label or needle in item_label or item_label in needle):
+ return item.get("value")
+ return None
+
+
+def model_type_token_value(token: Any, choices: list[dict[str, Any]] | None = None) -> Any:
+ """Convert modal labels like 图片/视频 into frontend dictionary values."""
+ number = as_int(token)
+ if number is not None:
+ return number
+
+ text = str(token or "").strip()
+ lower = text.lower()
+ exact = choice_value_by_label(choices, text)
+ if exact is not None:
+ return exact
+
+ label = ""
+ fallback_value = 2
+ if "视" in text or "video" in lower:
+ label, fallback_value = "视频", 3
+ elif "图" in text or "image" in lower or "picture" in lower or "photo" in lower:
+ label, fallback_value = "图片", 2
+ elif "音" in text or "audio" in lower or "sound" in lower:
+ label, fallback_value = "音频", 4
+ elif "发文" in text or "文本" in text or "文字" in text or "text" in lower:
+ label, fallback_value = "文本", 1
+
+ if label:
+ matched = choice_value_by_label(choices, label)
+ if matched is not None:
+ return matched
+ return fallback_value
+
+ # Mirrors the frontend helper's unknown-token default.
+ return 2
+
+
+def strategy_model_type(value: Any, choices: list[dict[str, Any]] | None = None) -> str:
+ """Return the comma-separated value string expected by task-action-message."""
+ tokens = choice_tokens(value)
+ if not tokens:
+ # Frontend save paths fall back to "1" when no modal type is selected.
+ return "1"
+ values: list[str] = []
+ seen: set[str] = set()
+ for token in tokens:
+ normalized = str(model_type_token_value(token, choices)).strip()
+ if normalized and normalized not in seen:
+ values.append(normalized)
+ seen.add(normalized)
+ return ",".join(values) or "1"
def strategy_language(value: Any) -> int:
@@ -1501,11 +1964,28 @@ def save_strategy_details(
*,
audience: bool,
) -> None:
- platform = strategy_platform(first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})))
+ """Save all frontend-readable strategy child rows.
+
+ The strategy table and arrangement page do not read only the main strategy
+ row; they also expect channel, narrate, message, and relation rows.
+ """
+ platform = strategy_platform(
+ first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
+ )
+ account_type = str(
+ first_present(
+ strategy.get("accountType"),
+ item.get("accountType"),
+ item.get("type"),
+ "目标受众账号" if audience else "关键传播节点账号",
+ )
+ or ""
+ ).strip()
persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
title = str(strategy.get("title") or "").strip()
+ model_type_choices = dict_choices(ctx, state, "modelType", stage + "/dict-modelType")
channel_id, _ = save_with_id(
ctx,
@@ -1516,6 +1996,7 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"platform": platform,
+ "accountType": account_type,
"personaJson": json.dumps([{"persona": persona, "size": persona_size}], ensure_ascii=False),
},
stage + "/save-channel",
@@ -1531,7 +2012,10 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"channelId": channel_id,
- "modelType": strategy_model_type(first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType"))),
+ "modelType": strategy_model_type(
+ first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType")),
+ model_type_choices,
+ ),
"language": strategy_language(first_present(strategy.get("language"), strategy.get("语种"))),
"respondingCount": responding_count,
"messageCount": responding_count,
@@ -1564,7 +2048,46 @@ def object_for_audience(audience: dict[str, Any]) -> dict[str, Any]:
}
+def selected_accounts_for_item(item: dict[str, Any], *, audience: bool) -> list[Any]:
+ if audience:
+ accounts = item.get("accounts") or []
+ return accounts or [object_for_audience(item)]
+ return [item.get("account") or item]
+
+
+def downstream_limit(ctx: FlowContext, *, audience: bool) -> int:
+ """Limit expensive downstream analysis while keeping 3 upstream rows stored."""
+ raw = ctx.downstream_audiences if audience else ctx.downstream_pathways
+ return max(1, raw)
+
+
+def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, audience: bool) -> list[dict[str, Any]]:
+ """Select the 1-2 best stored rows for vulnerability/strategy generation."""
+ source_key = "topAudiences" if audience else "topPathways"
+ id_key = "designId" if audience else "pathwayAccountId"
+ selected_key = "selectedDownstreamAudiences" if audience else "selectedDownstreamPathways"
+ items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)]
+ limit = downstream_limit(ctx, audience=audience)
+ selected = items[:limit]
+ state[selected_key] = [
+ {
+ "id": item.get(id_key),
+ "name": item.get("targetAudience") if audience else item.get("accountName"),
+ }
+ for item in selected
+ ]
+ if items:
+ record_event(
+ state,
+ stage,
+ f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}",
+ {"limit": limit, "selected": state[selected_key]},
+ )
+ return selected
+
+
def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze audiences, save top 3, and attach any referenced accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step1-audience"
if state.get("topAudiences") and not ctx.dry_run:
@@ -1582,12 +2105,15 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
state["audienceRawText"] = text
+ # The first model call may return raw text plus embedded account objects.
accounts = accounts_from_response(parsed, objects, text)
state["audienceAccounts"] = accounts
+ # The second model call converts the long audience analysis into numbered
+ # groups that can be saved as task-action-design rows.
group_payload = {
"mubiao": state["mubiao"],
- "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
"zhanghao": accounts,
"history": [],
"stream": True,
@@ -1602,6 +2128,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
top = select_top_audiences(candidates, ctx.max_audiences)
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the audience first; account rows and downstream analyses depend
+ # on the returned designId.
design_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
@@ -1614,6 +2142,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
continue
item = {**item, "designId": design_id, "saveResponse": resp, "accounts": [], "savedAccountIds": []}
for ref in item.get("userIndexes") or []:
+ # Model references are intentionally fuzzy, so resolve them against
+ # both explicit indexes and account names/ids.
account = resolve_account_ref(ref, accounts)
if account is None:
record_error(state, stage + "/save-design-account", f"用户编号 {ref} 未匹配到候选账号")
@@ -1645,6 +2175,7 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze key pathway accounts for saved audiences and store top 3."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step2-pathway"
audiences = [a for a in state.get("topAudiences") or [] if a.get("designId")]
@@ -1656,6 +2187,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
return
all_candidates: list[dict[str, Any]] = []
for audience in audiences:
+ # Analyze pathway candidates in the context of each saved audience.
payload = {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。",
"otherinfo": {
@@ -1673,6 +2205,8 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
accounts = accounts_from_response(parsed, objects, text)
+ # Ask the model to point back to candidate accounts, then resolve those
+ # references to concrete account objects.
spread_payload = {
"mubiao": state["mubiao"],
"user_message": "分析其中关键信息。请从候选账号中抽取核心传播节点,字段必须是:账号1、理由1、账号2、理由2 ...,最多输出5个。账号字段优先返回候选数组的引用编号或索引,只返回JSON。",
@@ -1707,6 +2241,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways]
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the account row before writing the design-pathway relation.
account = item["account"]
aid = account_id(account)
name = account_name(account)
@@ -1757,18 +2292,20 @@ def run_step4(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate and save weak points for selected audiences or pathway accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step3-audience-vulns" if audience else "step4-pathway-vulns"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
output_key = "audienceVulns" if audience else "pathwayVulns"
state.setdefault(output_key, {})
for item in items:
+ # owner_id is designId for audiences and pathwayAccountId for accounts.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
if not owner_id:
continue
if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run:
continue
- zhanghao = item.get("accounts") or [object_for_audience(item)] if audience else [item.get("account") or item]
+ zhanghao = selected_accounts_for_item(item, audience=audience)
prompt = (
"你是中国对台湾方向的认知战专家,你要传播行动要求的叙事主题。请基于该受众目标的画像、关注议题、社会分工定位、利益诉求与上一轮账号佐证,分析该受众目标有哪些脆弱点可以成为后续策略设计的关键要素。请输出理由、脆弱点1、理由1、脆弱点2、理由2、脆弱点3、理由3,只返回JSON。"
if audience
@@ -1789,6 +2326,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) ->
vulns = extract_vulns(data, ctx.max_vulns)
saved: list[dict[str, Any]] = []
for vuln in vulns:
+ # The two weak-point tables have nearly identical payloads but
+ # different foreign-key names.
if audience:
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
@@ -1835,14 +2374,16 @@ def run_step6(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate strategies from saved weak points and persist frontend child rows."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step5-audience-strategy" if audience else "step6-pathway-strategy"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {}
output_key = "audienceStrategies" if audience else "pathwayStrategies"
state.setdefault(output_key, [])
existing_topics = {s.get("topic") for s in state[output_key] if isinstance(s, dict)}
for item in items:
+ # Strategies are only meaningful after the matching weak points exist.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
vulns = vulns_by_owner.get(str(owner_id)) or []
vuln_texts = [v.get("vulnerabilities") for v in vulns if v.get("vulnerabilities")]
@@ -1850,7 +2391,9 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
record_error(state, stage, "没有可用脆弱点,跳过策略生成", request=item)
continue
target_names = [item.get("targetAudience")] if audience else (item.get("relatedAudiences") or [item.get("accountName")])
- pingtai = state.get("platform") or (platform_label(item.get("account") or {}) if not audience else "")
+ zhanghao = selected_accounts_for_item(item, audience=audience)
+ first_account = zhanghao[0] if zhanghao and isinstance(zhanghao[0], dict) else {}
+ pingtai = state.get("platform") or platform_label(first_account)
payload = {
"user_message": DEFAULT_STRATEGY_PROMPT_AUDIENCE if audience else DEFAULT_STRATEGY_PROMPT_PATHWAY,
"cuiruodian": vuln_texts,
@@ -1858,6 +2401,7 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
"shouzhong": [name for name in target_names if name],
"mudi": extract_goal(state["mubiao"]),
"heiliao": [],
+ "zhanghao": zhanghao,
"history": [],
"stream": True,
"model_name": "OpenAI3",
@@ -1876,16 +2420,20 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
continue
if topic in existing_topics:
continue
+ # Save the main row first; channel/narrate/message/relation rows all
+ # require the returned strategy id.
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
"targetAudience": target,
"accountType": account_type,
"topic": topic,
- "strategyTitle": "",
+ "strategyTitle": str(strategy.get("title") or "").strip(),
}
strategy_id, resp = save_with_id(ctx, state, "save_strategy", save_payload, stage + "/save")
if strategy_id is not None:
+ # Frontend strategy pages read these child tables, so a strategy
+ # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience)
existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp})
@@ -1914,13 +2462,14 @@ STEP_LABELS = {
def state_counts(state: dict[str, Any]) -> dict[str, int]:
+ """Summarize stored objects for step feedback."""
return {
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceVulns": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
- "pathwayVulns": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "受众脆弱点数量": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
+ "传播节点脆弱点数量": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
}
@@ -1932,23 +2481,26 @@ def emit_step_feedback(
status: str | None = None,
note: str = "",
) -> None:
+ # One feedback line per step keeps the single-skill and split-skill versions
+ # equally transparent to the user.
total_errors = len(state.get("errors") or [])
new_errors = max(0, total_errors - before_error_count)
final_status = status or ("partial" if new_errors else "ok")
payload = {
- "type": "STEP_FEEDBACK",
- "step": step,
- "label": STEP_LABELS.get(step, step),
- "status": final_status,
- "note": note,
- "newErrors": new_errors,
- "totalErrors": total_errors,
- "counts": state_counts(state),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "updatedAt": state.get("updatedAt"),
+ "日志类型": "步骤反馈",
+ "步骤": STEP_NUMBER_LABELS.get(step, step),
+ "步骤名称": STEP_LABELS.get(step, step),
+ "状态": STATUS_LABELS.get(final_status, final_status),
+ "执行状态": status_label(final_status),
+ "备注": note,
+ "本步骤新增错误数": new_errors,
+ "累计错误数": total_errors,
+ "累计入库数量": state_counts(state),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "更新时间": state.get("updatedAt"),
}
- print("STEP_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["step"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def finish_step(
@@ -1960,6 +2512,8 @@ def finish_step(
status: str | None = None,
note: str = "",
) -> None:
+ # Always persist before emitting step feedback so the user can inspect files
+ # immediately after a reported step completes.
state["updatedAt"] = utc_now()
save_json_file(ctx.state_path, state)
if ctx.summary_path:
@@ -1980,6 +2534,11 @@ def skip_step_with_feedback(ctx: FlowContext, state: dict[str, Any], step: str,
def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Legacy full runner.
+
+ The wrapper script is preferred for real-time logs, but this remains useful
+ for dry-runs and unattended local troubleshooting.
+ """
run_step_with_feedback(ctx, state, "step1")
run_step_with_feedback(ctx, state, "step2")
run_step_with_feedback(ctx, state, "step3")
@@ -1995,6 +2554,7 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
def write_summary(path: Path, state: dict[str, Any]) -> None:
+ """Write a human-readable checkpoint beside the machine state JSON."""
audience_vuln_count = sum(len(v) for v in (state.get("audienceVulns") or {}).values())
pathway_vuln_count = sum(len(v) for v in (state.get("pathwayVulns") or {}).values())
lines = [
@@ -2005,6 +2565,8 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
f"- actionId:{state.get('actionId')}",
f"- 受众入库:{len(state.get('topAudiences') or [])}",
f"- 传播节点入库:{len(state.get('topPathways') or [])}",
+ f"- 下游受众选择:{len(state.get('selectedDownstreamAudiences') or [])}",
+ f"- 下游传播节点选择:{len(state.get('selectedDownstreamPathways') or [])}",
f"- 受众脆弱点入库:{audience_vuln_count}",
f"- 传播节点脆弱点入库:{pathway_vuln_count}",
f"- 受众策略入库:{len(state.get('audienceStrategies') or [])}",
@@ -2021,9 +2583,9 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
if state.get("errors"):
lines += ["", "## 失败明细"]
for err in state["errors"]:
- lines.append(f"- [{err.get('stage')}] {err.get('message')} status={err.get('status')} url={err.get('url')}")
+ lines.append(f"- [{stage_label(err.get('stage'))}] {err.get('message')} 状态码={err.get('status')} 接口={err.get('url')}")
if err.get("response"):
- lines.append(f" - response: {err.get('response')}")
+ lines.append(f" - 响应摘要:{err.get('response')}")
text = "\n".join(lines) + "\n"
try:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -2034,34 +2596,34 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
def build_arg_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(description="Run xdfx analysis/storage flow.")
+ parser = argparse.ArgumentParser(description="运行 xdfx 分析与入库流程。")
parser.add_argument("--mode", choices=["full", *STEP_FUNCS.keys()], default="full")
- parser.add_argument("--input", help="JSON config file. Keys: api_base, taskId, actionId, mubiao, direction, platform, headers.")
- parser.add_argument("--state", default="xdfx-flow-state.json", help="State JSON path for resume and step handoff.")
- parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown summary output path.")
+ parser.add_argument("--input", help="JSON 配置文件。常用字段:api_base、taskId、actionId、mubiao、direction、platform、headers。")
+ parser.add_argument("--state", default="xdfx-flow-state.json", help="用于断点续跑和步骤交接的状态 JSON 文件路径。")
+ parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown 执行摘要输出路径。")
parser.add_argument("--api-base")
parser.add_argument("--task-id")
parser.add_argument("--action-id")
parser.add_argument("--mubiao")
parser.add_argument("--direction", default=None)
parser.add_argument("--platform", default=None)
- parser.add_argument("--auth-token", default=os.getenv("XDFX_AUTH_TOKEN"))
- parser.add_argument("--authorization", default=os.getenv("AUTHORIZATION"))
- parser.add_argument("--header", action="append", default=[], help="Extra HTTP header, format 'Key: Value'.")
- parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="Username for automatic login when Authorization is missing or stale.")
- parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="Password query parameter for automatic username login.")
- parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="Login endpoint path or absolute URL.")
- parser.add_argument("--no-auto-login", action="store_true", help="Disable automatic username login and 401 token refresh.")
- parser.add_argument("--verify-ssl", action="store_true", help="Verify HTTPS certificates. Default is disabled for internal/UAT certificates.")
+ parser.add_argument("--header", action="append", default=[], help="附加非鉴权 HTTP 请求头,格式为 'Key: Value';Authorization 会被忽略。")
+ parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="xdfx_auth 启动阶段自动登录使用的用户名。")
+ parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="登录接口 JSON 请求体中的 password。")
+ parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/auth/login"), help="登录接口路径或完整 URL。")
+ parser.add_argument("--no-auto-login", action="store_true", help="关闭 xdfx_auth 自动登录和 401 刷新。")
+ parser.add_argument("--verify-ssl", action="store_true", help="校验 HTTPS 证书。默认不校验,以兼容内网/UAT 自签证书。")
parser.add_argument("--timeout", type=float, default=90)
parser.add_argument("--retries", type=int, default=2)
parser.add_argument("--dry-run", action="store_true")
- parser.add_argument("--no-step-status", action="store_true", help="Do not update task/task-action.step after completed steps.")
- parser.add_argument("--model-retries", type=int, default=1, help="Retry model/stream endpoints when the first response is empty, timeout-like, or says the model did not respond.")
+ parser.add_argument("--no-step-status", action="store_true", help="步骤完成后不更新 task/task-action.step。")
+ parser.add_argument("--model-retries", type=int, default=1, help="模型/流式接口首次空响应、超时或提示模型无响应时的重试次数。")
parser.add_argument("--max-audiences", type=int, default=3)
parser.add_argument("--max-pathways", type=int, default=3)
parser.add_argument("--max-vulns", type=int, default=3)
parser.add_argument("--max-strategies", type=int, default=3)
+ parser.add_argument("--downstream-audiences", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库受众数量,默认 2。")
+ parser.add_argument("--downstream-pathways", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库传播节点数量,默认 2。")
return parser
@@ -2070,6 +2632,8 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
state_path = Path(args.state)
state = default_state()
+ # Load previous state first, then overlay input JSON and finally CLI flags.
+ # That ordering makes resume safe while still allowing explicit overrides.
if state_path.exists():
loaded = load_json_file(state_path)
if isinstance(loaded, dict):
@@ -2081,9 +2645,7 @@ def main(argv: list[str] | None = None) -> int:
merge_config_into_state(state, config)
input_headers = config.get("headers")
if isinstance(input_headers, dict):
- args.header.extend(f"{k}: {v}" for k, v in input_headers.items())
- args.auth_token = args.auth_token or config.get("auth_token")
- args.authorization = args.authorization or config.get("authorization")
+ args.header.extend(f"{k}: {v}" for k, v in input_headers.items() if str(k).lower() != "authorization")
if config.get("login_username") is not None:
args.login_username = str(config.get("login_username") or "")
if config.get("login_password") is not None:
@@ -2094,6 +2656,10 @@ def main(argv: list[str] | None = None) -> int:
args.no_auto_login = True
if config.get("verify_ssl") is not None:
args.verify_ssl = bool(config.get("verify_ssl"))
+ if config.get("downstream_audiences") is not None:
+ args.downstream_audiences = as_int(config.get("downstream_audiences")) or args.downstream_audiences
+ if config.get("downstream_pathways") is not None:
+ args.downstream_pathways = as_int(config.get("downstream_pathways")) or args.downstream_pathways
cli_config = {
"api_base": args.api_base,
@@ -2105,7 +2671,8 @@ def main(argv: list[str] | None = None) -> int:
}
merge_config_into_state(state, {k: v for k, v in cli_config.items() if v is not None})
api_base = state.get("api_base") or "https://ch1.b.uat.4.cn"
- headers = parse_headers(args.header, args.auth_token, args.authorization)
+ headers = parse_headers(args.header)
+ # ctx owns runtime knobs; state owns business data and resumable ids.
ctx = FlowContext(
api_base=api_base,
headers=headers,
@@ -2118,6 +2685,8 @@ def main(argv: list[str] | None = None) -> int:
max_pathways=max(args.max_pathways, 1),
max_vulns=max(args.max_vulns, 1),
max_strategies=max(args.max_strategies, 1),
+ downstream_audiences=max(args.downstream_audiences, 1),
+ downstream_pathways=max(args.downstream_pathways, 1),
sync_step_status=not args.no_step_status,
model_retries=max(args.model_retries, 0),
verify_ssl=bool(args.verify_ssl),
@@ -2131,11 +2700,17 @@ def main(argv: list[str] | None = None) -> int:
state["_summaryPath"] = str(ctx.summary_path) if ctx.summary_path else ""
state["_autoLogin"] = ctx.auto_login
state["_verifySsl"] = ctx.verify_ssl
+ state["_downstreamLimits"] = {
+ "audiences": ctx.downstream_audiences,
+ "pathways": ctx.downstream_pathways,
+ }
record_event(state, "runner", f"start mode={args.mode} dry_run={ctx.dry_run}")
try:
require_context(state, "taskId", "actionId", "mubiao")
- if ctx.auto_login and not has_authorization(ctx.headers):
- try_auto_login(ctx, state, "runner/auto-login")
+ if ctx.auto_login and not ctx.dry_run:
+ # Always acquire a token through the imported auth package before the first business call.
+ if not try_auto_login(ctx, state, "runner/initial-login", force=True):
+ raise FlowError("启动阶段获取 token 失败,已停止执行,避免无 token 调用业务接口")
if args.mode == "full":
run_full(ctx, state)
else:
@@ -2145,13 +2720,14 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(f"ERROR: {exc}", file=sys.stderr)
+ print(f"错误:{exc}", file=sys.stderr)
return 2
except Exception as exc: # noqa: BLE001
record_error(state, "runner", "未捕获异常", response=traceback.format_exc(), exception=exc)
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
+ print("未捕获异常堆栈:", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
return 1
@@ -2159,20 +2735,23 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(json.dumps(
- {
- "ok": True,
- "mode": args.mode,
- "state": str(state_path),
- "summary": str(ctx.summary_path) if ctx.summary_path else "",
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
- "errors": len(state.get("errors") or []),
- },
- ensure_ascii=False,
- ))
+ # Final JSON is intentionally compact; detailed per-step status has already
+ # been emitted as Chinese step feedback.
+ final_payload = {
+ "日志类型": "执行完成",
+ "是否成功": "是",
+ "执行模式": mode_label(args.mode),
+ "状态文件": str(state_path),
+ "摘要文件": str(ctx.summary_path) if ctx.summary_path else "",
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "已选择受众数": len(state.get("selectedDownstreamAudiences") or []),
+ "已选择传播节点数": len(state.get("selectedDownstreamPathways") or []),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
+ "错误数": len(state.get("errors") or []),
+ }
+ print(LOG_PREFIXES["done"] + " " + json.dumps(final_payload, ensure_ascii=False))
return 0
diff --git a/skills-data/xdfx-split-controller/scripts/xdfx_run_full_steps.py b/skills-data/xdfx-split-controller/scripts/xdfx_run_full_steps.py
new file mode 100644
index 00000000..d6468b51
--- /dev/null
+++ b/skills-data/xdfx-split-controller/scripts/xdfx_run_full_steps.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+"""Run the xdfx flow one step at a time.
+
+The heavy business logic intentionally stays in xdfx_flow.py. This wrapper only
+keeps "full flow" orchestration readable and preserves per-step feedback: every
+subprocess line is flushed immediately, so OP/SAVE/ERROR/STEP feedback remains
+visible to the calling agent.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+
+STEPS = ("step1", "step2", "step3", "step4", "step5", "step6")
+
+STEP_LABELS = {
+ "step1": "受众目标分析与入库",
+ "step2": "关键传播节点分析与入库",
+ "step3": "受众脆弱点分析与入库",
+ "step4": "传播节点脆弱点分析与入库",
+ "step5": "受众策略分析与入库",
+ "step6": "传播节点策略分析与入库",
+}
+
+
+def build_parser() -> argparse.ArgumentParser:
+ """Parse only wrapper-owned flags; pass all other flags to xdfx_flow.py."""
+ parser = argparse.ArgumentParser(
+ description="按六个步骤运行 xdfx_flow.py,并实时转发中文反馈日志。",
+ add_help=True,
+ )
+ parser.add_argument("--from-step", choices=STEPS, default="step1")
+ parser.add_argument("--to-step", choices=STEPS, default="step6")
+ parser.add_argument(
+ "--continue-on-error",
+ action="store_true",
+ help="某一步返回非零退出码时仍继续执行后续步骤。",
+ )
+ return parser
+
+
+def selected_steps(first: str, last: str) -> list[str]:
+ """Return an inclusive step slice for resume/partial reruns."""
+ start = STEPS.index(first)
+ end = STEPS.index(last)
+ if start > end:
+ raise SystemExit("起始步骤必须小于或等于结束步骤")
+ return list(STEPS[start : end + 1])
+
+
+def stream_command(step: str, cmd: list[str]) -> int:
+ """Run one step and forward stdout/stderr lines as they are produced."""
+ print(f"运行步骤:{STEP_LABELS.get(step, step)};执行命令:" + " ".join(cmd), flush=True)
+ env = os.environ.copy()
+ env["PYTHONIOENCODING"] = "utf-8"
+ process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ env=env,
+ )
+ assert process.stdout is not None
+ for line in process.stdout:
+ sys.stdout.buffer.write(line.encode("utf-8", errors="replace"))
+ sys.stdout.buffer.flush()
+ return process.wait()
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args, passthrough = parser.parse_known_args(argv)
+ flow_script = Path(__file__).with_name("xdfx_flow.py")
+ exit_code = 0
+ for step in selected_steps(args.from_step, args.to_step):
+ # Keep all business flags unchanged so the wrapper behaves like a thin
+ # streaming version of repeated xdfx_flow.py --mode stepN calls.
+ cmd = [sys.executable, str(flow_script), "--mode", step, *passthrough]
+ code = stream_command(step, cmd)
+ if code:
+ exit_code = code
+ if not args.continue_on_error:
+ return code
+ return exit_code
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/skills-data/xdfx-split-flow-skill-guide.md b/skills-data/xdfx-split-flow-skill-guide.md
index f40c8834..62d52a6e 100644
--- a/skills-data/xdfx-split-flow-skill-guide.md
+++ b/skills-data/xdfx-split-flow-skill-guide.md
@@ -45,12 +45,12 @@ scripts/xdfx_flow.py
xdfx-flow-state.json
```
-默认会自动处理登录态:缺少 `Authorization` 或接口返回 401 时,脚本会用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/v1/auth/login/username?password=ch%40user`,请求体为 `{"username":"admin"}`,拿到 `access_token` 后写入 `Authorization: Bearer ...` 并重试原接口一次。默认关闭 HTTPS 证书校验,用于兼容 UAT/内网自签证书;需要严格校验证书时追加 `--verify-ssl`。已有 token 时可追加 `--auth-token TOKEN` 或 `--header "Authorization: Bearer TOKEN"`。
+默认会自动处理登录态:脚本启动时先 `import scripts/xdfx_auth.py`,用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/auth/login`,请求体为 `{"username":"admin","password":"ch@user"}`,拿到 `access_token` 后写入内部 `Authorization: Bearer ...`,再调用第一步业务接口;不要通过任何命令行参数传 token。默认关闭 HTTPS 证书校验,用于兼容 UAT/内网自签证书;需要严格校验证书时追加 `--verify-ssl`。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次。
每一步命令结束都会输出一行:
```text
-STEP_FEEDBACK {...}
+步骤反馈 {...}
```
总控智能体必须在每一步结束后立即把这行反馈转述给用户,再执行下一步。
@@ -58,15 +58,23 @@ STEP_FEEDBACK {...}
每次执行具体操作或调用接口前,脚本会输出:
```text
-OP_FEEDBACK {...}
+操作反馈 {...}
```
-总控智能体必须把 `operation`、`method` 和 `url` 转述给用户。
+总控智能体必须把 `操作`、`请求方式` 和 `接口地址` 转述给用户。
+
+每次保存接口成功拿到 `id` 后,脚本会输出:
+
+```text
+入库反馈 {...}
+```
+
+总控智能体必须把 `入库对象类型`、`入库对象名称` 和 `返回ID` 转述给用户,例如“已入库受众目标:xxx,designId=123”或“已入库策略:xxx,id=456”。
如果报错,脚本会立即输出:
```text
-ERROR_FEEDBACK {...}
+错误反馈 {...}
```
总控智能体必须立刻告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
@@ -74,12 +82,12 @@ ERROR_FEEDBACK {...}
如果模型 JSON 经过自动提取和修复仍无法解析,脚本会输出:
```text
-JSON_PARSE_REVIEW {...}
+JSON解析复核 {...}
```
-总控智能体必须查看 `responseExcerpt`,判断是否能识别真正 JSON;无法确认时重跑当前 step 或重新调用对应模型接口。
+总控智能体必须查看 `响应片段`,判断是否能识别真正 JSON;无法确认时重跑当前步骤或重新调用对应模型接口。
-每一步中的流式模型接口第一次返回空响应、`y模型没有响应`、`模型没有响应` 或超时类字符串时,脚本会自动再调用一次;如果第二次仍失败,才写入 `errors` 并在该步骤反馈里体现为 `partial`。
+每一步中的流式模型接口第一次返回空响应、`y模型没有响应`、`模型没有响应` 或超时类字符串时,脚本会自动再调用一次;如果第二次仍失败,才写入 `errors` 并在该步骤反馈里体现为 `部分完成`。
## 推荐执行顺序
@@ -96,23 +104,39 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
如果某一步失败,修复接口、鉴权或输入后,只重跑对应 `stepN` 即可。
+如果需要一条命令完成分步编排,可以在 `xdfx-split-controller` 目录使用包装脚本;它内部仍按 step1-step6 调用执行器,并逐行转发反馈:
+
+```bash
+python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
+```
+
## 每步反馈格式
-脚本会输出三类反馈行:
+脚本会输出五类反馈行:
-- `OP_FEEDBACK`:当前正在做什么、调用哪个接口。
-- `ERROR_FEEDBACK`:当前错误的阶段、接口、状态码、请求摘要和响应摘要。
-- `JSON_PARSE_REVIEW`:模型返回内容无法稳定解析为 JSON,需要执行智能体复核是否重试。
-- `STEP_FEEDBACK`:当前步骤最终状态和累计入库数量。
+- `操作反馈`:当前正在做什么、调用哪个接口。
+- `入库反馈`:当前已入库对象的类型、名称和返回 ID。
+- `错误反馈`:当前错误的阶段、接口、状态码、请求摘要和响应摘要。
+- `JSON解析复核`:模型返回内容无法稳定解析为 JSON,需要执行智能体复核是否重试。
+- `步骤反馈`:当前步骤最终状态和累计入库数量。
-`STEP_FEEDBACK` 中包含:
+`步骤反馈` 中包含:
-- `step`:步骤编号。
-- `label`:步骤名称。
-- `status`:`ok`、`partial` 或 `skipped`。
-- `counts`:当前累计入库数量。
-- `newErrors`:本步骤新增错误数。
-- `totalErrors`:累计错误数。
+- `步骤`:步骤编号。
+- `步骤名称`:步骤名称。
+- `状态`:`成功`、`部分完成` 或 `已跳过`。
+- `执行状态`:中文执行状态。
+- `累计入库数量`:当前累计入库数量。
+- `本步骤新增错误数`:本步骤新增错误数。
+- `累计错误数`:累计错误数。
+
+默认数量规则:
+
+- Step1 入库最优 3 个受众。
+- Step2 入库最优 3 个传播节点。
+- Step3/Step4 只从已入库结果中默认选择前 2 个受众/传播节点继续,每个生成最多 3 个脆弱点;可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。
+- Step5/Step6 对上述选中的每个受众/传播节点默认生成最多 3 个策略。
+- Step5/Step6 的策略模态必须按前端 `modelType` 字典保存为 value 逗号串;脚本会自动把“图片,视频/文本,图片”等文字转成 `task-action-message.modelType` 需要的值。
总控反馈用户时建议使用这种格式:
@@ -120,9 +144,9 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
Step2 关键传播节点分析完成:当前已入库传播节点 X 个,本步骤新增错误 Y 个。
```
-如果 `status=partial`,说明该步骤部分完成,并继续执行后续可执行步骤。
+如果 `状态` 为 `部分完成`,说明该步骤部分完成,并继续执行后续可执行步骤。
-如果 `status=skipped`,说明跳过原因。
+如果 `状态` 为 `已跳过`,说明跳过原因。
## 各步骤职责
@@ -254,7 +278,7 @@ xdfx-flow-summary.md
xdfx-flow-state.json
```
-优先依据本步骤的 `STEP_FEEDBACK` 给即时反馈;最终汇总时再读取完整 summary 和 errors。
+优先依据本步骤的 `步骤反馈` 给即时反馈;最终汇总时再读取完整 summary 和 errors。
如果 `errors` 非空,必须告诉用户:
@@ -266,3 +290,4 @@ xdfx-flow-state.json
- 哪些数据已成功入库,哪些数据未完成。
只要存在错误,就说“部分完成”,不要说“全部完成”。
+
diff --git a/skills-data/xdfx-step1-audience/SKILL.md b/skills-data/xdfx-step1-audience/SKILL.md
index 0496ce2d..5b9f7474 100644
--- a/skills-data/xdfx-step1-audience/SKILL.md
+++ b/skills-data/xdfx-step1-audience/SKILL.md
@@ -24,7 +24,7 @@ python scripts/xdfx_flow.py --mode step1 --input xdfx-input.json --state xdfx-fl
}
```
-鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
+鉴权和证书由脚本兜底处理:脚本启动时先 `import scripts/xdfx_auth.py`,自动用 `admin / ch@user` 获取 `access_token`,并在第一次业务接口调用前写入内部 Authorization;不要通过命令行传 token。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
@@ -32,7 +32,8 @@ python scripts/xdfx_flow.py --mode step1 --input xdfx-input.json --state xdfx-fl
- 自动解析流式 answer 与候选账号。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 抽取结构化受众。
-- 选择最优 3 个受众。
+- 抽取结果中的 `用户编号列表` / `编号id` / `id` 必须对应上一轮 `zhanghao` 账号数组的索引值;脚本按索引依次找到账号并保存。
+- 选择并入库最优 3 个受众。
- 保存受众目标。
- 保存受众目标账号。
- 同步前端步骤状态 `step-analysis-audience` 与 `step-analysis-keypoint`。
@@ -40,12 +41,22 @@ python scripts/xdfx_flow.py --mode step1 --input xdfx-input.json --state xdfx-fl
执行后检查 `xdfx-flow-summary.md`。如果 `errors` 非空,向用户报告失败接口、状态码、响应正文摘要。
+## 失败重试规则
+
+- 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前 Step1 命令。
+- 重试前必须告诉用户失败阶段、接口 URL、状态码、响应摘要和“正在重试 Step1”,不能静默重试。
+- Step1 未重试成功前,不允许继续 Step2;不要因为 Step1 失败就跳过受众分析。
+- Step1 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
+
## 步骤反馈
-命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step1 的完成状态、当前受众入库数量和新增错误数,再决定是否继续下一步。
+命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step1 的完成状态、当前受众入库数量和新增错误数,再决定是否继续下一步。
-执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
+执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。
-如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的受众、受众账号和返回 ID。
+
+如果出现 `错误反馈 {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+
+如果出现 `JSON解析复核 {...}`,必须查看 `响应片段`,判断是否能识别受众 JSON;无法确认时重跑 Step1。
-如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别受众 JSON;无法确认时重跑 Step1。
diff --git a/skills-data/xdfx-step1-audience/scripts/xdfx_auth.py b/skills-data/xdfx-step1-audience/scripts/xdfx_auth.py
new file mode 100644
index 00000000..4003e55d
--- /dev/null
+++ b/skills-data/xdfx-step1-audience/scripts/xdfx_auth.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+"""Authentication helper for xdfx skill scripts.
+
+The caller should not pass tokens through command-line arguments. This module
+owns the fixed admin login flow and returns a Bearer token before business
+interfaces are called.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import ssl
+import urllib.error
+import urllib.request
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass
+class AuthResult:
+ token: str
+ url: str
+ status: int | None
+ raw: str
+
+
+class AuthError(RuntimeError):
+ def __init__(
+ self,
+ message: str,
+ *,
+ url: str,
+ status: int | None = None,
+ response: str = "",
+ exception: BaseException | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.url = url
+ self.status = status
+ self.response = response
+ self.exception = exception
+
+
+def auth_header_value(token: str) -> str:
+ token = token.strip()
+ return token if token.lower().startswith("bearer ") else f"Bearer {token}"
+
+
+def redact_url(url: str) -> str:
+ return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
+
+
+def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
+ if verify_ssl:
+ return None
+ return ssl._create_unverified_context()
+
+
+def build_login_url(api_base: str, path: str) -> str:
+ return path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
+
+
+def extract_access_token(data: Any) -> str | None:
+ if isinstance(data, dict):
+ for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
+ value = data.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ for value in data.values():
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ elif isinstance(data, list):
+ for value in data:
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ return None
+
+
+def parse_json_loose(text: str) -> Any:
+ try:
+ return json.loads(text)
+ except Exception:
+ pass
+ match = re.search(r"[\[{]", text or "")
+ if not match:
+ return {}
+ start = match.start()
+ for end in range(len(text), start, -1):
+ try:
+ return json.loads(text[start:end])
+ except Exception:
+ continue
+ return {}
+
+
+def fetch_access_token(
+ *,
+ api_base: str,
+ username: str = "admin",
+ password: str = "ch@user",
+ login_path: str = "/api/auth/login",
+ timeout: float = 90,
+ verify_ssl: bool = False,
+) -> AuthResult:
+ """Login once and return the extracted access token."""
+ url = build_login_url(api_base, login_path)
+ payload = {"username": username, "password": password}
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ parsed = parse_json_loose(raw)
+ token = extract_access_token(parsed)
+ if not token:
+ raise AuthError(
+ "登录接口已响应但未找到 access_token",
+ url=redact_url(url),
+ status=getattr(resp, "status", None),
+ response=raw,
+ )
+ return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
+ raise AuthError(
+ "登录接口返回错误",
+ url=redact_url(url),
+ status=exc.code,
+ response=body,
+ exception=exc,
+ ) from exc
+ except AuthError:
+ raise
+ except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
+ raise AuthError(
+ "登录接口调用异常",
+ url=redact_url(url),
+ response=str(exc),
+ exception=exc,
+ ) from exc
diff --git a/skills-data/xdfx-step1-audience/scripts/xdfx_flow.py b/skills-data/xdfx-step1-audience/scripts/xdfx_flow.py
index 52880e82..d9580e2f 100644
--- a/skills-data/xdfx-step1-audience/scripts/xdfx_flow.py
+++ b/skills-data/xdfx-step1-audience/scripts/xdfx_flow.py
@@ -26,7 +26,10 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
+import xdfx_auth
+
+# Keep every backend path in one table so endpoint drift is easy to audit.
ENDPOINTS = {
"aiuser": "/gpt/chat/chat_aiuser",
"group": "/gpt/chat/chat_qianqian_group_chat",
@@ -44,10 +47,12 @@ ENDPOINTS = {
"save_narrate": "/api/base/base-narrate",
"save_message": "/api/task/task-action-message",
"save_strategy_relation": "/api/task/task-action-design-strategy-relation",
+ "dict_data": "/api/sys/sys-dict-data",
}
+# Human-readable names are used only in the Chinese feedback logs.
ENDPOINT_LABELS = {
- "login": "登录获取访问 token",
+ "login": "登录获取访问令牌",
"aiuser": "调用候选账号/受众分析模型接口",
"group": "调用受众目标结构化抽取接口",
"spread": "调用关键传播节点结构化抽取接口",
@@ -64,8 +69,90 @@ ENDPOINT_LABELS = {
"save_narrate": "保存策略信息主题",
"save_message": "保存策略消息配置",
"save_strategy_relation": "保存策略与受众/传播节点关系",
+ "dict_data": "读取前端字典数据",
}
+LOG_PREFIXES = {
+ "operation": "操作反馈",
+ "error": "错误反馈",
+ "json_review": "JSON解析复核",
+ "save": "入库反馈",
+ "step": "步骤反馈",
+ "done": "执行完成",
+}
+
+STATUS_LABELS = {
+ "ok": "成功",
+ "partial": "部分完成",
+ "skipped": "已跳过",
+}
+
+STEP_NUMBER_LABELS = {
+ "step1": "第一步",
+ "step2": "第二步",
+ "step3": "第三步",
+ "step4": "第四步",
+ "step5": "第五步",
+ "step6": "第六步",
+}
+
+HTTP_METHOD_LABELS = {
+ "GET": "读取(GET)",
+ "POST": "提交(POST)",
+ "PUT": "更新(PUT)",
+ "PATCH": "局部更新(PATCH)",
+ "DELETE": "删除(DELETE)",
+}
+
+STAGE_SEGMENT_LABELS = {
+ "analyze": "模型分析",
+ "extract": "结构化抽取",
+ "save": "保存数据",
+ "read": "读取数据",
+ "status": "同步状态",
+}
+
+REQUEST_FIELD_LABELS = {
+ "history": "历史消息",
+ "max_tokens": "最大生成令牌数",
+ "model_name": "模型名称",
+ "otherinfo": "其他信息",
+ "prompt_name": "提示词名称",
+ "stream": "是否流式",
+ "temperature": "温度",
+ "usermessage": "用户消息",
+ "user_message": "用户消息",
+ "username": "登录用户名",
+ "password": "登录密码",
+ "mubiao": "行动目标",
+ "zhanghao": "账号列表",
+ "taskId": "任务ID",
+ "actionId": "行动ID",
+ "taskActionId": "行动ID",
+ "designId": "受众ID",
+ "pathwayId": "传播节点ID",
+ "strategyId": "策略ID",
+ "accountId": "账号ID",
+ "accountName": "账号名称",
+ "accountType": "账号类型",
+ "targetAudience": "受众目标",
+ "vulnerabilities": "脆弱点",
+ "topic": "信息标题",
+ "narrate": "叙事主题",
+ "platform": "平台",
+ "persona": "发布人设",
+ "modelType": "信息形式",
+ "quantity": "数量",
+ "publishTime": "发布时间段",
+ "reason": "理由",
+ "ids": "ID列表",
+ "id": "ID",
+ "step": "步骤状态",
+}
+
+SENSITIVE_KEY_PARTS = ("password", "token", "authorization", "access_token", "accesstoken", "authtoken")
+
+# Platform values mirror the frontend dictionaries used by xdfx strategy pages.
PLATFORM_MAP = {
"facebook": 1,
"fb": 1,
@@ -78,6 +165,8 @@ PLATFORM_MAP = {
"yt": 4,
}
+# The model service sometimes returns a successful HTTP response with these
+# strings instead of useful content. Treat them like transient failures.
MODEL_RETRY_MARKERS = (
"y模型没有响应",
"模型没有响应",
@@ -118,6 +207,12 @@ class FlowError(RuntimeError):
@dataclass
class FlowContext:
+ """Runtime options shared by every step.
+
+ Keep this object boring and serializable-ish: it is passed through every
+ helper, while durable progress belongs in the state JSON file.
+ """
+
api_base: str
headers: dict[str, str]
timeout: float
@@ -129,13 +224,15 @@ class FlowContext:
max_pathways: int = 3
max_vulns: int = 3
max_strategies: int = 3
+ downstream_audiences: int = 2
+ downstream_pathways: int = 2
sync_step_status: bool = True
model_retries: int = 1
verify_ssl: bool = False
auto_login: bool = True
login_username: str = "admin"
login_password: str = "ch@user"
- login_path: str = "/api/v1/auth/login/username"
+ login_path: str = "/api/auth/login"
login_attempt_count: int = 0
@@ -153,7 +250,38 @@ def compact(value: Any, limit: int = 1400) -> str:
text = repr(value)
if len(text) <= limit:
return text
- return text[:limit] + f"... "
+ return text[:limit] + f"... <已截断 {len(text) - limit} 个字符>"
+
+
+def is_sensitive_key(key: Any) -> bool:
+ normalized = re.sub(r"[^a-z0-9_]", "", str(key or "").lower())
+ return any(part in normalized for part in SENSITIVE_KEY_PARTS)
+
+
+def redact_sensitive(value: Any) -> Any:
+ if isinstance(value, dict):
+ return {key: "***" if is_sensitive_key(key) else redact_sensitive(item) for key, item in value.items()}
+ if isinstance(value, list):
+ return [redact_sensitive(item) for item in value]
+ if isinstance(value, tuple):
+ return tuple(redact_sensitive(item) for item in value)
+ if isinstance(value, str):
+ text = re.sub(r"(?i)(Bearer\s+)[^\s\",}]+", r"\1***", value)
+ text = re.sub(
+ r"(?i)(\"(?:password|token|authToken|access_token|accessToken|authorization)\"\s*:\s*)\"[^\"]*\"",
+ r'\1"***"',
+ text,
+ )
+ return re.sub(
+ r"(?i)((?:password|token|authToken|access_token|accessToken|authorization)=)[^&\s]+",
+ r"\1***",
+ text,
+ )
+ return value
+
+
+def safe_compact(value: Any, limit: int = 1400) -> str:
+ return compact(redact_sensitive(value), limit)
def stable_json(value: Any) -> str:
@@ -223,6 +351,11 @@ def save_json_file(path: str | Path, value: Any) -> None:
def default_state() -> dict[str, Any]:
+ """Create a resumable state container.
+
+ The script can be run as six independent commands. IDs returned by one step
+ are persisted here so later steps can resume without redoing earlier saves.
+ """
return {
"api_base": "",
"taskId": "",
@@ -247,6 +380,7 @@ def default_state() -> dict[str, Any]:
def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> None:
+ # Accept both frontend-style camelCase and CLI-friendly snake_case inputs.
aliases = {
"task_id": "taskId",
"action_id": "actionId",
@@ -260,16 +394,12 @@ def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> No
continue
if dest in {"taskId", "actionId"}:
state[dest] = str(value)
- elif dest in state or dest in {
- "api_base",
- "headers",
- "auth_token",
- "authorization",
- }:
+ elif dest in state or dest in {"api_base", "headers"}:
state[dest] = value
def record_event(state: dict[str, Any], stage: str, message: str, data: Any | None = None) -> None:
+ """Append a compact diagnostic event to the state file."""
state.setdefault("events", []).append(
{
"time": utc_now(),
@@ -286,9 +416,13 @@ def feedback_step(stage: str) -> str:
return match.group(1) if match else ""
+def feedback_step_label(stage: str) -> str:
+ return STEP_NUMBER_LABELS.get(feedback_step(stage), "")
+
+
def request_keys(payload: Any) -> list[str]:
if isinstance(payload, dict):
- return sorted(str(key) for key in payload.keys())
+ return sorted(REQUEST_FIELD_LABELS.get(str(key), str(key)) for key in payload.keys())
return []
@@ -306,6 +440,77 @@ def endpoint_label(endpoint_key: str | None, method: str, url: str) -> str:
return f"调用接口 {method}"
+def method_label(method: str) -> str:
+ return HTTP_METHOD_LABELS.get(str(method).upper(), str(method))
+
+
+def yes_no(value: bool) -> str:
+ return "是" if value else "否"
+
+
+def stage_label(stage: Any) -> str:
+ text = str(stage or "")
+ if not text:
+ return ""
+ if text in STEP_LABELS:
+ return STEP_LABELS[text]
+ if text == "runner":
+ return "流程执行器"
+ parts = text.split("/")
+ base = parts[0]
+ match = re.search(r"(step[1-6])", base)
+ labels: list[str] = []
+ if match:
+ labels.append(STEP_LABELS.get(match.group(1), match.group(1)))
+ elif base == "runner":
+ labels.append("流程执行器")
+ else:
+ labels.append(base)
+ for segment in parts[1:]:
+ normalized = segment.replace("-", "_")
+ attempt = re.fullmatch(r"model-call-attempt-(\d+)", segment)
+ json_attempt = re.fullmatch(r"model-json-attempt-(\d+)", segment)
+ if attempt:
+ labels.append(f"模型接口第 {attempt.group(1)} 次尝试")
+ elif json_attempt:
+ labels.append(f"JSON解析第 {json_attempt.group(1)} 次尝试")
+ elif segment == "auto-login":
+ labels.append("自动登录")
+ elif segment in STAGE_SEGMENT_LABELS:
+ labels.append(STAGE_SEGMENT_LABELS[segment])
+ elif normalized in ENDPOINT_LABELS:
+ labels.append(ENDPOINT_LABELS[normalized])
+ else:
+ labels.append(segment)
+ return " / ".join(labels)
+
+
+def status_label(status: Any) -> str:
+ if status is None:
+ return ""
+ return STATUS_LABELS.get(str(status), str(status))
+
+
+def mode_label(mode: str) -> str:
+ if mode == "full":
+ return "全流程"
+ return STEP_LABELS.get(mode, mode)
+
+
+def exception_type_label(exception_type: Any) -> str:
+ name = str(exception_type or "")
+ labels = {
+ "HTTPError": "HTTP 调用异常",
+ "URLError": "网络地址异常",
+ "TimeoutError": "请求超时异常",
+ "FlowError": "流程异常",
+ "ValueError": "数据格式异常",
+ "KeyError": "字段缺失异常",
+ "RuntimeError": "运行异常",
+ }
+ return labels.get(name, "程序异常" if name else "")
+
+
def emit_operation_feedback(
state: dict[str, Any],
stage: str,
@@ -317,39 +522,41 @@ def emit_operation_feedback(
attempt: int = 1,
dry_run: bool = False,
) -> None:
+ # Feedback lines are a mini protocol consumed by the calling agent. They
+ # must stay single-line JSON so weak models/tools can relay them reliably.
payload_obj = {
- "type": "OP_FEEDBACK",
- "step": feedback_step(stage),
- "stage": stage,
- "operation": endpoint_label(endpoint_key, method, url),
- "method": method,
- "url": url,
- "endpoint": endpoint_key or "",
- "attempt": attempt,
- "dryRun": dry_run,
- "requestKeys": request_keys(payload),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "操作反馈",
+ "步骤": feedback_step_label(stage),
+ "阶段": stage_label(stage),
+ "操作": endpoint_label(endpoint_key, method, url),
+ "请求方式": method_label(method),
+ "接口地址": url,
+ "接口名称": endpoint_label(endpoint_key, method, url),
+ "第几次尝试": attempt,
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("OP_FEEDBACK " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["operation"] + " " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
def emit_error_feedback(error: dict[str, Any]) -> None:
payload = {
- "type": "ERROR_FEEDBACK",
- "stage": error.get("stage"),
- "step": feedback_step(str(error.get("stage") or "")),
- "message": error.get("message"),
- "url": error.get("url"),
- "status": error.get("status"),
- "request": error.get("request"),
- "response": error.get("response"),
- "exceptionType": error.get("exceptionType"),
- "exception": error.get("exception"),
- "time": error.get("time"),
+ "日志类型": "错误反馈",
+ "阶段": stage_label(error.get("stage")),
+ "步骤": feedback_step_label(str(error.get("stage") or "")),
+ "错误信息": error.get("message"),
+ "接口地址": error.get("url"),
+ "状态码": error.get("status"),
+ "请求摘要": error.get("request"),
+ "响应摘要": error.get("response"),
+ "异常类型": exception_type_label(error.get("exceptionType")),
+ "异常信息": error.get("exception"),
+ "时间": error.get("time"),
}
- print("ERROR_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["error"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def emit_json_parse_review(
@@ -360,18 +567,89 @@ def emit_json_parse_review(
candidate_count: int,
) -> None:
payload = {
- "type": "JSON_PARSE_REVIEW",
- "stage": stage,
- "step": feedback_step(stage),
- "endpoint": endpoint_key,
- "message": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
- "candidateCount": candidate_count,
- "responseExcerpt": compact(text, 3000),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "JSON解析复核",
+ "阶段": stage_label(stage),
+ "步骤": feedback_step_label(stage),
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, endpoint_key),
+ "提示": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
+ "候选数量": candidate_count,
+ "响应片段": compact(text, 3000),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("JSON_PARSE_REVIEW " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["json_review"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
+
+
+SAVE_ENTITY_LABELS = {
+ "save_design": "受众目标",
+ "save_design_account": "受众目标账号",
+ "save_pathway": "关键传播节点",
+ "save_relation": "受众-传播节点关系",
+ "save_design_vuln": "受众脆弱点",
+ "save_pathway_vuln": "传播节点脆弱点",
+ "save_strategy": "策略",
+ "save_channel": "策略平台与人设",
+ "save_narrate": "策略信息主题",
+ "save_message": "策略消息配置",
+ "save_strategy_relation": "策略关联关系",
+}
+
+
+def saved_entity_name(endpoint_key: str, payload: dict[str, Any]) -> str:
+ """Return the most user-readable name for an inserted row."""
+ candidates = [
+ payload.get("targetAudience"),
+ payload.get("accountName"),
+ payload.get("vulnerabilities"),
+ payload.get("topic"),
+ payload.get("narrate"),
+ payload.get("platform"),
+ payload.get("strategyId"),
+ payload.get("designId"),
+ payload.get("pathwayId"),
+ ]
+ for value in candidates:
+ if value is not None and str(value).strip():
+ return str(value).strip()
+ return endpoint_key
+
+
+def emit_save_feedback(
+ state: dict[str, Any],
+ stage: str,
+ endpoint_key: str,
+ payload: dict[str, Any],
+ row_id: int,
+ *,
+ dry_run: bool,
+) -> None:
+ # Emit only after an id is known. Downstream steps use these ids and should
+ # fail loudly rather than inventing relations against missing rows.
+ label = SAVE_ENTITY_LABELS.get(endpoint_key, endpoint_key)
+ name = saved_entity_name(endpoint_key, payload)
+ feedback = {
+ "日志类型": "入库反馈",
+ "步骤": feedback_step(stage),
+ "阶段": stage,
+ "入库对象类型": label,
+ "入库对象名称": name,
+ "返回ID": row_id,
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, label),
+ "接口路径": ENDPOINTS.get(endpoint_key, ""),
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
+ }
+ print(LOG_PREFIXES["save"] + " " + json.dumps(feedback, ensure_ascii=False), flush=True)
+ record_event(
+ state,
+ stage,
+ f"入库{label}成功:{name}",
+ {"id": row_id, "endpoint": endpoint_key, "dryRun": dry_run},
+ )
def record_error(
@@ -385,16 +663,18 @@ def record_error(
response: Any | None = None,
exception: BaseException | None = None,
) -> None:
+ # Persist full-enough error context for post-run diagnosis, then immediately
+ # emit a short line the agent can show to the user.
item = {
"time": utc_now(),
"stage": stage,
"message": message,
"url": url,
"status": status,
- "request": compact(request, 1200) if request is not None else None,
- "response": compact(response, 2000) if response is not None else None,
+ "request": safe_compact(request, 1200) if request is not None else None,
+ "response": safe_compact(response, 2000) if response is not None else None,
"exceptionType": type(exception).__name__ if exception else None,
- "exception": str(exception) if exception else None,
+ "exception": safe_compact(str(exception), 1000) if exception else None,
}
state.setdefault("errors", []).append(item)
state["updatedAt"] = utc_now()
@@ -430,69 +710,29 @@ def url_for_path(ctx: FlowContext, path: str) -> str:
return ctx.api_base.rstrip("/") + "/" + path.lstrip("/")
-def parse_headers(header_values: list[str], auth_token: str | None, authorization: str | None) -> dict[str, str]:
+def parse_headers(header_values: list[str]) -> dict[str, str]:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
- if auth_token:
- headers["Authorization"] = auth_token if auth_token.lower().startswith("bearer ") else f"Bearer {auth_token}"
- if authorization:
- headers["Authorization"] = authorization
for raw in header_values:
if ":" not in raw:
raise FlowError(f"header 格式错误,应为 Key: Value,实际:{raw}")
key, value = raw.split(":", 1)
- headers[key.strip()] = value.strip()
+ normalized = key.strip()
+ if normalized.lower() == "authorization":
+ continue
+ headers[normalized] = value.strip()
return headers
-def has_authorization(headers: dict[str, str]) -> bool:
- return any(key.lower() == "authorization" and str(value).strip() for key, value in headers.items())
-
-
def ssl_context(ctx: FlowContext) -> ssl.SSLContext | None:
if ctx.verify_ssl:
return None
return ssl._create_unverified_context()
-def auth_header_value(token: str) -> str:
- token = token.strip()
- return token if token.lower().startswith("bearer ") else f"Bearer {token}"
-
-
-def extract_access_token(data: Any) -> str | None:
- if isinstance(data, dict):
- for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
- value = data.get(key)
- if isinstance(value, str) and value.strip():
- return value.strip()
- for value in data.values():
- nested = extract_access_token(value)
- if nested:
- return nested
- elif isinstance(data, list):
- for value in data:
- nested = extract_access_token(value)
- if nested:
- return nested
- return None
-
-
-def redact_url(url: str) -> str:
- return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
-
-
-def build_login_url(ctx: FlowContext, path: str) -> str:
- base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else url_for_path(ctx, path)
- if ctx.login_password:
- separator = "&" if "?" in base else "?"
- base = f"{base}{separator}password={urllib.parse.quote(ctx.login_password, safe='')}"
- return base
-
-
def login_candidate_paths(ctx: FlowContext) -> list[str]:
paths = [
- ctx.login_path or "/api/v1/auth/login/username",
- "/api/v1/auth/login/username",
+ ctx.login_path or "/api/auth/login",
+ "/api/auth/login",
]
seen: set[str] = set()
unique: list[str] = []
@@ -506,6 +746,7 @@ def login_candidate_paths(ctx: FlowContext) -> list[str]:
def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force: bool = False) -> bool:
+ """Fetch a fresh token through the local auth package before business calls."""
if not ctx.auto_login:
return False
if ctx.dry_run:
@@ -515,18 +756,17 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
record_error(state, stage, "自动登录失败:缺少 login_username")
return False
if not force and ctx.login_attempt_count > 0:
- return has_authorization(ctx.headers)
+ return any(key.lower() == "authorization" and str(value).strip() for key, value in ctx.headers.items())
if force and ctx.login_attempt_count >= 2:
return False
ctx.login_attempt_count += 1
- payload = {"username": ctx.login_username}
+ payload = {"username": ctx.login_username, "password": ctx.login_password}
last_status: int | None = None
last_response = ""
last_exc: BaseException | None = None
for attempt, path in enumerate(login_candidate_paths(ctx), start=1):
- url = build_login_url(ctx, path)
- display_url = redact_url(url)
+ display_url = xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, path))
emit_operation_feedback(
state,
stage,
@@ -537,63 +777,39 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
attempt=attempt,
)
try:
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- headers = {
- "Content-Type": "application/json",
- "Accept": "application/json",
- }
- req = urllib.request.Request(url, data=data, headers=headers, method="POST")
- with urllib.request.urlopen(req, timeout=ctx.timeout, context=ssl_context(ctx)) as resp:
- raw = resp.read().decode("utf-8", errors="replace")
- parsed = parse_json_lenient(raw)
- token = extract_access_token(parsed)
- if not token:
- last_status = getattr(resp, "status", None)
- last_response = raw
- record_event(
- state,
- stage,
- "登录接口已响应但未找到 access_token",
- {"url": display_url, "status": last_status, "response": compact(raw, 1000)},
- )
- continue
- ctx.headers["Authorization"] = auth_header_value(token)
- record_event(
- state,
- stage,
- "自动登录成功,已写入 Authorization Bearer token",
- {
- "url": display_url,
- "status": getattr(resp, "status", None),
- "username": ctx.login_username,
- },
- )
- return True
- except urllib.error.HTTPError as exc:
- last_status = exc.code
- last_response = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
- last_exc = exc
- record_event(
- state,
- stage,
- "登录接口返回错误,准备尝试下一个候选登录路径",
- {"url": display_url, "status": exc.code, "response": compact(last_response, 1000)},
+ result = xdfx_auth.fetch_access_token(
+ api_base=ctx.api_base,
+ username=ctx.login_username,
+ password=ctx.login_password,
+ login_path=path,
+ timeout=ctx.timeout,
+ verify_ssl=ctx.verify_ssl,
)
- except Exception as exc: # noqa: BLE001 - auto-login must report every failure.
- last_exc = exc
- last_response = traceback.format_exc()
+ ctx.headers["Authorization"] = xdfx_auth.auth_header_value(result.token)
+ state["_tokenSource"] = "xdfx_auth"
record_event(
state,
stage,
- "登录接口调用异常,准备尝试下一个候选登录路径",
- {"url": display_url, "exceptionType": type(exc).__name__, "exception": str(exc)},
+ "启动阶段已通过 xdfx_auth 包获取 token,并写入 Authorization",
+ {"url": result.url, "status": result.status, "username": ctx.login_username},
+ )
+ return True
+ except xdfx_auth.AuthError as exc:
+ last_status = exc.status
+ last_response = exc.response
+ last_exc = exc
+ record_event(
+ state,
+ stage,
+ "xdfx_auth 获取 token 失败,准备尝试下一个候选登录路径",
+ {"url": exc.url, "status": exc.status, "response": safe_compact(exc.response, 1000), "exception": str(exc)},
)
record_error(
state,
stage,
- "自动登录失败,未能获取 access_token",
- url=redact_url(build_login_url(ctx, ctx.login_path or "/api/v1/auth/login/username")),
+ "自动登录失败,未能通过 xdfx_auth 获取 access_token",
+ url=xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, ctx.login_path or "/api/auth/login")),
status=last_status,
request=payload,
response=last_response,
@@ -612,6 +828,7 @@ def http_request_json_url(
endpoint_key: str | None = None,
allow_auto_login: bool = True,
) -> tuple[Any | None, str]:
+ """Call an HTTP JSON endpoint with logging, retries, and 401 recovery."""
method = method.upper()
if ctx.dry_run:
emit_operation_feedback(state, stage, method, url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
@@ -640,6 +857,7 @@ def http_request_json_url(
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
if exc.code == 401 and allow_auto_login and ctx.auto_login:
+ # Refresh once on 401, then replay the original request exactly.
refreshed = try_auto_login(ctx, state, f"{stage}/auto-login", force=True)
if refreshed:
record_event(
@@ -709,6 +927,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
emit_operation_feedback(state, stage, "POST", url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
record_event(state, stage, f"DRY RUN POST {url}", {"payload": payload})
if endpoint_key.startswith("save_"):
+ # Synthetic ids let downstream dry-run code exercise relation wiring.
synthetic_id = abs(hash(stage + stable_json(payload))) % 100000000
return {"id": synthetic_id, "dryRun": True}, json.dumps({"id": synthetic_id, "dryRun": True})
return {"dryRun": True}, json.dumps({"dryRun": True})
@@ -716,6 +935,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
def parse_json_lenient(text: str) -> Any | None:
+ """Parse normal JSON plus adjacent/SSE-like JSON objects."""
stripped = text.strip()
if not stripped:
return None
@@ -730,6 +950,7 @@ def parse_json_lenient(text: str) -> Any | None:
def parse_concatenated_json_objects(text: str) -> list[Any]:
+ """Read streams such as {"answer": "..."}{"answer": "..."} or data: lines."""
decoder = json.JSONDecoder()
objects: list[Any] = []
i = 0
@@ -766,6 +987,7 @@ def parse_concatenated_json_objects(text: str) -> list[Any]:
def model_text_from_response(parsed: Any, raw: str) -> tuple[str, list[Any]]:
+ """Flatten streamed model chunks into one text buffer."""
objects: list[Any]
if isinstance(parsed, list):
objects = parsed
@@ -824,6 +1046,7 @@ def extract_json_from_text(text: str) -> Any | None:
def extract_json_candidates_from_text(text: str) -> list[Any]:
+ """Collect every plausible JSON object/array from messy model text."""
cleaned = strip_think(text)
candidates: list[Any] = []
seen: set[str] = set()
@@ -860,6 +1083,7 @@ def extract_json_candidates_from_text(text: str) -> list[Any]:
def select_json_candidate(candidates: list[Any], endpoint_key: str, stage: str) -> Any | None:
+ """Pick the candidate whose keys best match the current step."""
if not candidates:
return None
scored = [(json_candidate_score(candidate, endpoint_key, stage), idx, candidate) for idx, candidate in enumerate(candidates)]
@@ -1010,6 +1234,7 @@ def extract_first_balanced(text: str, open_ch: str, close_ch: str) -> str | None
def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[str, list[Any], Any | None]:
+ """Call a model endpoint and retry only known empty/timeout/no-response cases."""
max_attempts = max(ctx.model_retries, 0) + 1
last_text = ""
last_objects: list[Any] = []
@@ -1048,6 +1273,11 @@ def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str,
def call_model_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[dict[str, Any], str, list[Any]]:
+ """Return the best structured JSON for a model step.
+
+ If the first parse fails, the second attempt includes an explicit repair
+ instruction so weak models can re-emit only the required JSON.
+ """
last_text = ""
last_objects: list[Any] = []
last_candidate_count = 0
@@ -1202,23 +1432,85 @@ def profile_info(account: dict[str, Any]) -> str:
def parse_index_list(value: Any) -> list[str]:
- if value is None:
- return []
- if isinstance(value, list):
- return [str(v).strip() for v in value if str(v).strip()]
- text = str(value)
- return [part.strip().strip("<>").strip() for part in re.split(r"[,,;;、\s]+", text) if part.strip()]
+ refs: list[str] = []
+
+ def add(raw: Any) -> None:
+ if raw is None:
+ return
+ if isinstance(raw, dict):
+ keys = (
+ "编号id",
+ "编号ID",
+ "用户编号id",
+ "用户编号ID",
+ "用户编号",
+ "账号编号",
+ "引用序号",
+ "索引",
+ "index",
+ "id",
+ "ids",
+ "accountIndex",
+ "accountIndexes",
+ "userIndex",
+ "userIndexes",
+ )
+ for key in keys:
+ if key in raw:
+ add(raw.get(key))
+ return
+ if isinstance(raw, (list, tuple, set)):
+ for item in raw:
+ add(item)
+ return
+ text = str(raw).strip()
+ if not text:
+ return
+ angle_refs = re.findall(r"<<\s*([^<>]+?)\s*>>", text)
+ if angle_refs:
+ for token in angle_refs:
+ add(token)
+ return
+ for part in re.split(r"[,,;;、\s]+", text):
+ token = part.strip().strip("<>").strip()
+ if token:
+ refs.append(token)
+
+ add(value)
+ deduped: list[str] = []
+ seen: set[str] = set()
+ for ref in refs:
+ if ref in seen:
+ continue
+ seen.add(ref)
+ deduped.append(ref)
+ return deduped
+
+
+def account_ref_numbers(account: dict[str, Any]) -> set[int]:
+ numbers: set[int] = set()
+ meta = account.get("metadata") if isinstance(account.get("metadata"), dict) else {}
+ for key in ("编号id", "编号ID", "用户编号id", "用户编号ID", "用户编号", "账号编号", "引用序号", "索引", "index", "accountIndex"):
+ for container in (account, meta):
+ number = as_int(container.get(key)) if isinstance(container, dict) else None
+ if number is not None:
+ numbers.add(number)
+ return numbers
def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, Any] | None:
+ """Resolve model references like <<3>>, index numbers, names, or account ids."""
if not accounts:
return None
text = str(ref).strip().strip("<>").strip()
number = as_int(text)
if number is not None:
for account in accounts:
- if any(as_int(account.get(key)) == number for key in ("引用序号", "索引", "index")):
+ if number in account_ref_numbers(account):
return account
+ # Step1 group extraction returns 编号/id values that refer to the
+ # previous zhanghao array index. Prefer exact index fields above, then
+ # fall back to zero-based and one-based array positions.
if 0 <= number < len(accounts):
return accounts[number]
if 1 <= number <= len(accounts):
@@ -1232,11 +1524,25 @@ def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, A
def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Normalize audience JSON whether the model used numbered fields or items[]."""
items: list[dict[str, Any]] = []
for i in range(1, 21):
group = first_present(data.get(f"群体{i}"), data.get(f"受众{i}"), data.get(f"targetAudience{i}"))
reason = first_present(data.get(f"理由{i}"), data.get(f"推荐理由{i}"), data.get(f"recReason{i}"))
- users = first_present(data.get(f"用户编号列表{i}"), data.get(f"用户列表{i}"), data.get(f"账号编号列表{i}"))
+ users = first_present(
+ data.get(f"用户编号列表{i}"),
+ data.get(f"编号id列表{i}"),
+ data.get(f"编号ID列表{i}"),
+ data.get(f"用户编号id列表{i}"),
+ data.get(f"用户编号ID列表{i}"),
+ data.get(f"用户列表{i}"),
+ data.get(f"账号编号列表{i}"),
+ data.get(f"索引列表{i}"),
+ data.get(f"编号{i}"),
+ data.get(f"id{i}"),
+ data.get(f"ids{i}"),
+ data.get(f"索引{i}"),
+ )
if group:
items.append(
{
@@ -1255,7 +1561,27 @@ def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
{
"targetAudience": str(group).strip(),
"recReason": str(first_present(item.get("理由"), item.get("recReason"), item.get("reason")) or "").strip(),
- "userIndexes": parse_index_list(first_present(item.get("用户编号列表"), item.get("users"), item.get("accounts"))),
+ "userIndexes": parse_index_list(
+ first_present(
+ item.get("用户编号列表"),
+ item.get("编号id列表"),
+ item.get("编号ID列表"),
+ item.get("用户编号id列表"),
+ item.get("用户编号ID列表"),
+ item.get("编号id"),
+ item.get("编号ID"),
+ item.get("用户编号id"),
+ item.get("用户编号ID"),
+ item.get("编号"),
+ item.get("索引"),
+ item.get("id"),
+ item.get("ids"),
+ item.get("users"),
+ item.get("accounts"),
+ item.get("userIndexes"),
+ item.get("accountIndexes"),
+ )
+ ),
"rank": i,
}
)
@@ -1272,6 +1598,7 @@ def audience_score(item: dict[str, Any]) -> tuple[int, int]:
def select_top_audiences(items: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
+ """Deduplicate audience names and keep the strongest candidates."""
unique: dict[str, dict[str, Any]] = {}
for item in items:
key = item.get("targetAudience", "")
@@ -1301,6 +1628,7 @@ def pathway_score(item: dict[str, Any]) -> tuple[int, int, int]:
def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Merge the same account found from different audiences before saving top 3."""
merged: dict[str, dict[str, Any]] = {}
for item in candidates:
account = item.get("account") or {}
@@ -1325,6 +1653,7 @@ def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str,
def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
+ """Normalize weak-point model output into a capped list."""
vulns: list[dict[str, str]] = []
for i in range(1, 21):
vuln = first_present(data.get(f"脆弱点{i}"), data.get(f"vulnerability{i}"), data.get(f"vuln{i}"))
@@ -1339,6 +1668,7 @@ def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
def extract_strategies(data: dict[str, Any], limit: int) -> list[dict[str, Any]]:
+ """Normalize strategy output and preserve the modal text for later mapping."""
strategies: list[dict[str, Any]] = []
for i in range(1, 21):
title = first_present(data.get(f"信息标题{i}"), data.get(f"主题{i}"), data.get(f"topic{i}"))
@@ -1395,11 +1725,29 @@ def response_id(response: Any) -> int | None:
return None
+def response_rows(response: Any) -> list[Any]:
+ """Extract list rows from common CRUD response shapes."""
+ if isinstance(response, list):
+ return response
+ if not isinstance(response, dict):
+ return []
+ for key in ("data", "rows", "items", "records", "list"):
+ value = response.get(key)
+ if isinstance(value, list):
+ return value
+ rows = response_rows(value)
+ if rows:
+ return rows
+ return []
+
+
def save_with_id(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[int | None, Any | None]:
response, _ = http_post_json(ctx, state, endpoint_key, payload, stage)
rid = response_id(response)
if rid is None:
- record_error(state, stage, "保存接口未返回 id,视为失败", request=payload, response=response)
+ record_error(state, stage, "保存接口未返回 ID,视为失败", request=payload, response=response)
+ else:
+ emit_save_feedback(state, stage, endpoint_key, payload, rid, dry_run=ctx.dry_run)
return rid, response
@@ -1429,6 +1777,7 @@ def parse_step_flags(raw: Any) -> dict[str, Any]:
def mark_step_status(ctx: FlowContext, state: dict[str, Any], links: list[str], stage: str) -> None:
+ """Update the frontend step bar without overwriting unrelated step flags."""
if not ctx.sync_step_status:
record_event(state, stage, "跳过 task-action.step 状态同步")
return
@@ -1477,12 +1826,126 @@ def strategy_platform(value: Any) -> Any:
return platform_to_number(value) or 1
-def strategy_model_type(value: Any) -> str:
- # Frontend defaults to text model type "1" when no modal type is selected.
- raw = first_present(value, "1")
- if isinstance(raw, list):
- return ",".join(str(v) for v in raw if str(v).strip()) or "1"
- return str(raw or "1")
+def choice_tokens(value: Any) -> list[Any]:
+ """Split frontend choice values from arrays, JSON strings, and mixed delimiters."""
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return choice_tokens(
+ first_present(value.get("value"), value.get("dictValue"), value.get("label"), value.get("dictLabel"))
+ )
+ if isinstance(value, (list, tuple, set)):
+ tokens: list[Any] = []
+ for item in value:
+ tokens.extend(choice_tokens(item))
+ return tokens
+ text = str(value).strip()
+ if not text:
+ return []
+ if text[0] in "[{":
+ try:
+ parsed = json.loads(text)
+ if parsed != value:
+ return choice_tokens(parsed)
+ except Exception:
+ pass
+ return [part.strip() for part in re.split(r"[,,、;/|]+", text) if part.strip()]
+
+
+def dict_choices(
+ ctx: FlowContext,
+ state: dict[str, Any],
+ dict_type: str,
+ stage: str,
+) -> list[dict[str, Any]]:
+ """Fetch and cache frontend dictionary choices such as modelType."""
+ cache = state.setdefault("_dictChoiceCache", {})
+ if dict_type in cache:
+ cached = cache[dict_type]
+ return cached if isinstance(cached, list) else []
+ if ctx.dry_run:
+ cache[dict_type] = []
+ return []
+
+ query = urllib.parse.urlencode({"filter": f"dictType||$eq||{dict_type}", "limit": 999, "page": 1})
+ url = f"{url_for(ctx, 'dict_data')}?{query}"
+ response, _ = http_request_json_url(ctx, state, "GET", url, None, stage, endpoint_key="dict_data")
+ choices: list[dict[str, Any]] = []
+ for row in response_rows(response):
+ if not isinstance(row, dict):
+ continue
+ label = str(first_present(row.get("dictLabel"), row.get("label"), "") or "").strip()
+ raw_value = first_present(row.get("dictValue"), row.get("value"))
+ if not label or raw_value is None:
+ continue
+ number = as_int(raw_value)
+ choices.append({"label": label, "value": number if number is not None else str(raw_value).strip()})
+ for alias in choice_tokens(row.get("alias")):
+ alias_label = str(alias).strip()
+ if alias_label:
+ choices.append({"label": alias_label, "value": number if number is not None else str(raw_value).strip()})
+ cache[dict_type] = choices
+ return choices
+
+
+def choice_value_by_label(choices: list[dict[str, Any]] | None, label: str) -> Any | None:
+ needle = re.sub(r"\s+", "", str(label or "").lower())
+ if not needle:
+ return None
+ for item in choices or []:
+ item_label = re.sub(r"\s+", "", str(item.get("label") or "").lower())
+ if item_label and (needle == item_label or needle in item_label or item_label in needle):
+ return item.get("value")
+ return None
+
+
+def model_type_token_value(token: Any, choices: list[dict[str, Any]] | None = None) -> Any:
+ """Convert modal labels like 图片/视频 into frontend dictionary values."""
+ number = as_int(token)
+ if number is not None:
+ return number
+
+ text = str(token or "").strip()
+ lower = text.lower()
+ exact = choice_value_by_label(choices, text)
+ if exact is not None:
+ return exact
+
+ label = ""
+ fallback_value = 2
+ if "视" in text or "video" in lower:
+ label, fallback_value = "视频", 3
+ elif "图" in text or "image" in lower or "picture" in lower or "photo" in lower:
+ label, fallback_value = "图片", 2
+ elif "音" in text or "audio" in lower or "sound" in lower:
+ label, fallback_value = "音频", 4
+ elif "发文" in text or "文本" in text or "文字" in text or "text" in lower:
+ label, fallback_value = "文本", 1
+
+ if label:
+ matched = choice_value_by_label(choices, label)
+ if matched is not None:
+ return matched
+ return fallback_value
+
+ # Mirrors the frontend helper's unknown-token default.
+ return 2
+
+
+def strategy_model_type(value: Any, choices: list[dict[str, Any]] | None = None) -> str:
+ """Return the comma-separated value string expected by task-action-message."""
+ tokens = choice_tokens(value)
+ if not tokens:
+ # Frontend save paths fall back to "1" when no modal type is selected.
+ return "1"
+ values: list[str] = []
+ seen: set[str] = set()
+ for token in tokens:
+ normalized = str(model_type_token_value(token, choices)).strip()
+ if normalized and normalized not in seen:
+ values.append(normalized)
+ seen.add(normalized)
+ return ",".join(values) or "1"
def strategy_language(value: Any) -> int:
@@ -1501,11 +1964,28 @@ def save_strategy_details(
*,
audience: bool,
) -> None:
- platform = strategy_platform(first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})))
+ """Save all frontend-readable strategy child rows.
+
+ The strategy table and arrangement page do not read only the main strategy
+ row; they also expect channel, narrate, message, and relation rows.
+ """
+ platform = strategy_platform(
+ first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
+ )
+ account_type = str(
+ first_present(
+ strategy.get("accountType"),
+ item.get("accountType"),
+ item.get("type"),
+ "目标受众账号" if audience else "关键传播节点账号",
+ )
+ or ""
+ ).strip()
persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
title = str(strategy.get("title") or "").strip()
+ model_type_choices = dict_choices(ctx, state, "modelType", stage + "/dict-modelType")
channel_id, _ = save_with_id(
ctx,
@@ -1516,6 +1996,7 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"platform": platform,
+ "accountType": account_type,
"personaJson": json.dumps([{"persona": persona, "size": persona_size}], ensure_ascii=False),
},
stage + "/save-channel",
@@ -1531,7 +2012,10 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"channelId": channel_id,
- "modelType": strategy_model_type(first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType"))),
+ "modelType": strategy_model_type(
+ first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType")),
+ model_type_choices,
+ ),
"language": strategy_language(first_present(strategy.get("language"), strategy.get("语种"))),
"respondingCount": responding_count,
"messageCount": responding_count,
@@ -1564,7 +2048,46 @@ def object_for_audience(audience: dict[str, Any]) -> dict[str, Any]:
}
+def selected_accounts_for_item(item: dict[str, Any], *, audience: bool) -> list[Any]:
+ if audience:
+ accounts = item.get("accounts") or []
+ return accounts or [object_for_audience(item)]
+ return [item.get("account") or item]
+
+
+def downstream_limit(ctx: FlowContext, *, audience: bool) -> int:
+ """Limit expensive downstream analysis while keeping 3 upstream rows stored."""
+ raw = ctx.downstream_audiences if audience else ctx.downstream_pathways
+ return max(1, raw)
+
+
+def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, audience: bool) -> list[dict[str, Any]]:
+ """Select the 1-2 best stored rows for vulnerability/strategy generation."""
+ source_key = "topAudiences" if audience else "topPathways"
+ id_key = "designId" if audience else "pathwayAccountId"
+ selected_key = "selectedDownstreamAudiences" if audience else "selectedDownstreamPathways"
+ items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)]
+ limit = downstream_limit(ctx, audience=audience)
+ selected = items[:limit]
+ state[selected_key] = [
+ {
+ "id": item.get(id_key),
+ "name": item.get("targetAudience") if audience else item.get("accountName"),
+ }
+ for item in selected
+ ]
+ if items:
+ record_event(
+ state,
+ stage,
+ f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}",
+ {"limit": limit, "selected": state[selected_key]},
+ )
+ return selected
+
+
def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze audiences, save top 3, and attach any referenced accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step1-audience"
if state.get("topAudiences") and not ctx.dry_run:
@@ -1582,12 +2105,15 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
state["audienceRawText"] = text
+ # The first model call may return raw text plus embedded account objects.
accounts = accounts_from_response(parsed, objects, text)
state["audienceAccounts"] = accounts
+ # The second model call converts the long audience analysis into numbered
+ # groups that can be saved as task-action-design rows.
group_payload = {
"mubiao": state["mubiao"],
- "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
"zhanghao": accounts,
"history": [],
"stream": True,
@@ -1602,6 +2128,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
top = select_top_audiences(candidates, ctx.max_audiences)
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the audience first; account rows and downstream analyses depend
+ # on the returned designId.
design_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
@@ -1614,6 +2142,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
continue
item = {**item, "designId": design_id, "saveResponse": resp, "accounts": [], "savedAccountIds": []}
for ref in item.get("userIndexes") or []:
+ # Model references are intentionally fuzzy, so resolve them against
+ # both explicit indexes and account names/ids.
account = resolve_account_ref(ref, accounts)
if account is None:
record_error(state, stage + "/save-design-account", f"用户编号 {ref} 未匹配到候选账号")
@@ -1645,6 +2175,7 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze key pathway accounts for saved audiences and store top 3."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step2-pathway"
audiences = [a for a in state.get("topAudiences") or [] if a.get("designId")]
@@ -1656,6 +2187,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
return
all_candidates: list[dict[str, Any]] = []
for audience in audiences:
+ # Analyze pathway candidates in the context of each saved audience.
payload = {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。",
"otherinfo": {
@@ -1673,6 +2205,8 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
accounts = accounts_from_response(parsed, objects, text)
+ # Ask the model to point back to candidate accounts, then resolve those
+ # references to concrete account objects.
spread_payload = {
"mubiao": state["mubiao"],
"user_message": "分析其中关键信息。请从候选账号中抽取核心传播节点,字段必须是:账号1、理由1、账号2、理由2 ...,最多输出5个。账号字段优先返回候选数组的引用编号或索引,只返回JSON。",
@@ -1707,6 +2241,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways]
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the account row before writing the design-pathway relation.
account = item["account"]
aid = account_id(account)
name = account_name(account)
@@ -1757,18 +2292,20 @@ def run_step4(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate and save weak points for selected audiences or pathway accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step3-audience-vulns" if audience else "step4-pathway-vulns"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
output_key = "audienceVulns" if audience else "pathwayVulns"
state.setdefault(output_key, {})
for item in items:
+ # owner_id is designId for audiences and pathwayAccountId for accounts.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
if not owner_id:
continue
if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run:
continue
- zhanghao = item.get("accounts") or [object_for_audience(item)] if audience else [item.get("account") or item]
+ zhanghao = selected_accounts_for_item(item, audience=audience)
prompt = (
"你是中国对台湾方向的认知战专家,你要传播行动要求的叙事主题。请基于该受众目标的画像、关注议题、社会分工定位、利益诉求与上一轮账号佐证,分析该受众目标有哪些脆弱点可以成为后续策略设计的关键要素。请输出理由、脆弱点1、理由1、脆弱点2、理由2、脆弱点3、理由3,只返回JSON。"
if audience
@@ -1789,6 +2326,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) ->
vulns = extract_vulns(data, ctx.max_vulns)
saved: list[dict[str, Any]] = []
for vuln in vulns:
+ # The two weak-point tables have nearly identical payloads but
+ # different foreign-key names.
if audience:
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
@@ -1835,14 +2374,16 @@ def run_step6(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate strategies from saved weak points and persist frontend child rows."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step5-audience-strategy" if audience else "step6-pathway-strategy"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {}
output_key = "audienceStrategies" if audience else "pathwayStrategies"
state.setdefault(output_key, [])
existing_topics = {s.get("topic") for s in state[output_key] if isinstance(s, dict)}
for item in items:
+ # Strategies are only meaningful after the matching weak points exist.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
vulns = vulns_by_owner.get(str(owner_id)) or []
vuln_texts = [v.get("vulnerabilities") for v in vulns if v.get("vulnerabilities")]
@@ -1850,7 +2391,9 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
record_error(state, stage, "没有可用脆弱点,跳过策略生成", request=item)
continue
target_names = [item.get("targetAudience")] if audience else (item.get("relatedAudiences") or [item.get("accountName")])
- pingtai = state.get("platform") or (platform_label(item.get("account") or {}) if not audience else "")
+ zhanghao = selected_accounts_for_item(item, audience=audience)
+ first_account = zhanghao[0] if zhanghao and isinstance(zhanghao[0], dict) else {}
+ pingtai = state.get("platform") or platform_label(first_account)
payload = {
"user_message": DEFAULT_STRATEGY_PROMPT_AUDIENCE if audience else DEFAULT_STRATEGY_PROMPT_PATHWAY,
"cuiruodian": vuln_texts,
@@ -1858,6 +2401,7 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
"shouzhong": [name for name in target_names if name],
"mudi": extract_goal(state["mubiao"]),
"heiliao": [],
+ "zhanghao": zhanghao,
"history": [],
"stream": True,
"model_name": "OpenAI3",
@@ -1876,16 +2420,20 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
continue
if topic in existing_topics:
continue
+ # Save the main row first; channel/narrate/message/relation rows all
+ # require the returned strategy id.
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
"targetAudience": target,
"accountType": account_type,
"topic": topic,
- "strategyTitle": "",
+ "strategyTitle": str(strategy.get("title") or "").strip(),
}
strategy_id, resp = save_with_id(ctx, state, "save_strategy", save_payload, stage + "/save")
if strategy_id is not None:
+ # Frontend strategy pages read these child tables, so a strategy
+ # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience)
existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp})
@@ -1914,13 +2462,14 @@ STEP_LABELS = {
def state_counts(state: dict[str, Any]) -> dict[str, int]:
+ """Summarize stored objects for step feedback."""
return {
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceVulns": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
- "pathwayVulns": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "受众脆弱点数量": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
+ "传播节点脆弱点数量": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
}
@@ -1932,23 +2481,26 @@ def emit_step_feedback(
status: str | None = None,
note: str = "",
) -> None:
+ # One feedback line per step keeps the single-skill and split-skill versions
+ # equally transparent to the user.
total_errors = len(state.get("errors") or [])
new_errors = max(0, total_errors - before_error_count)
final_status = status or ("partial" if new_errors else "ok")
payload = {
- "type": "STEP_FEEDBACK",
- "step": step,
- "label": STEP_LABELS.get(step, step),
- "status": final_status,
- "note": note,
- "newErrors": new_errors,
- "totalErrors": total_errors,
- "counts": state_counts(state),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "updatedAt": state.get("updatedAt"),
+ "日志类型": "步骤反馈",
+ "步骤": STEP_NUMBER_LABELS.get(step, step),
+ "步骤名称": STEP_LABELS.get(step, step),
+ "状态": STATUS_LABELS.get(final_status, final_status),
+ "执行状态": status_label(final_status),
+ "备注": note,
+ "本步骤新增错误数": new_errors,
+ "累计错误数": total_errors,
+ "累计入库数量": state_counts(state),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "更新时间": state.get("updatedAt"),
}
- print("STEP_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["step"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def finish_step(
@@ -1960,6 +2512,8 @@ def finish_step(
status: str | None = None,
note: str = "",
) -> None:
+ # Always persist before emitting step feedback so the user can inspect files
+ # immediately after a reported step completes.
state["updatedAt"] = utc_now()
save_json_file(ctx.state_path, state)
if ctx.summary_path:
@@ -1980,6 +2534,11 @@ def skip_step_with_feedback(ctx: FlowContext, state: dict[str, Any], step: str,
def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Legacy full runner.
+
+ The wrapper script is preferred for real-time logs, but this remains useful
+ for dry-runs and unattended local troubleshooting.
+ """
run_step_with_feedback(ctx, state, "step1")
run_step_with_feedback(ctx, state, "step2")
run_step_with_feedback(ctx, state, "step3")
@@ -1995,6 +2554,7 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
def write_summary(path: Path, state: dict[str, Any]) -> None:
+ """Write a human-readable checkpoint beside the machine state JSON."""
audience_vuln_count = sum(len(v) for v in (state.get("audienceVulns") or {}).values())
pathway_vuln_count = sum(len(v) for v in (state.get("pathwayVulns") or {}).values())
lines = [
@@ -2005,6 +2565,8 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
f"- actionId:{state.get('actionId')}",
f"- 受众入库:{len(state.get('topAudiences') or [])}",
f"- 传播节点入库:{len(state.get('topPathways') or [])}",
+ f"- 下游受众选择:{len(state.get('selectedDownstreamAudiences') or [])}",
+ f"- 下游传播节点选择:{len(state.get('selectedDownstreamPathways') or [])}",
f"- 受众脆弱点入库:{audience_vuln_count}",
f"- 传播节点脆弱点入库:{pathway_vuln_count}",
f"- 受众策略入库:{len(state.get('audienceStrategies') or [])}",
@@ -2021,9 +2583,9 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
if state.get("errors"):
lines += ["", "## 失败明细"]
for err in state["errors"]:
- lines.append(f"- [{err.get('stage')}] {err.get('message')} status={err.get('status')} url={err.get('url')}")
+ lines.append(f"- [{stage_label(err.get('stage'))}] {err.get('message')} 状态码={err.get('status')} 接口={err.get('url')}")
if err.get("response"):
- lines.append(f" - response: {err.get('response')}")
+ lines.append(f" - 响应摘要:{err.get('response')}")
text = "\n".join(lines) + "\n"
try:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -2034,34 +2596,34 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
def build_arg_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(description="Run xdfx analysis/storage flow.")
+ parser = argparse.ArgumentParser(description="运行 xdfx 分析与入库流程。")
parser.add_argument("--mode", choices=["full", *STEP_FUNCS.keys()], default="full")
- parser.add_argument("--input", help="JSON config file. Keys: api_base, taskId, actionId, mubiao, direction, platform, headers.")
- parser.add_argument("--state", default="xdfx-flow-state.json", help="State JSON path for resume and step handoff.")
- parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown summary output path.")
+ parser.add_argument("--input", help="JSON 配置文件。常用字段:api_base、taskId、actionId、mubiao、direction、platform、headers。")
+ parser.add_argument("--state", default="xdfx-flow-state.json", help="用于断点续跑和步骤交接的状态 JSON 文件路径。")
+ parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown 执行摘要输出路径。")
parser.add_argument("--api-base")
parser.add_argument("--task-id")
parser.add_argument("--action-id")
parser.add_argument("--mubiao")
parser.add_argument("--direction", default=None)
parser.add_argument("--platform", default=None)
- parser.add_argument("--auth-token", default=os.getenv("XDFX_AUTH_TOKEN"))
- parser.add_argument("--authorization", default=os.getenv("AUTHORIZATION"))
- parser.add_argument("--header", action="append", default=[], help="Extra HTTP header, format 'Key: Value'.")
- parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="Username for automatic login when Authorization is missing or stale.")
- parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="Password query parameter for automatic username login.")
- parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="Login endpoint path or absolute URL.")
- parser.add_argument("--no-auto-login", action="store_true", help="Disable automatic username login and 401 token refresh.")
- parser.add_argument("--verify-ssl", action="store_true", help="Verify HTTPS certificates. Default is disabled for internal/UAT certificates.")
+ parser.add_argument("--header", action="append", default=[], help="附加非鉴权 HTTP 请求头,格式为 'Key: Value';Authorization 会被忽略。")
+ parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="xdfx_auth 启动阶段自动登录使用的用户名。")
+ parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="登录接口 JSON 请求体中的 password。")
+ parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/auth/login"), help="登录接口路径或完整 URL。")
+ parser.add_argument("--no-auto-login", action="store_true", help="关闭 xdfx_auth 自动登录和 401 刷新。")
+ parser.add_argument("--verify-ssl", action="store_true", help="校验 HTTPS 证书。默认不校验,以兼容内网/UAT 自签证书。")
parser.add_argument("--timeout", type=float, default=90)
parser.add_argument("--retries", type=int, default=2)
parser.add_argument("--dry-run", action="store_true")
- parser.add_argument("--no-step-status", action="store_true", help="Do not update task/task-action.step after completed steps.")
- parser.add_argument("--model-retries", type=int, default=1, help="Retry model/stream endpoints when the first response is empty, timeout-like, or says the model did not respond.")
+ parser.add_argument("--no-step-status", action="store_true", help="步骤完成后不更新 task/task-action.step。")
+ parser.add_argument("--model-retries", type=int, default=1, help="模型/流式接口首次空响应、超时或提示模型无响应时的重试次数。")
parser.add_argument("--max-audiences", type=int, default=3)
parser.add_argument("--max-pathways", type=int, default=3)
parser.add_argument("--max-vulns", type=int, default=3)
parser.add_argument("--max-strategies", type=int, default=3)
+ parser.add_argument("--downstream-audiences", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库受众数量,默认 2。")
+ parser.add_argument("--downstream-pathways", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库传播节点数量,默认 2。")
return parser
@@ -2070,6 +2632,8 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
state_path = Path(args.state)
state = default_state()
+ # Load previous state first, then overlay input JSON and finally CLI flags.
+ # That ordering makes resume safe while still allowing explicit overrides.
if state_path.exists():
loaded = load_json_file(state_path)
if isinstance(loaded, dict):
@@ -2081,9 +2645,7 @@ def main(argv: list[str] | None = None) -> int:
merge_config_into_state(state, config)
input_headers = config.get("headers")
if isinstance(input_headers, dict):
- args.header.extend(f"{k}: {v}" for k, v in input_headers.items())
- args.auth_token = args.auth_token or config.get("auth_token")
- args.authorization = args.authorization or config.get("authorization")
+ args.header.extend(f"{k}: {v}" for k, v in input_headers.items() if str(k).lower() != "authorization")
if config.get("login_username") is not None:
args.login_username = str(config.get("login_username") or "")
if config.get("login_password") is not None:
@@ -2094,6 +2656,10 @@ def main(argv: list[str] | None = None) -> int:
args.no_auto_login = True
if config.get("verify_ssl") is not None:
args.verify_ssl = bool(config.get("verify_ssl"))
+ if config.get("downstream_audiences") is not None:
+ args.downstream_audiences = as_int(config.get("downstream_audiences")) or args.downstream_audiences
+ if config.get("downstream_pathways") is not None:
+ args.downstream_pathways = as_int(config.get("downstream_pathways")) or args.downstream_pathways
cli_config = {
"api_base": args.api_base,
@@ -2105,7 +2671,8 @@ def main(argv: list[str] | None = None) -> int:
}
merge_config_into_state(state, {k: v for k, v in cli_config.items() if v is not None})
api_base = state.get("api_base") or "https://ch1.b.uat.4.cn"
- headers = parse_headers(args.header, args.auth_token, args.authorization)
+ headers = parse_headers(args.header)
+ # ctx owns runtime knobs; state owns business data and resumable ids.
ctx = FlowContext(
api_base=api_base,
headers=headers,
@@ -2118,6 +2685,8 @@ def main(argv: list[str] | None = None) -> int:
max_pathways=max(args.max_pathways, 1),
max_vulns=max(args.max_vulns, 1),
max_strategies=max(args.max_strategies, 1),
+ downstream_audiences=max(args.downstream_audiences, 1),
+ downstream_pathways=max(args.downstream_pathways, 1),
sync_step_status=not args.no_step_status,
model_retries=max(args.model_retries, 0),
verify_ssl=bool(args.verify_ssl),
@@ -2131,11 +2700,17 @@ def main(argv: list[str] | None = None) -> int:
state["_summaryPath"] = str(ctx.summary_path) if ctx.summary_path else ""
state["_autoLogin"] = ctx.auto_login
state["_verifySsl"] = ctx.verify_ssl
+ state["_downstreamLimits"] = {
+ "audiences": ctx.downstream_audiences,
+ "pathways": ctx.downstream_pathways,
+ }
record_event(state, "runner", f"start mode={args.mode} dry_run={ctx.dry_run}")
try:
require_context(state, "taskId", "actionId", "mubiao")
- if ctx.auto_login and not has_authorization(ctx.headers):
- try_auto_login(ctx, state, "runner/auto-login")
+ if ctx.auto_login and not ctx.dry_run:
+ # Always acquire a token through the imported auth package before the first business call.
+ if not try_auto_login(ctx, state, "runner/initial-login", force=True):
+ raise FlowError("启动阶段获取 token 失败,已停止执行,避免无 token 调用业务接口")
if args.mode == "full":
run_full(ctx, state)
else:
@@ -2145,13 +2720,14 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(f"ERROR: {exc}", file=sys.stderr)
+ print(f"错误:{exc}", file=sys.stderr)
return 2
except Exception as exc: # noqa: BLE001
record_error(state, "runner", "未捕获异常", response=traceback.format_exc(), exception=exc)
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
+ print("未捕获异常堆栈:", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
return 1
@@ -2159,20 +2735,23 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(json.dumps(
- {
- "ok": True,
- "mode": args.mode,
- "state": str(state_path),
- "summary": str(ctx.summary_path) if ctx.summary_path else "",
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
- "errors": len(state.get("errors") or []),
- },
- ensure_ascii=False,
- ))
+ # Final JSON is intentionally compact; detailed per-step status has already
+ # been emitted as Chinese step feedback.
+ final_payload = {
+ "日志类型": "执行完成",
+ "是否成功": "是",
+ "执行模式": mode_label(args.mode),
+ "状态文件": str(state_path),
+ "摘要文件": str(ctx.summary_path) if ctx.summary_path else "",
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "已选择受众数": len(state.get("selectedDownstreamAudiences") or []),
+ "已选择传播节点数": len(state.get("selectedDownstreamPathways") or []),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
+ "错误数": len(state.get("errors") or []),
+ }
+ print(LOG_PREFIXES["done"] + " " + json.dumps(final_payload, ensure_ascii=False))
return 0
diff --git a/skills-data/xdfx-step2-pathway/SKILL.md b/skills-data/xdfx-step2-pathway/SKILL.md
index 0dbc754c..9c396ebb 100644
--- a/skills-data/xdfx-step2-pathway/SKILL.md
+++ b/skills-data/xdfx-step2-pathway/SKILL.md
@@ -13,27 +13,37 @@ description: xdfx 拆分流程 step2,基于受众分析关键传播节点,
python scripts/xdfx_flow.py --mode step2 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
-鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
+鉴权和证书由脚本兜底处理:脚本启动时先 `import scripts/xdfx_auth.py`,自动用 `admin / ch@user` 获取 `access_token`,并在第一次业务接口调用前写入内部 Authorization;不要通过命令行传 token。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
- 对已入库受众逐个分析关键传播节点。
- 自动解析候选账号和传播节点 JSON。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
-- 跨受众合并去重,选择全局最优 3 个传播节点。
+- 跨受众合并去重,选择并入库全局最优 3 个传播节点。
- 保存传播节点。
- 保存传播节点与受众关系。
- 同步前端步骤状态 `step-analysis-keypoint` 与 `step-analysis-weakpoint`。
- 把 `pathwayAccountId`、关联 `designIds`、错误明细写入 state。
-关系保存失败不阻断后续流程,但必须报告给用户。
+关系保存失败会写入错误;智能体必须按失败重试规则先重跑 Step2,不能直接跳到 Step3。
+
+## 失败重试规则
+
+- 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前 Step2 命令。
+- 重试前必须告诉用户失败阶段、接口 URL、状态码、响应摘要和“正在重试 Step2”,不能静默重试。
+- Step2 未重试成功前,不允许继续 Step3;不要因为 Step2 失败就跳过关键传播节点分析。
+- Step2 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
## 步骤反馈
-命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step2 的完成状态、当前传播节点入库数量和新增错误数,再决定是否继续下一步。
+命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step2 的完成状态、当前传播节点入库数量和新增错误数,再决定是否继续下一步。
-执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
+执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。
-如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的传播节点和返回 ID。
+
+如果出现 `错误反馈 {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+
+如果出现 `JSON解析复核 {...}`,必须查看 `响应片段`,判断是否能识别传播节点 JSON;无法确认时重跑 Step2。
-如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别传播节点 JSON;无法确认时重跑 Step2。
diff --git a/skills-data/xdfx-step2-pathway/scripts/xdfx_auth.py b/skills-data/xdfx-step2-pathway/scripts/xdfx_auth.py
new file mode 100644
index 00000000..4003e55d
--- /dev/null
+++ b/skills-data/xdfx-step2-pathway/scripts/xdfx_auth.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+"""Authentication helper for xdfx skill scripts.
+
+The caller should not pass tokens through command-line arguments. This module
+owns the fixed admin login flow and returns a Bearer token before business
+interfaces are called.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import ssl
+import urllib.error
+import urllib.request
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass
+class AuthResult:
+ token: str
+ url: str
+ status: int | None
+ raw: str
+
+
+class AuthError(RuntimeError):
+ def __init__(
+ self,
+ message: str,
+ *,
+ url: str,
+ status: int | None = None,
+ response: str = "",
+ exception: BaseException | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.url = url
+ self.status = status
+ self.response = response
+ self.exception = exception
+
+
+def auth_header_value(token: str) -> str:
+ token = token.strip()
+ return token if token.lower().startswith("bearer ") else f"Bearer {token}"
+
+
+def redact_url(url: str) -> str:
+ return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
+
+
+def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
+ if verify_ssl:
+ return None
+ return ssl._create_unverified_context()
+
+
+def build_login_url(api_base: str, path: str) -> str:
+ return path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
+
+
+def extract_access_token(data: Any) -> str | None:
+ if isinstance(data, dict):
+ for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
+ value = data.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ for value in data.values():
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ elif isinstance(data, list):
+ for value in data:
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ return None
+
+
+def parse_json_loose(text: str) -> Any:
+ try:
+ return json.loads(text)
+ except Exception:
+ pass
+ match = re.search(r"[\[{]", text or "")
+ if not match:
+ return {}
+ start = match.start()
+ for end in range(len(text), start, -1):
+ try:
+ return json.loads(text[start:end])
+ except Exception:
+ continue
+ return {}
+
+
+def fetch_access_token(
+ *,
+ api_base: str,
+ username: str = "admin",
+ password: str = "ch@user",
+ login_path: str = "/api/auth/login",
+ timeout: float = 90,
+ verify_ssl: bool = False,
+) -> AuthResult:
+ """Login once and return the extracted access token."""
+ url = build_login_url(api_base, login_path)
+ payload = {"username": username, "password": password}
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ parsed = parse_json_loose(raw)
+ token = extract_access_token(parsed)
+ if not token:
+ raise AuthError(
+ "登录接口已响应但未找到 access_token",
+ url=redact_url(url),
+ status=getattr(resp, "status", None),
+ response=raw,
+ )
+ return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
+ raise AuthError(
+ "登录接口返回错误",
+ url=redact_url(url),
+ status=exc.code,
+ response=body,
+ exception=exc,
+ ) from exc
+ except AuthError:
+ raise
+ except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
+ raise AuthError(
+ "登录接口调用异常",
+ url=redact_url(url),
+ response=str(exc),
+ exception=exc,
+ ) from exc
diff --git a/skills-data/xdfx-step2-pathway/scripts/xdfx_flow.py b/skills-data/xdfx-step2-pathway/scripts/xdfx_flow.py
index 52880e82..d9580e2f 100644
--- a/skills-data/xdfx-step2-pathway/scripts/xdfx_flow.py
+++ b/skills-data/xdfx-step2-pathway/scripts/xdfx_flow.py
@@ -26,7 +26,10 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
+import xdfx_auth
+
+# Keep every backend path in one table so endpoint drift is easy to audit.
ENDPOINTS = {
"aiuser": "/gpt/chat/chat_aiuser",
"group": "/gpt/chat/chat_qianqian_group_chat",
@@ -44,10 +47,12 @@ ENDPOINTS = {
"save_narrate": "/api/base/base-narrate",
"save_message": "/api/task/task-action-message",
"save_strategy_relation": "/api/task/task-action-design-strategy-relation",
+ "dict_data": "/api/sys/sys-dict-data",
}
+# Human-readable names are used only in the Chinese feedback logs.
ENDPOINT_LABELS = {
- "login": "登录获取访问 token",
+ "login": "登录获取访问令牌",
"aiuser": "调用候选账号/受众分析模型接口",
"group": "调用受众目标结构化抽取接口",
"spread": "调用关键传播节点结构化抽取接口",
@@ -64,8 +69,90 @@ ENDPOINT_LABELS = {
"save_narrate": "保存策略信息主题",
"save_message": "保存策略消息配置",
"save_strategy_relation": "保存策略与受众/传播节点关系",
+ "dict_data": "读取前端字典数据",
}
+LOG_PREFIXES = {
+ "operation": "操作反馈",
+ "error": "错误反馈",
+ "json_review": "JSON解析复核",
+ "save": "入库反馈",
+ "step": "步骤反馈",
+ "done": "执行完成",
+}
+
+STATUS_LABELS = {
+ "ok": "成功",
+ "partial": "部分完成",
+ "skipped": "已跳过",
+}
+
+STEP_NUMBER_LABELS = {
+ "step1": "第一步",
+ "step2": "第二步",
+ "step3": "第三步",
+ "step4": "第四步",
+ "step5": "第五步",
+ "step6": "第六步",
+}
+
+HTTP_METHOD_LABELS = {
+ "GET": "读取(GET)",
+ "POST": "提交(POST)",
+ "PUT": "更新(PUT)",
+ "PATCH": "局部更新(PATCH)",
+ "DELETE": "删除(DELETE)",
+}
+
+STAGE_SEGMENT_LABELS = {
+ "analyze": "模型分析",
+ "extract": "结构化抽取",
+ "save": "保存数据",
+ "read": "读取数据",
+ "status": "同步状态",
+}
+
+REQUEST_FIELD_LABELS = {
+ "history": "历史消息",
+ "max_tokens": "最大生成令牌数",
+ "model_name": "模型名称",
+ "otherinfo": "其他信息",
+ "prompt_name": "提示词名称",
+ "stream": "是否流式",
+ "temperature": "温度",
+ "usermessage": "用户消息",
+ "user_message": "用户消息",
+ "username": "登录用户名",
+ "password": "登录密码",
+ "mubiao": "行动目标",
+ "zhanghao": "账号列表",
+ "taskId": "任务ID",
+ "actionId": "行动ID",
+ "taskActionId": "行动ID",
+ "designId": "受众ID",
+ "pathwayId": "传播节点ID",
+ "strategyId": "策略ID",
+ "accountId": "账号ID",
+ "accountName": "账号名称",
+ "accountType": "账号类型",
+ "targetAudience": "受众目标",
+ "vulnerabilities": "脆弱点",
+ "topic": "信息标题",
+ "narrate": "叙事主题",
+ "platform": "平台",
+ "persona": "发布人设",
+ "modelType": "信息形式",
+ "quantity": "数量",
+ "publishTime": "发布时间段",
+ "reason": "理由",
+ "ids": "ID列表",
+ "id": "ID",
+ "step": "步骤状态",
+}
+
+SENSITIVE_KEY_PARTS = ("password", "token", "authorization", "access_token", "accesstoken", "authtoken")
+
+# Platform values mirror the frontend dictionaries used by xdfx strategy pages.
PLATFORM_MAP = {
"facebook": 1,
"fb": 1,
@@ -78,6 +165,8 @@ PLATFORM_MAP = {
"yt": 4,
}
+# The model service sometimes returns a successful HTTP response with these
+# strings instead of useful content. Treat them like transient failures.
MODEL_RETRY_MARKERS = (
"y模型没有响应",
"模型没有响应",
@@ -118,6 +207,12 @@ class FlowError(RuntimeError):
@dataclass
class FlowContext:
+ """Runtime options shared by every step.
+
+ Keep this object boring and serializable-ish: it is passed through every
+ helper, while durable progress belongs in the state JSON file.
+ """
+
api_base: str
headers: dict[str, str]
timeout: float
@@ -129,13 +224,15 @@ class FlowContext:
max_pathways: int = 3
max_vulns: int = 3
max_strategies: int = 3
+ downstream_audiences: int = 2
+ downstream_pathways: int = 2
sync_step_status: bool = True
model_retries: int = 1
verify_ssl: bool = False
auto_login: bool = True
login_username: str = "admin"
login_password: str = "ch@user"
- login_path: str = "/api/v1/auth/login/username"
+ login_path: str = "/api/auth/login"
login_attempt_count: int = 0
@@ -153,7 +250,38 @@ def compact(value: Any, limit: int = 1400) -> str:
text = repr(value)
if len(text) <= limit:
return text
- return text[:limit] + f"... "
+ return text[:limit] + f"... <已截断 {len(text) - limit} 个字符>"
+
+
+def is_sensitive_key(key: Any) -> bool:
+ normalized = re.sub(r"[^a-z0-9_]", "", str(key or "").lower())
+ return any(part in normalized for part in SENSITIVE_KEY_PARTS)
+
+
+def redact_sensitive(value: Any) -> Any:
+ if isinstance(value, dict):
+ return {key: "***" if is_sensitive_key(key) else redact_sensitive(item) for key, item in value.items()}
+ if isinstance(value, list):
+ return [redact_sensitive(item) for item in value]
+ if isinstance(value, tuple):
+ return tuple(redact_sensitive(item) for item in value)
+ if isinstance(value, str):
+ text = re.sub(r"(?i)(Bearer\s+)[^\s\",}]+", r"\1***", value)
+ text = re.sub(
+ r"(?i)(\"(?:password|token|authToken|access_token|accessToken|authorization)\"\s*:\s*)\"[^\"]*\"",
+ r'\1"***"',
+ text,
+ )
+ return re.sub(
+ r"(?i)((?:password|token|authToken|access_token|accessToken|authorization)=)[^&\s]+",
+ r"\1***",
+ text,
+ )
+ return value
+
+
+def safe_compact(value: Any, limit: int = 1400) -> str:
+ return compact(redact_sensitive(value), limit)
def stable_json(value: Any) -> str:
@@ -223,6 +351,11 @@ def save_json_file(path: str | Path, value: Any) -> None:
def default_state() -> dict[str, Any]:
+ """Create a resumable state container.
+
+ The script can be run as six independent commands. IDs returned by one step
+ are persisted here so later steps can resume without redoing earlier saves.
+ """
return {
"api_base": "",
"taskId": "",
@@ -247,6 +380,7 @@ def default_state() -> dict[str, Any]:
def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> None:
+ # Accept both frontend-style camelCase and CLI-friendly snake_case inputs.
aliases = {
"task_id": "taskId",
"action_id": "actionId",
@@ -260,16 +394,12 @@ def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> No
continue
if dest in {"taskId", "actionId"}:
state[dest] = str(value)
- elif dest in state or dest in {
- "api_base",
- "headers",
- "auth_token",
- "authorization",
- }:
+ elif dest in state or dest in {"api_base", "headers"}:
state[dest] = value
def record_event(state: dict[str, Any], stage: str, message: str, data: Any | None = None) -> None:
+ """Append a compact diagnostic event to the state file."""
state.setdefault("events", []).append(
{
"time": utc_now(),
@@ -286,9 +416,13 @@ def feedback_step(stage: str) -> str:
return match.group(1) if match else ""
+def feedback_step_label(stage: str) -> str:
+ return STEP_NUMBER_LABELS.get(feedback_step(stage), "")
+
+
def request_keys(payload: Any) -> list[str]:
if isinstance(payload, dict):
- return sorted(str(key) for key in payload.keys())
+ return sorted(REQUEST_FIELD_LABELS.get(str(key), str(key)) for key in payload.keys())
return []
@@ -306,6 +440,77 @@ def endpoint_label(endpoint_key: str | None, method: str, url: str) -> str:
return f"调用接口 {method}"
+def method_label(method: str) -> str:
+ return HTTP_METHOD_LABELS.get(str(method).upper(), str(method))
+
+
+def yes_no(value: bool) -> str:
+ return "是" if value else "否"
+
+
+def stage_label(stage: Any) -> str:
+ text = str(stage or "")
+ if not text:
+ return ""
+ if text in STEP_LABELS:
+ return STEP_LABELS[text]
+ if text == "runner":
+ return "流程执行器"
+ parts = text.split("/")
+ base = parts[0]
+ match = re.search(r"(step[1-6])", base)
+ labels: list[str] = []
+ if match:
+ labels.append(STEP_LABELS.get(match.group(1), match.group(1)))
+ elif base == "runner":
+ labels.append("流程执行器")
+ else:
+ labels.append(base)
+ for segment in parts[1:]:
+ normalized = segment.replace("-", "_")
+ attempt = re.fullmatch(r"model-call-attempt-(\d+)", segment)
+ json_attempt = re.fullmatch(r"model-json-attempt-(\d+)", segment)
+ if attempt:
+ labels.append(f"模型接口第 {attempt.group(1)} 次尝试")
+ elif json_attempt:
+ labels.append(f"JSON解析第 {json_attempt.group(1)} 次尝试")
+ elif segment == "auto-login":
+ labels.append("自动登录")
+ elif segment in STAGE_SEGMENT_LABELS:
+ labels.append(STAGE_SEGMENT_LABELS[segment])
+ elif normalized in ENDPOINT_LABELS:
+ labels.append(ENDPOINT_LABELS[normalized])
+ else:
+ labels.append(segment)
+ return " / ".join(labels)
+
+
+def status_label(status: Any) -> str:
+ if status is None:
+ return ""
+ return STATUS_LABELS.get(str(status), str(status))
+
+
+def mode_label(mode: str) -> str:
+ if mode == "full":
+ return "全流程"
+ return STEP_LABELS.get(mode, mode)
+
+
+def exception_type_label(exception_type: Any) -> str:
+ name = str(exception_type or "")
+ labels = {
+ "HTTPError": "HTTP 调用异常",
+ "URLError": "网络地址异常",
+ "TimeoutError": "请求超时异常",
+ "FlowError": "流程异常",
+ "ValueError": "数据格式异常",
+ "KeyError": "字段缺失异常",
+ "RuntimeError": "运行异常",
+ }
+ return labels.get(name, "程序异常" if name else "")
+
+
def emit_operation_feedback(
state: dict[str, Any],
stage: str,
@@ -317,39 +522,41 @@ def emit_operation_feedback(
attempt: int = 1,
dry_run: bool = False,
) -> None:
+ # Feedback lines are a mini protocol consumed by the calling agent. They
+ # must stay single-line JSON so weak models/tools can relay them reliably.
payload_obj = {
- "type": "OP_FEEDBACK",
- "step": feedback_step(stage),
- "stage": stage,
- "operation": endpoint_label(endpoint_key, method, url),
- "method": method,
- "url": url,
- "endpoint": endpoint_key or "",
- "attempt": attempt,
- "dryRun": dry_run,
- "requestKeys": request_keys(payload),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "操作反馈",
+ "步骤": feedback_step_label(stage),
+ "阶段": stage_label(stage),
+ "操作": endpoint_label(endpoint_key, method, url),
+ "请求方式": method_label(method),
+ "接口地址": url,
+ "接口名称": endpoint_label(endpoint_key, method, url),
+ "第几次尝试": attempt,
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("OP_FEEDBACK " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["operation"] + " " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
def emit_error_feedback(error: dict[str, Any]) -> None:
payload = {
- "type": "ERROR_FEEDBACK",
- "stage": error.get("stage"),
- "step": feedback_step(str(error.get("stage") or "")),
- "message": error.get("message"),
- "url": error.get("url"),
- "status": error.get("status"),
- "request": error.get("request"),
- "response": error.get("response"),
- "exceptionType": error.get("exceptionType"),
- "exception": error.get("exception"),
- "time": error.get("time"),
+ "日志类型": "错误反馈",
+ "阶段": stage_label(error.get("stage")),
+ "步骤": feedback_step_label(str(error.get("stage") or "")),
+ "错误信息": error.get("message"),
+ "接口地址": error.get("url"),
+ "状态码": error.get("status"),
+ "请求摘要": error.get("request"),
+ "响应摘要": error.get("response"),
+ "异常类型": exception_type_label(error.get("exceptionType")),
+ "异常信息": error.get("exception"),
+ "时间": error.get("time"),
}
- print("ERROR_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["error"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def emit_json_parse_review(
@@ -360,18 +567,89 @@ def emit_json_parse_review(
candidate_count: int,
) -> None:
payload = {
- "type": "JSON_PARSE_REVIEW",
- "stage": stage,
- "step": feedback_step(stage),
- "endpoint": endpoint_key,
- "message": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
- "candidateCount": candidate_count,
- "responseExcerpt": compact(text, 3000),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "JSON解析复核",
+ "阶段": stage_label(stage),
+ "步骤": feedback_step_label(stage),
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, endpoint_key),
+ "提示": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
+ "候选数量": candidate_count,
+ "响应片段": compact(text, 3000),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("JSON_PARSE_REVIEW " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["json_review"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
+
+
+SAVE_ENTITY_LABELS = {
+ "save_design": "受众目标",
+ "save_design_account": "受众目标账号",
+ "save_pathway": "关键传播节点",
+ "save_relation": "受众-传播节点关系",
+ "save_design_vuln": "受众脆弱点",
+ "save_pathway_vuln": "传播节点脆弱点",
+ "save_strategy": "策略",
+ "save_channel": "策略平台与人设",
+ "save_narrate": "策略信息主题",
+ "save_message": "策略消息配置",
+ "save_strategy_relation": "策略关联关系",
+}
+
+
+def saved_entity_name(endpoint_key: str, payload: dict[str, Any]) -> str:
+ """Return the most user-readable name for an inserted row."""
+ candidates = [
+ payload.get("targetAudience"),
+ payload.get("accountName"),
+ payload.get("vulnerabilities"),
+ payload.get("topic"),
+ payload.get("narrate"),
+ payload.get("platform"),
+ payload.get("strategyId"),
+ payload.get("designId"),
+ payload.get("pathwayId"),
+ ]
+ for value in candidates:
+ if value is not None and str(value).strip():
+ return str(value).strip()
+ return endpoint_key
+
+
+def emit_save_feedback(
+ state: dict[str, Any],
+ stage: str,
+ endpoint_key: str,
+ payload: dict[str, Any],
+ row_id: int,
+ *,
+ dry_run: bool,
+) -> None:
+ # Emit only after an id is known. Downstream steps use these ids and should
+ # fail loudly rather than inventing relations against missing rows.
+ label = SAVE_ENTITY_LABELS.get(endpoint_key, endpoint_key)
+ name = saved_entity_name(endpoint_key, payload)
+ feedback = {
+ "日志类型": "入库反馈",
+ "步骤": feedback_step(stage),
+ "阶段": stage,
+ "入库对象类型": label,
+ "入库对象名称": name,
+ "返回ID": row_id,
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, label),
+ "接口路径": ENDPOINTS.get(endpoint_key, ""),
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
+ }
+ print(LOG_PREFIXES["save"] + " " + json.dumps(feedback, ensure_ascii=False), flush=True)
+ record_event(
+ state,
+ stage,
+ f"入库{label}成功:{name}",
+ {"id": row_id, "endpoint": endpoint_key, "dryRun": dry_run},
+ )
def record_error(
@@ -385,16 +663,18 @@ def record_error(
response: Any | None = None,
exception: BaseException | None = None,
) -> None:
+ # Persist full-enough error context for post-run diagnosis, then immediately
+ # emit a short line the agent can show to the user.
item = {
"time": utc_now(),
"stage": stage,
"message": message,
"url": url,
"status": status,
- "request": compact(request, 1200) if request is not None else None,
- "response": compact(response, 2000) if response is not None else None,
+ "request": safe_compact(request, 1200) if request is not None else None,
+ "response": safe_compact(response, 2000) if response is not None else None,
"exceptionType": type(exception).__name__ if exception else None,
- "exception": str(exception) if exception else None,
+ "exception": safe_compact(str(exception), 1000) if exception else None,
}
state.setdefault("errors", []).append(item)
state["updatedAt"] = utc_now()
@@ -430,69 +710,29 @@ def url_for_path(ctx: FlowContext, path: str) -> str:
return ctx.api_base.rstrip("/") + "/" + path.lstrip("/")
-def parse_headers(header_values: list[str], auth_token: str | None, authorization: str | None) -> dict[str, str]:
+def parse_headers(header_values: list[str]) -> dict[str, str]:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
- if auth_token:
- headers["Authorization"] = auth_token if auth_token.lower().startswith("bearer ") else f"Bearer {auth_token}"
- if authorization:
- headers["Authorization"] = authorization
for raw in header_values:
if ":" not in raw:
raise FlowError(f"header 格式错误,应为 Key: Value,实际:{raw}")
key, value = raw.split(":", 1)
- headers[key.strip()] = value.strip()
+ normalized = key.strip()
+ if normalized.lower() == "authorization":
+ continue
+ headers[normalized] = value.strip()
return headers
-def has_authorization(headers: dict[str, str]) -> bool:
- return any(key.lower() == "authorization" and str(value).strip() for key, value in headers.items())
-
-
def ssl_context(ctx: FlowContext) -> ssl.SSLContext | None:
if ctx.verify_ssl:
return None
return ssl._create_unverified_context()
-def auth_header_value(token: str) -> str:
- token = token.strip()
- return token if token.lower().startswith("bearer ") else f"Bearer {token}"
-
-
-def extract_access_token(data: Any) -> str | None:
- if isinstance(data, dict):
- for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
- value = data.get(key)
- if isinstance(value, str) and value.strip():
- return value.strip()
- for value in data.values():
- nested = extract_access_token(value)
- if nested:
- return nested
- elif isinstance(data, list):
- for value in data:
- nested = extract_access_token(value)
- if nested:
- return nested
- return None
-
-
-def redact_url(url: str) -> str:
- return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
-
-
-def build_login_url(ctx: FlowContext, path: str) -> str:
- base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else url_for_path(ctx, path)
- if ctx.login_password:
- separator = "&" if "?" in base else "?"
- base = f"{base}{separator}password={urllib.parse.quote(ctx.login_password, safe='')}"
- return base
-
-
def login_candidate_paths(ctx: FlowContext) -> list[str]:
paths = [
- ctx.login_path or "/api/v1/auth/login/username",
- "/api/v1/auth/login/username",
+ ctx.login_path or "/api/auth/login",
+ "/api/auth/login",
]
seen: set[str] = set()
unique: list[str] = []
@@ -506,6 +746,7 @@ def login_candidate_paths(ctx: FlowContext) -> list[str]:
def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force: bool = False) -> bool:
+ """Fetch a fresh token through the local auth package before business calls."""
if not ctx.auto_login:
return False
if ctx.dry_run:
@@ -515,18 +756,17 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
record_error(state, stage, "自动登录失败:缺少 login_username")
return False
if not force and ctx.login_attempt_count > 0:
- return has_authorization(ctx.headers)
+ return any(key.lower() == "authorization" and str(value).strip() for key, value in ctx.headers.items())
if force and ctx.login_attempt_count >= 2:
return False
ctx.login_attempt_count += 1
- payload = {"username": ctx.login_username}
+ payload = {"username": ctx.login_username, "password": ctx.login_password}
last_status: int | None = None
last_response = ""
last_exc: BaseException | None = None
for attempt, path in enumerate(login_candidate_paths(ctx), start=1):
- url = build_login_url(ctx, path)
- display_url = redact_url(url)
+ display_url = xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, path))
emit_operation_feedback(
state,
stage,
@@ -537,63 +777,39 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
attempt=attempt,
)
try:
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- headers = {
- "Content-Type": "application/json",
- "Accept": "application/json",
- }
- req = urllib.request.Request(url, data=data, headers=headers, method="POST")
- with urllib.request.urlopen(req, timeout=ctx.timeout, context=ssl_context(ctx)) as resp:
- raw = resp.read().decode("utf-8", errors="replace")
- parsed = parse_json_lenient(raw)
- token = extract_access_token(parsed)
- if not token:
- last_status = getattr(resp, "status", None)
- last_response = raw
- record_event(
- state,
- stage,
- "登录接口已响应但未找到 access_token",
- {"url": display_url, "status": last_status, "response": compact(raw, 1000)},
- )
- continue
- ctx.headers["Authorization"] = auth_header_value(token)
- record_event(
- state,
- stage,
- "自动登录成功,已写入 Authorization Bearer token",
- {
- "url": display_url,
- "status": getattr(resp, "status", None),
- "username": ctx.login_username,
- },
- )
- return True
- except urllib.error.HTTPError as exc:
- last_status = exc.code
- last_response = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
- last_exc = exc
- record_event(
- state,
- stage,
- "登录接口返回错误,准备尝试下一个候选登录路径",
- {"url": display_url, "status": exc.code, "response": compact(last_response, 1000)},
+ result = xdfx_auth.fetch_access_token(
+ api_base=ctx.api_base,
+ username=ctx.login_username,
+ password=ctx.login_password,
+ login_path=path,
+ timeout=ctx.timeout,
+ verify_ssl=ctx.verify_ssl,
)
- except Exception as exc: # noqa: BLE001 - auto-login must report every failure.
- last_exc = exc
- last_response = traceback.format_exc()
+ ctx.headers["Authorization"] = xdfx_auth.auth_header_value(result.token)
+ state["_tokenSource"] = "xdfx_auth"
record_event(
state,
stage,
- "登录接口调用异常,准备尝试下一个候选登录路径",
- {"url": display_url, "exceptionType": type(exc).__name__, "exception": str(exc)},
+ "启动阶段已通过 xdfx_auth 包获取 token,并写入 Authorization",
+ {"url": result.url, "status": result.status, "username": ctx.login_username},
+ )
+ return True
+ except xdfx_auth.AuthError as exc:
+ last_status = exc.status
+ last_response = exc.response
+ last_exc = exc
+ record_event(
+ state,
+ stage,
+ "xdfx_auth 获取 token 失败,准备尝试下一个候选登录路径",
+ {"url": exc.url, "status": exc.status, "response": safe_compact(exc.response, 1000), "exception": str(exc)},
)
record_error(
state,
stage,
- "自动登录失败,未能获取 access_token",
- url=redact_url(build_login_url(ctx, ctx.login_path or "/api/v1/auth/login/username")),
+ "自动登录失败,未能通过 xdfx_auth 获取 access_token",
+ url=xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, ctx.login_path or "/api/auth/login")),
status=last_status,
request=payload,
response=last_response,
@@ -612,6 +828,7 @@ def http_request_json_url(
endpoint_key: str | None = None,
allow_auto_login: bool = True,
) -> tuple[Any | None, str]:
+ """Call an HTTP JSON endpoint with logging, retries, and 401 recovery."""
method = method.upper()
if ctx.dry_run:
emit_operation_feedback(state, stage, method, url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
@@ -640,6 +857,7 @@ def http_request_json_url(
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
if exc.code == 401 and allow_auto_login and ctx.auto_login:
+ # Refresh once on 401, then replay the original request exactly.
refreshed = try_auto_login(ctx, state, f"{stage}/auto-login", force=True)
if refreshed:
record_event(
@@ -709,6 +927,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
emit_operation_feedback(state, stage, "POST", url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
record_event(state, stage, f"DRY RUN POST {url}", {"payload": payload})
if endpoint_key.startswith("save_"):
+ # Synthetic ids let downstream dry-run code exercise relation wiring.
synthetic_id = abs(hash(stage + stable_json(payload))) % 100000000
return {"id": synthetic_id, "dryRun": True}, json.dumps({"id": synthetic_id, "dryRun": True})
return {"dryRun": True}, json.dumps({"dryRun": True})
@@ -716,6 +935,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
def parse_json_lenient(text: str) -> Any | None:
+ """Parse normal JSON plus adjacent/SSE-like JSON objects."""
stripped = text.strip()
if not stripped:
return None
@@ -730,6 +950,7 @@ def parse_json_lenient(text: str) -> Any | None:
def parse_concatenated_json_objects(text: str) -> list[Any]:
+ """Read streams such as {"answer": "..."}{"answer": "..."} or data: lines."""
decoder = json.JSONDecoder()
objects: list[Any] = []
i = 0
@@ -766,6 +987,7 @@ def parse_concatenated_json_objects(text: str) -> list[Any]:
def model_text_from_response(parsed: Any, raw: str) -> tuple[str, list[Any]]:
+ """Flatten streamed model chunks into one text buffer."""
objects: list[Any]
if isinstance(parsed, list):
objects = parsed
@@ -824,6 +1046,7 @@ def extract_json_from_text(text: str) -> Any | None:
def extract_json_candidates_from_text(text: str) -> list[Any]:
+ """Collect every plausible JSON object/array from messy model text."""
cleaned = strip_think(text)
candidates: list[Any] = []
seen: set[str] = set()
@@ -860,6 +1083,7 @@ def extract_json_candidates_from_text(text: str) -> list[Any]:
def select_json_candidate(candidates: list[Any], endpoint_key: str, stage: str) -> Any | None:
+ """Pick the candidate whose keys best match the current step."""
if not candidates:
return None
scored = [(json_candidate_score(candidate, endpoint_key, stage), idx, candidate) for idx, candidate in enumerate(candidates)]
@@ -1010,6 +1234,7 @@ def extract_first_balanced(text: str, open_ch: str, close_ch: str) -> str | None
def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[str, list[Any], Any | None]:
+ """Call a model endpoint and retry only known empty/timeout/no-response cases."""
max_attempts = max(ctx.model_retries, 0) + 1
last_text = ""
last_objects: list[Any] = []
@@ -1048,6 +1273,11 @@ def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str,
def call_model_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[dict[str, Any], str, list[Any]]:
+ """Return the best structured JSON for a model step.
+
+ If the first parse fails, the second attempt includes an explicit repair
+ instruction so weak models can re-emit only the required JSON.
+ """
last_text = ""
last_objects: list[Any] = []
last_candidate_count = 0
@@ -1202,23 +1432,85 @@ def profile_info(account: dict[str, Any]) -> str:
def parse_index_list(value: Any) -> list[str]:
- if value is None:
- return []
- if isinstance(value, list):
- return [str(v).strip() for v in value if str(v).strip()]
- text = str(value)
- return [part.strip().strip("<>").strip() for part in re.split(r"[,,;;、\s]+", text) if part.strip()]
+ refs: list[str] = []
+
+ def add(raw: Any) -> None:
+ if raw is None:
+ return
+ if isinstance(raw, dict):
+ keys = (
+ "编号id",
+ "编号ID",
+ "用户编号id",
+ "用户编号ID",
+ "用户编号",
+ "账号编号",
+ "引用序号",
+ "索引",
+ "index",
+ "id",
+ "ids",
+ "accountIndex",
+ "accountIndexes",
+ "userIndex",
+ "userIndexes",
+ )
+ for key in keys:
+ if key in raw:
+ add(raw.get(key))
+ return
+ if isinstance(raw, (list, tuple, set)):
+ for item in raw:
+ add(item)
+ return
+ text = str(raw).strip()
+ if not text:
+ return
+ angle_refs = re.findall(r"<<\s*([^<>]+?)\s*>>", text)
+ if angle_refs:
+ for token in angle_refs:
+ add(token)
+ return
+ for part in re.split(r"[,,;;、\s]+", text):
+ token = part.strip().strip("<>").strip()
+ if token:
+ refs.append(token)
+
+ add(value)
+ deduped: list[str] = []
+ seen: set[str] = set()
+ for ref in refs:
+ if ref in seen:
+ continue
+ seen.add(ref)
+ deduped.append(ref)
+ return deduped
+
+
+def account_ref_numbers(account: dict[str, Any]) -> set[int]:
+ numbers: set[int] = set()
+ meta = account.get("metadata") if isinstance(account.get("metadata"), dict) else {}
+ for key in ("编号id", "编号ID", "用户编号id", "用户编号ID", "用户编号", "账号编号", "引用序号", "索引", "index", "accountIndex"):
+ for container in (account, meta):
+ number = as_int(container.get(key)) if isinstance(container, dict) else None
+ if number is not None:
+ numbers.add(number)
+ return numbers
def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, Any] | None:
+ """Resolve model references like <<3>>, index numbers, names, or account ids."""
if not accounts:
return None
text = str(ref).strip().strip("<>").strip()
number = as_int(text)
if number is not None:
for account in accounts:
- if any(as_int(account.get(key)) == number for key in ("引用序号", "索引", "index")):
+ if number in account_ref_numbers(account):
return account
+ # Step1 group extraction returns 编号/id values that refer to the
+ # previous zhanghao array index. Prefer exact index fields above, then
+ # fall back to zero-based and one-based array positions.
if 0 <= number < len(accounts):
return accounts[number]
if 1 <= number <= len(accounts):
@@ -1232,11 +1524,25 @@ def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, A
def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Normalize audience JSON whether the model used numbered fields or items[]."""
items: list[dict[str, Any]] = []
for i in range(1, 21):
group = first_present(data.get(f"群体{i}"), data.get(f"受众{i}"), data.get(f"targetAudience{i}"))
reason = first_present(data.get(f"理由{i}"), data.get(f"推荐理由{i}"), data.get(f"recReason{i}"))
- users = first_present(data.get(f"用户编号列表{i}"), data.get(f"用户列表{i}"), data.get(f"账号编号列表{i}"))
+ users = first_present(
+ data.get(f"用户编号列表{i}"),
+ data.get(f"编号id列表{i}"),
+ data.get(f"编号ID列表{i}"),
+ data.get(f"用户编号id列表{i}"),
+ data.get(f"用户编号ID列表{i}"),
+ data.get(f"用户列表{i}"),
+ data.get(f"账号编号列表{i}"),
+ data.get(f"索引列表{i}"),
+ data.get(f"编号{i}"),
+ data.get(f"id{i}"),
+ data.get(f"ids{i}"),
+ data.get(f"索引{i}"),
+ )
if group:
items.append(
{
@@ -1255,7 +1561,27 @@ def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
{
"targetAudience": str(group).strip(),
"recReason": str(first_present(item.get("理由"), item.get("recReason"), item.get("reason")) or "").strip(),
- "userIndexes": parse_index_list(first_present(item.get("用户编号列表"), item.get("users"), item.get("accounts"))),
+ "userIndexes": parse_index_list(
+ first_present(
+ item.get("用户编号列表"),
+ item.get("编号id列表"),
+ item.get("编号ID列表"),
+ item.get("用户编号id列表"),
+ item.get("用户编号ID列表"),
+ item.get("编号id"),
+ item.get("编号ID"),
+ item.get("用户编号id"),
+ item.get("用户编号ID"),
+ item.get("编号"),
+ item.get("索引"),
+ item.get("id"),
+ item.get("ids"),
+ item.get("users"),
+ item.get("accounts"),
+ item.get("userIndexes"),
+ item.get("accountIndexes"),
+ )
+ ),
"rank": i,
}
)
@@ -1272,6 +1598,7 @@ def audience_score(item: dict[str, Any]) -> tuple[int, int]:
def select_top_audiences(items: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
+ """Deduplicate audience names and keep the strongest candidates."""
unique: dict[str, dict[str, Any]] = {}
for item in items:
key = item.get("targetAudience", "")
@@ -1301,6 +1628,7 @@ def pathway_score(item: dict[str, Any]) -> tuple[int, int, int]:
def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Merge the same account found from different audiences before saving top 3."""
merged: dict[str, dict[str, Any]] = {}
for item in candidates:
account = item.get("account") or {}
@@ -1325,6 +1653,7 @@ def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str,
def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
+ """Normalize weak-point model output into a capped list."""
vulns: list[dict[str, str]] = []
for i in range(1, 21):
vuln = first_present(data.get(f"脆弱点{i}"), data.get(f"vulnerability{i}"), data.get(f"vuln{i}"))
@@ -1339,6 +1668,7 @@ def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
def extract_strategies(data: dict[str, Any], limit: int) -> list[dict[str, Any]]:
+ """Normalize strategy output and preserve the modal text for later mapping."""
strategies: list[dict[str, Any]] = []
for i in range(1, 21):
title = first_present(data.get(f"信息标题{i}"), data.get(f"主题{i}"), data.get(f"topic{i}"))
@@ -1395,11 +1725,29 @@ def response_id(response: Any) -> int | None:
return None
+def response_rows(response: Any) -> list[Any]:
+ """Extract list rows from common CRUD response shapes."""
+ if isinstance(response, list):
+ return response
+ if not isinstance(response, dict):
+ return []
+ for key in ("data", "rows", "items", "records", "list"):
+ value = response.get(key)
+ if isinstance(value, list):
+ return value
+ rows = response_rows(value)
+ if rows:
+ return rows
+ return []
+
+
def save_with_id(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[int | None, Any | None]:
response, _ = http_post_json(ctx, state, endpoint_key, payload, stage)
rid = response_id(response)
if rid is None:
- record_error(state, stage, "保存接口未返回 id,视为失败", request=payload, response=response)
+ record_error(state, stage, "保存接口未返回 ID,视为失败", request=payload, response=response)
+ else:
+ emit_save_feedback(state, stage, endpoint_key, payload, rid, dry_run=ctx.dry_run)
return rid, response
@@ -1429,6 +1777,7 @@ def parse_step_flags(raw: Any) -> dict[str, Any]:
def mark_step_status(ctx: FlowContext, state: dict[str, Any], links: list[str], stage: str) -> None:
+ """Update the frontend step bar without overwriting unrelated step flags."""
if not ctx.sync_step_status:
record_event(state, stage, "跳过 task-action.step 状态同步")
return
@@ -1477,12 +1826,126 @@ def strategy_platform(value: Any) -> Any:
return platform_to_number(value) or 1
-def strategy_model_type(value: Any) -> str:
- # Frontend defaults to text model type "1" when no modal type is selected.
- raw = first_present(value, "1")
- if isinstance(raw, list):
- return ",".join(str(v) for v in raw if str(v).strip()) or "1"
- return str(raw or "1")
+def choice_tokens(value: Any) -> list[Any]:
+ """Split frontend choice values from arrays, JSON strings, and mixed delimiters."""
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return choice_tokens(
+ first_present(value.get("value"), value.get("dictValue"), value.get("label"), value.get("dictLabel"))
+ )
+ if isinstance(value, (list, tuple, set)):
+ tokens: list[Any] = []
+ for item in value:
+ tokens.extend(choice_tokens(item))
+ return tokens
+ text = str(value).strip()
+ if not text:
+ return []
+ if text[0] in "[{":
+ try:
+ parsed = json.loads(text)
+ if parsed != value:
+ return choice_tokens(parsed)
+ except Exception:
+ pass
+ return [part.strip() for part in re.split(r"[,,、;/|]+", text) if part.strip()]
+
+
+def dict_choices(
+ ctx: FlowContext,
+ state: dict[str, Any],
+ dict_type: str,
+ stage: str,
+) -> list[dict[str, Any]]:
+ """Fetch and cache frontend dictionary choices such as modelType."""
+ cache = state.setdefault("_dictChoiceCache", {})
+ if dict_type in cache:
+ cached = cache[dict_type]
+ return cached if isinstance(cached, list) else []
+ if ctx.dry_run:
+ cache[dict_type] = []
+ return []
+
+ query = urllib.parse.urlencode({"filter": f"dictType||$eq||{dict_type}", "limit": 999, "page": 1})
+ url = f"{url_for(ctx, 'dict_data')}?{query}"
+ response, _ = http_request_json_url(ctx, state, "GET", url, None, stage, endpoint_key="dict_data")
+ choices: list[dict[str, Any]] = []
+ for row in response_rows(response):
+ if not isinstance(row, dict):
+ continue
+ label = str(first_present(row.get("dictLabel"), row.get("label"), "") or "").strip()
+ raw_value = first_present(row.get("dictValue"), row.get("value"))
+ if not label or raw_value is None:
+ continue
+ number = as_int(raw_value)
+ choices.append({"label": label, "value": number if number is not None else str(raw_value).strip()})
+ for alias in choice_tokens(row.get("alias")):
+ alias_label = str(alias).strip()
+ if alias_label:
+ choices.append({"label": alias_label, "value": number if number is not None else str(raw_value).strip()})
+ cache[dict_type] = choices
+ return choices
+
+
+def choice_value_by_label(choices: list[dict[str, Any]] | None, label: str) -> Any | None:
+ needle = re.sub(r"\s+", "", str(label or "").lower())
+ if not needle:
+ return None
+ for item in choices or []:
+ item_label = re.sub(r"\s+", "", str(item.get("label") or "").lower())
+ if item_label and (needle == item_label or needle in item_label or item_label in needle):
+ return item.get("value")
+ return None
+
+
+def model_type_token_value(token: Any, choices: list[dict[str, Any]] | None = None) -> Any:
+ """Convert modal labels like 图片/视频 into frontend dictionary values."""
+ number = as_int(token)
+ if number is not None:
+ return number
+
+ text = str(token or "").strip()
+ lower = text.lower()
+ exact = choice_value_by_label(choices, text)
+ if exact is not None:
+ return exact
+
+ label = ""
+ fallback_value = 2
+ if "视" in text or "video" in lower:
+ label, fallback_value = "视频", 3
+ elif "图" in text or "image" in lower or "picture" in lower or "photo" in lower:
+ label, fallback_value = "图片", 2
+ elif "音" in text or "audio" in lower or "sound" in lower:
+ label, fallback_value = "音频", 4
+ elif "发文" in text or "文本" in text or "文字" in text or "text" in lower:
+ label, fallback_value = "文本", 1
+
+ if label:
+ matched = choice_value_by_label(choices, label)
+ if matched is not None:
+ return matched
+ return fallback_value
+
+ # Mirrors the frontend helper's unknown-token default.
+ return 2
+
+
+def strategy_model_type(value: Any, choices: list[dict[str, Any]] | None = None) -> str:
+ """Return the comma-separated value string expected by task-action-message."""
+ tokens = choice_tokens(value)
+ if not tokens:
+ # Frontend save paths fall back to "1" when no modal type is selected.
+ return "1"
+ values: list[str] = []
+ seen: set[str] = set()
+ for token in tokens:
+ normalized = str(model_type_token_value(token, choices)).strip()
+ if normalized and normalized not in seen:
+ values.append(normalized)
+ seen.add(normalized)
+ return ",".join(values) or "1"
def strategy_language(value: Any) -> int:
@@ -1501,11 +1964,28 @@ def save_strategy_details(
*,
audience: bool,
) -> None:
- platform = strategy_platform(first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})))
+ """Save all frontend-readable strategy child rows.
+
+ The strategy table and arrangement page do not read only the main strategy
+ row; they also expect channel, narrate, message, and relation rows.
+ """
+ platform = strategy_platform(
+ first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
+ )
+ account_type = str(
+ first_present(
+ strategy.get("accountType"),
+ item.get("accountType"),
+ item.get("type"),
+ "目标受众账号" if audience else "关键传播节点账号",
+ )
+ or ""
+ ).strip()
persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
title = str(strategy.get("title") or "").strip()
+ model_type_choices = dict_choices(ctx, state, "modelType", stage + "/dict-modelType")
channel_id, _ = save_with_id(
ctx,
@@ -1516,6 +1996,7 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"platform": platform,
+ "accountType": account_type,
"personaJson": json.dumps([{"persona": persona, "size": persona_size}], ensure_ascii=False),
},
stage + "/save-channel",
@@ -1531,7 +2012,10 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"channelId": channel_id,
- "modelType": strategy_model_type(first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType"))),
+ "modelType": strategy_model_type(
+ first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType")),
+ model_type_choices,
+ ),
"language": strategy_language(first_present(strategy.get("language"), strategy.get("语种"))),
"respondingCount": responding_count,
"messageCount": responding_count,
@@ -1564,7 +2048,46 @@ def object_for_audience(audience: dict[str, Any]) -> dict[str, Any]:
}
+def selected_accounts_for_item(item: dict[str, Any], *, audience: bool) -> list[Any]:
+ if audience:
+ accounts = item.get("accounts") or []
+ return accounts or [object_for_audience(item)]
+ return [item.get("account") or item]
+
+
+def downstream_limit(ctx: FlowContext, *, audience: bool) -> int:
+ """Limit expensive downstream analysis while keeping 3 upstream rows stored."""
+ raw = ctx.downstream_audiences if audience else ctx.downstream_pathways
+ return max(1, raw)
+
+
+def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, audience: bool) -> list[dict[str, Any]]:
+ """Select the 1-2 best stored rows for vulnerability/strategy generation."""
+ source_key = "topAudiences" if audience else "topPathways"
+ id_key = "designId" if audience else "pathwayAccountId"
+ selected_key = "selectedDownstreamAudiences" if audience else "selectedDownstreamPathways"
+ items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)]
+ limit = downstream_limit(ctx, audience=audience)
+ selected = items[:limit]
+ state[selected_key] = [
+ {
+ "id": item.get(id_key),
+ "name": item.get("targetAudience") if audience else item.get("accountName"),
+ }
+ for item in selected
+ ]
+ if items:
+ record_event(
+ state,
+ stage,
+ f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}",
+ {"limit": limit, "selected": state[selected_key]},
+ )
+ return selected
+
+
def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze audiences, save top 3, and attach any referenced accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step1-audience"
if state.get("topAudiences") and not ctx.dry_run:
@@ -1582,12 +2105,15 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
state["audienceRawText"] = text
+ # The first model call may return raw text plus embedded account objects.
accounts = accounts_from_response(parsed, objects, text)
state["audienceAccounts"] = accounts
+ # The second model call converts the long audience analysis into numbered
+ # groups that can be saved as task-action-design rows.
group_payload = {
"mubiao": state["mubiao"],
- "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
"zhanghao": accounts,
"history": [],
"stream": True,
@@ -1602,6 +2128,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
top = select_top_audiences(candidates, ctx.max_audiences)
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the audience first; account rows and downstream analyses depend
+ # on the returned designId.
design_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
@@ -1614,6 +2142,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
continue
item = {**item, "designId": design_id, "saveResponse": resp, "accounts": [], "savedAccountIds": []}
for ref in item.get("userIndexes") or []:
+ # Model references are intentionally fuzzy, so resolve them against
+ # both explicit indexes and account names/ids.
account = resolve_account_ref(ref, accounts)
if account is None:
record_error(state, stage + "/save-design-account", f"用户编号 {ref} 未匹配到候选账号")
@@ -1645,6 +2175,7 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze key pathway accounts for saved audiences and store top 3."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step2-pathway"
audiences = [a for a in state.get("topAudiences") or [] if a.get("designId")]
@@ -1656,6 +2187,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
return
all_candidates: list[dict[str, Any]] = []
for audience in audiences:
+ # Analyze pathway candidates in the context of each saved audience.
payload = {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。",
"otherinfo": {
@@ -1673,6 +2205,8 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
accounts = accounts_from_response(parsed, objects, text)
+ # Ask the model to point back to candidate accounts, then resolve those
+ # references to concrete account objects.
spread_payload = {
"mubiao": state["mubiao"],
"user_message": "分析其中关键信息。请从候选账号中抽取核心传播节点,字段必须是:账号1、理由1、账号2、理由2 ...,最多输出5个。账号字段优先返回候选数组的引用编号或索引,只返回JSON。",
@@ -1707,6 +2241,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways]
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the account row before writing the design-pathway relation.
account = item["account"]
aid = account_id(account)
name = account_name(account)
@@ -1757,18 +2292,20 @@ def run_step4(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate and save weak points for selected audiences or pathway accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step3-audience-vulns" if audience else "step4-pathway-vulns"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
output_key = "audienceVulns" if audience else "pathwayVulns"
state.setdefault(output_key, {})
for item in items:
+ # owner_id is designId for audiences and pathwayAccountId for accounts.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
if not owner_id:
continue
if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run:
continue
- zhanghao = item.get("accounts") or [object_for_audience(item)] if audience else [item.get("account") or item]
+ zhanghao = selected_accounts_for_item(item, audience=audience)
prompt = (
"你是中国对台湾方向的认知战专家,你要传播行动要求的叙事主题。请基于该受众目标的画像、关注议题、社会分工定位、利益诉求与上一轮账号佐证,分析该受众目标有哪些脆弱点可以成为后续策略设计的关键要素。请输出理由、脆弱点1、理由1、脆弱点2、理由2、脆弱点3、理由3,只返回JSON。"
if audience
@@ -1789,6 +2326,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) ->
vulns = extract_vulns(data, ctx.max_vulns)
saved: list[dict[str, Any]] = []
for vuln in vulns:
+ # The two weak-point tables have nearly identical payloads but
+ # different foreign-key names.
if audience:
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
@@ -1835,14 +2374,16 @@ def run_step6(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate strategies from saved weak points and persist frontend child rows."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step5-audience-strategy" if audience else "step6-pathway-strategy"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {}
output_key = "audienceStrategies" if audience else "pathwayStrategies"
state.setdefault(output_key, [])
existing_topics = {s.get("topic") for s in state[output_key] if isinstance(s, dict)}
for item in items:
+ # Strategies are only meaningful after the matching weak points exist.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
vulns = vulns_by_owner.get(str(owner_id)) or []
vuln_texts = [v.get("vulnerabilities") for v in vulns if v.get("vulnerabilities")]
@@ -1850,7 +2391,9 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
record_error(state, stage, "没有可用脆弱点,跳过策略生成", request=item)
continue
target_names = [item.get("targetAudience")] if audience else (item.get("relatedAudiences") or [item.get("accountName")])
- pingtai = state.get("platform") or (platform_label(item.get("account") or {}) if not audience else "")
+ zhanghao = selected_accounts_for_item(item, audience=audience)
+ first_account = zhanghao[0] if zhanghao and isinstance(zhanghao[0], dict) else {}
+ pingtai = state.get("platform") or platform_label(first_account)
payload = {
"user_message": DEFAULT_STRATEGY_PROMPT_AUDIENCE if audience else DEFAULT_STRATEGY_PROMPT_PATHWAY,
"cuiruodian": vuln_texts,
@@ -1858,6 +2401,7 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
"shouzhong": [name for name in target_names if name],
"mudi": extract_goal(state["mubiao"]),
"heiliao": [],
+ "zhanghao": zhanghao,
"history": [],
"stream": True,
"model_name": "OpenAI3",
@@ -1876,16 +2420,20 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
continue
if topic in existing_topics:
continue
+ # Save the main row first; channel/narrate/message/relation rows all
+ # require the returned strategy id.
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
"targetAudience": target,
"accountType": account_type,
"topic": topic,
- "strategyTitle": "",
+ "strategyTitle": str(strategy.get("title") or "").strip(),
}
strategy_id, resp = save_with_id(ctx, state, "save_strategy", save_payload, stage + "/save")
if strategy_id is not None:
+ # Frontend strategy pages read these child tables, so a strategy
+ # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience)
existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp})
@@ -1914,13 +2462,14 @@ STEP_LABELS = {
def state_counts(state: dict[str, Any]) -> dict[str, int]:
+ """Summarize stored objects for step feedback."""
return {
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceVulns": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
- "pathwayVulns": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "受众脆弱点数量": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
+ "传播节点脆弱点数量": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
}
@@ -1932,23 +2481,26 @@ def emit_step_feedback(
status: str | None = None,
note: str = "",
) -> None:
+ # One feedback line per step keeps the single-skill and split-skill versions
+ # equally transparent to the user.
total_errors = len(state.get("errors") or [])
new_errors = max(0, total_errors - before_error_count)
final_status = status or ("partial" if new_errors else "ok")
payload = {
- "type": "STEP_FEEDBACK",
- "step": step,
- "label": STEP_LABELS.get(step, step),
- "status": final_status,
- "note": note,
- "newErrors": new_errors,
- "totalErrors": total_errors,
- "counts": state_counts(state),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "updatedAt": state.get("updatedAt"),
+ "日志类型": "步骤反馈",
+ "步骤": STEP_NUMBER_LABELS.get(step, step),
+ "步骤名称": STEP_LABELS.get(step, step),
+ "状态": STATUS_LABELS.get(final_status, final_status),
+ "执行状态": status_label(final_status),
+ "备注": note,
+ "本步骤新增错误数": new_errors,
+ "累计错误数": total_errors,
+ "累计入库数量": state_counts(state),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "更新时间": state.get("updatedAt"),
}
- print("STEP_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["step"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def finish_step(
@@ -1960,6 +2512,8 @@ def finish_step(
status: str | None = None,
note: str = "",
) -> None:
+ # Always persist before emitting step feedback so the user can inspect files
+ # immediately after a reported step completes.
state["updatedAt"] = utc_now()
save_json_file(ctx.state_path, state)
if ctx.summary_path:
@@ -1980,6 +2534,11 @@ def skip_step_with_feedback(ctx: FlowContext, state: dict[str, Any], step: str,
def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Legacy full runner.
+
+ The wrapper script is preferred for real-time logs, but this remains useful
+ for dry-runs and unattended local troubleshooting.
+ """
run_step_with_feedback(ctx, state, "step1")
run_step_with_feedback(ctx, state, "step2")
run_step_with_feedback(ctx, state, "step3")
@@ -1995,6 +2554,7 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
def write_summary(path: Path, state: dict[str, Any]) -> None:
+ """Write a human-readable checkpoint beside the machine state JSON."""
audience_vuln_count = sum(len(v) for v in (state.get("audienceVulns") or {}).values())
pathway_vuln_count = sum(len(v) for v in (state.get("pathwayVulns") or {}).values())
lines = [
@@ -2005,6 +2565,8 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
f"- actionId:{state.get('actionId')}",
f"- 受众入库:{len(state.get('topAudiences') or [])}",
f"- 传播节点入库:{len(state.get('topPathways') or [])}",
+ f"- 下游受众选择:{len(state.get('selectedDownstreamAudiences') or [])}",
+ f"- 下游传播节点选择:{len(state.get('selectedDownstreamPathways') or [])}",
f"- 受众脆弱点入库:{audience_vuln_count}",
f"- 传播节点脆弱点入库:{pathway_vuln_count}",
f"- 受众策略入库:{len(state.get('audienceStrategies') or [])}",
@@ -2021,9 +2583,9 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
if state.get("errors"):
lines += ["", "## 失败明细"]
for err in state["errors"]:
- lines.append(f"- [{err.get('stage')}] {err.get('message')} status={err.get('status')} url={err.get('url')}")
+ lines.append(f"- [{stage_label(err.get('stage'))}] {err.get('message')} 状态码={err.get('status')} 接口={err.get('url')}")
if err.get("response"):
- lines.append(f" - response: {err.get('response')}")
+ lines.append(f" - 响应摘要:{err.get('response')}")
text = "\n".join(lines) + "\n"
try:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -2034,34 +2596,34 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
def build_arg_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(description="Run xdfx analysis/storage flow.")
+ parser = argparse.ArgumentParser(description="运行 xdfx 分析与入库流程。")
parser.add_argument("--mode", choices=["full", *STEP_FUNCS.keys()], default="full")
- parser.add_argument("--input", help="JSON config file. Keys: api_base, taskId, actionId, mubiao, direction, platform, headers.")
- parser.add_argument("--state", default="xdfx-flow-state.json", help="State JSON path for resume and step handoff.")
- parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown summary output path.")
+ parser.add_argument("--input", help="JSON 配置文件。常用字段:api_base、taskId、actionId、mubiao、direction、platform、headers。")
+ parser.add_argument("--state", default="xdfx-flow-state.json", help="用于断点续跑和步骤交接的状态 JSON 文件路径。")
+ parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown 执行摘要输出路径。")
parser.add_argument("--api-base")
parser.add_argument("--task-id")
parser.add_argument("--action-id")
parser.add_argument("--mubiao")
parser.add_argument("--direction", default=None)
parser.add_argument("--platform", default=None)
- parser.add_argument("--auth-token", default=os.getenv("XDFX_AUTH_TOKEN"))
- parser.add_argument("--authorization", default=os.getenv("AUTHORIZATION"))
- parser.add_argument("--header", action="append", default=[], help="Extra HTTP header, format 'Key: Value'.")
- parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="Username for automatic login when Authorization is missing or stale.")
- parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="Password query parameter for automatic username login.")
- parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="Login endpoint path or absolute URL.")
- parser.add_argument("--no-auto-login", action="store_true", help="Disable automatic username login and 401 token refresh.")
- parser.add_argument("--verify-ssl", action="store_true", help="Verify HTTPS certificates. Default is disabled for internal/UAT certificates.")
+ parser.add_argument("--header", action="append", default=[], help="附加非鉴权 HTTP 请求头,格式为 'Key: Value';Authorization 会被忽略。")
+ parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="xdfx_auth 启动阶段自动登录使用的用户名。")
+ parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="登录接口 JSON 请求体中的 password。")
+ parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/auth/login"), help="登录接口路径或完整 URL。")
+ parser.add_argument("--no-auto-login", action="store_true", help="关闭 xdfx_auth 自动登录和 401 刷新。")
+ parser.add_argument("--verify-ssl", action="store_true", help="校验 HTTPS 证书。默认不校验,以兼容内网/UAT 自签证书。")
parser.add_argument("--timeout", type=float, default=90)
parser.add_argument("--retries", type=int, default=2)
parser.add_argument("--dry-run", action="store_true")
- parser.add_argument("--no-step-status", action="store_true", help="Do not update task/task-action.step after completed steps.")
- parser.add_argument("--model-retries", type=int, default=1, help="Retry model/stream endpoints when the first response is empty, timeout-like, or says the model did not respond.")
+ parser.add_argument("--no-step-status", action="store_true", help="步骤完成后不更新 task/task-action.step。")
+ parser.add_argument("--model-retries", type=int, default=1, help="模型/流式接口首次空响应、超时或提示模型无响应时的重试次数。")
parser.add_argument("--max-audiences", type=int, default=3)
parser.add_argument("--max-pathways", type=int, default=3)
parser.add_argument("--max-vulns", type=int, default=3)
parser.add_argument("--max-strategies", type=int, default=3)
+ parser.add_argument("--downstream-audiences", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库受众数量,默认 2。")
+ parser.add_argument("--downstream-pathways", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库传播节点数量,默认 2。")
return parser
@@ -2070,6 +2632,8 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
state_path = Path(args.state)
state = default_state()
+ # Load previous state first, then overlay input JSON and finally CLI flags.
+ # That ordering makes resume safe while still allowing explicit overrides.
if state_path.exists():
loaded = load_json_file(state_path)
if isinstance(loaded, dict):
@@ -2081,9 +2645,7 @@ def main(argv: list[str] | None = None) -> int:
merge_config_into_state(state, config)
input_headers = config.get("headers")
if isinstance(input_headers, dict):
- args.header.extend(f"{k}: {v}" for k, v in input_headers.items())
- args.auth_token = args.auth_token or config.get("auth_token")
- args.authorization = args.authorization or config.get("authorization")
+ args.header.extend(f"{k}: {v}" for k, v in input_headers.items() if str(k).lower() != "authorization")
if config.get("login_username") is not None:
args.login_username = str(config.get("login_username") or "")
if config.get("login_password") is not None:
@@ -2094,6 +2656,10 @@ def main(argv: list[str] | None = None) -> int:
args.no_auto_login = True
if config.get("verify_ssl") is not None:
args.verify_ssl = bool(config.get("verify_ssl"))
+ if config.get("downstream_audiences") is not None:
+ args.downstream_audiences = as_int(config.get("downstream_audiences")) or args.downstream_audiences
+ if config.get("downstream_pathways") is not None:
+ args.downstream_pathways = as_int(config.get("downstream_pathways")) or args.downstream_pathways
cli_config = {
"api_base": args.api_base,
@@ -2105,7 +2671,8 @@ def main(argv: list[str] | None = None) -> int:
}
merge_config_into_state(state, {k: v for k, v in cli_config.items() if v is not None})
api_base = state.get("api_base") or "https://ch1.b.uat.4.cn"
- headers = parse_headers(args.header, args.auth_token, args.authorization)
+ headers = parse_headers(args.header)
+ # ctx owns runtime knobs; state owns business data and resumable ids.
ctx = FlowContext(
api_base=api_base,
headers=headers,
@@ -2118,6 +2685,8 @@ def main(argv: list[str] | None = None) -> int:
max_pathways=max(args.max_pathways, 1),
max_vulns=max(args.max_vulns, 1),
max_strategies=max(args.max_strategies, 1),
+ downstream_audiences=max(args.downstream_audiences, 1),
+ downstream_pathways=max(args.downstream_pathways, 1),
sync_step_status=not args.no_step_status,
model_retries=max(args.model_retries, 0),
verify_ssl=bool(args.verify_ssl),
@@ -2131,11 +2700,17 @@ def main(argv: list[str] | None = None) -> int:
state["_summaryPath"] = str(ctx.summary_path) if ctx.summary_path else ""
state["_autoLogin"] = ctx.auto_login
state["_verifySsl"] = ctx.verify_ssl
+ state["_downstreamLimits"] = {
+ "audiences": ctx.downstream_audiences,
+ "pathways": ctx.downstream_pathways,
+ }
record_event(state, "runner", f"start mode={args.mode} dry_run={ctx.dry_run}")
try:
require_context(state, "taskId", "actionId", "mubiao")
- if ctx.auto_login and not has_authorization(ctx.headers):
- try_auto_login(ctx, state, "runner/auto-login")
+ if ctx.auto_login and not ctx.dry_run:
+ # Always acquire a token through the imported auth package before the first business call.
+ if not try_auto_login(ctx, state, "runner/initial-login", force=True):
+ raise FlowError("启动阶段获取 token 失败,已停止执行,避免无 token 调用业务接口")
if args.mode == "full":
run_full(ctx, state)
else:
@@ -2145,13 +2720,14 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(f"ERROR: {exc}", file=sys.stderr)
+ print(f"错误:{exc}", file=sys.stderr)
return 2
except Exception as exc: # noqa: BLE001
record_error(state, "runner", "未捕获异常", response=traceback.format_exc(), exception=exc)
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
+ print("未捕获异常堆栈:", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
return 1
@@ -2159,20 +2735,23 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(json.dumps(
- {
- "ok": True,
- "mode": args.mode,
- "state": str(state_path),
- "summary": str(ctx.summary_path) if ctx.summary_path else "",
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
- "errors": len(state.get("errors") or []),
- },
- ensure_ascii=False,
- ))
+ # Final JSON is intentionally compact; detailed per-step status has already
+ # been emitted as Chinese step feedback.
+ final_payload = {
+ "日志类型": "执行完成",
+ "是否成功": "是",
+ "执行模式": mode_label(args.mode),
+ "状态文件": str(state_path),
+ "摘要文件": str(ctx.summary_path) if ctx.summary_path else "",
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "已选择受众数": len(state.get("selectedDownstreamAudiences") or []),
+ "已选择传播节点数": len(state.get("selectedDownstreamPathways") or []),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
+ "错误数": len(state.get("errors") or []),
+ }
+ print(LOG_PREFIXES["done"] + " " + json.dumps(final_payload, ensure_ascii=False))
return 0
diff --git a/skills-data/xdfx-step3-audience-vulns/SKILL.md b/skills-data/xdfx-step3-audience-vulns/SKILL.md
index ca21e94d..552b6e5d 100644
--- a/skills-data/xdfx-step3-audience-vulns/SKILL.md
+++ b/skills-data/xdfx-step3-audience-vulns/SKILL.md
@@ -13,26 +13,37 @@ description: xdfx 拆分流程 step3,为已入库受众抽取并保存最多3
python scripts/xdfx_flow.py --mode step3 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
-鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
+鉴权和证书由脚本兜底处理:脚本启动时先 `import scripts/xdfx_auth.py`,自动用 `admin / ch@user` 获取 `access_token`,并在第一次业务接口调用前写入内部 Authorization;不要通过命令行传 token。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
-- 对每个已入库受众抽取最多 3 个脆弱点。
+- 从已入库受众中默认选择前 2 个继续分析;可用 `--downstream-audiences 1` 调成只处理 1 个。
+- 对每个被选中的受众抽取并入库最多 3 个脆弱点。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 自动修复和解析模型 JSON。
- 调用受众脆弱点保存接口。
- 只有保存成功并返回 `id` 的脆弱点才进入后续策略阶段。
- 同步前端步骤状态 `step-analysis-weakpoint`。
-- 单个受众失败不影响其他受众。
+- 单个受众失败会写入错误;智能体必须按失败重试规则先重跑 Step3,不能直接跳到 Step4/Step5。
完成后查看 `audienceVulns` 和 `errors`。
+## 失败重试规则
+
+- 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前 Step3 命令。
+- 重试前必须告诉用户失败阶段、接口 URL、状态码、响应摘要和“正在重试 Step3”,不能静默重试。
+- Step3 未重试成功前,不允许继续 Step4/Step5;不要因为 Step3 失败就跳过受众脆弱点分析。
+- Step3 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
+
## 步骤反馈
-命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step3 的完成状态、当前受众脆弱点数量和新增错误数,再决定是否继续下一步。
+命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step3 的完成状态、当前受众脆弱点数量和新增错误数,再决定是否继续下一步。
-执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
+执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。
-如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的受众脆弱点和返回 ID。
+
+如果出现 `错误反馈 {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+
+如果出现 `JSON解析复核 {...}`,必须查看 `响应片段`,判断是否能识别受众脆弱点 JSON;无法确认时重跑 Step3。
-如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别受众脆弱点 JSON;无法确认时重跑 Step3。
diff --git a/skills-data/xdfx-step3-audience-vulns/scripts/xdfx_auth.py b/skills-data/xdfx-step3-audience-vulns/scripts/xdfx_auth.py
new file mode 100644
index 00000000..4003e55d
--- /dev/null
+++ b/skills-data/xdfx-step3-audience-vulns/scripts/xdfx_auth.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+"""Authentication helper for xdfx skill scripts.
+
+The caller should not pass tokens through command-line arguments. This module
+owns the fixed admin login flow and returns a Bearer token before business
+interfaces are called.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import ssl
+import urllib.error
+import urllib.request
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass
+class AuthResult:
+ token: str
+ url: str
+ status: int | None
+ raw: str
+
+
+class AuthError(RuntimeError):
+ def __init__(
+ self,
+ message: str,
+ *,
+ url: str,
+ status: int | None = None,
+ response: str = "",
+ exception: BaseException | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.url = url
+ self.status = status
+ self.response = response
+ self.exception = exception
+
+
+def auth_header_value(token: str) -> str:
+ token = token.strip()
+ return token if token.lower().startswith("bearer ") else f"Bearer {token}"
+
+
+def redact_url(url: str) -> str:
+ return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
+
+
+def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
+ if verify_ssl:
+ return None
+ return ssl._create_unverified_context()
+
+
+def build_login_url(api_base: str, path: str) -> str:
+ return path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
+
+
+def extract_access_token(data: Any) -> str | None:
+ if isinstance(data, dict):
+ for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
+ value = data.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ for value in data.values():
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ elif isinstance(data, list):
+ for value in data:
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ return None
+
+
+def parse_json_loose(text: str) -> Any:
+ try:
+ return json.loads(text)
+ except Exception:
+ pass
+ match = re.search(r"[\[{]", text or "")
+ if not match:
+ return {}
+ start = match.start()
+ for end in range(len(text), start, -1):
+ try:
+ return json.loads(text[start:end])
+ except Exception:
+ continue
+ return {}
+
+
+def fetch_access_token(
+ *,
+ api_base: str,
+ username: str = "admin",
+ password: str = "ch@user",
+ login_path: str = "/api/auth/login",
+ timeout: float = 90,
+ verify_ssl: bool = False,
+) -> AuthResult:
+ """Login once and return the extracted access token."""
+ url = build_login_url(api_base, login_path)
+ payload = {"username": username, "password": password}
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ parsed = parse_json_loose(raw)
+ token = extract_access_token(parsed)
+ if not token:
+ raise AuthError(
+ "登录接口已响应但未找到 access_token",
+ url=redact_url(url),
+ status=getattr(resp, "status", None),
+ response=raw,
+ )
+ return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
+ raise AuthError(
+ "登录接口返回错误",
+ url=redact_url(url),
+ status=exc.code,
+ response=body,
+ exception=exc,
+ ) from exc
+ except AuthError:
+ raise
+ except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
+ raise AuthError(
+ "登录接口调用异常",
+ url=redact_url(url),
+ response=str(exc),
+ exception=exc,
+ ) from exc
diff --git a/skills-data/xdfx-step3-audience-vulns/scripts/xdfx_flow.py b/skills-data/xdfx-step3-audience-vulns/scripts/xdfx_flow.py
index 52880e82..d9580e2f 100644
--- a/skills-data/xdfx-step3-audience-vulns/scripts/xdfx_flow.py
+++ b/skills-data/xdfx-step3-audience-vulns/scripts/xdfx_flow.py
@@ -26,7 +26,10 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
+import xdfx_auth
+
+# Keep every backend path in one table so endpoint drift is easy to audit.
ENDPOINTS = {
"aiuser": "/gpt/chat/chat_aiuser",
"group": "/gpt/chat/chat_qianqian_group_chat",
@@ -44,10 +47,12 @@ ENDPOINTS = {
"save_narrate": "/api/base/base-narrate",
"save_message": "/api/task/task-action-message",
"save_strategy_relation": "/api/task/task-action-design-strategy-relation",
+ "dict_data": "/api/sys/sys-dict-data",
}
+# Human-readable names are used only in the Chinese feedback logs.
ENDPOINT_LABELS = {
- "login": "登录获取访问 token",
+ "login": "登录获取访问令牌",
"aiuser": "调用候选账号/受众分析模型接口",
"group": "调用受众目标结构化抽取接口",
"spread": "调用关键传播节点结构化抽取接口",
@@ -64,8 +69,90 @@ ENDPOINT_LABELS = {
"save_narrate": "保存策略信息主题",
"save_message": "保存策略消息配置",
"save_strategy_relation": "保存策略与受众/传播节点关系",
+ "dict_data": "读取前端字典数据",
}
+LOG_PREFIXES = {
+ "operation": "操作反馈",
+ "error": "错误反馈",
+ "json_review": "JSON解析复核",
+ "save": "入库反馈",
+ "step": "步骤反馈",
+ "done": "执行完成",
+}
+
+STATUS_LABELS = {
+ "ok": "成功",
+ "partial": "部分完成",
+ "skipped": "已跳过",
+}
+
+STEP_NUMBER_LABELS = {
+ "step1": "第一步",
+ "step2": "第二步",
+ "step3": "第三步",
+ "step4": "第四步",
+ "step5": "第五步",
+ "step6": "第六步",
+}
+
+HTTP_METHOD_LABELS = {
+ "GET": "读取(GET)",
+ "POST": "提交(POST)",
+ "PUT": "更新(PUT)",
+ "PATCH": "局部更新(PATCH)",
+ "DELETE": "删除(DELETE)",
+}
+
+STAGE_SEGMENT_LABELS = {
+ "analyze": "模型分析",
+ "extract": "结构化抽取",
+ "save": "保存数据",
+ "read": "读取数据",
+ "status": "同步状态",
+}
+
+REQUEST_FIELD_LABELS = {
+ "history": "历史消息",
+ "max_tokens": "最大生成令牌数",
+ "model_name": "模型名称",
+ "otherinfo": "其他信息",
+ "prompt_name": "提示词名称",
+ "stream": "是否流式",
+ "temperature": "温度",
+ "usermessage": "用户消息",
+ "user_message": "用户消息",
+ "username": "登录用户名",
+ "password": "登录密码",
+ "mubiao": "行动目标",
+ "zhanghao": "账号列表",
+ "taskId": "任务ID",
+ "actionId": "行动ID",
+ "taskActionId": "行动ID",
+ "designId": "受众ID",
+ "pathwayId": "传播节点ID",
+ "strategyId": "策略ID",
+ "accountId": "账号ID",
+ "accountName": "账号名称",
+ "accountType": "账号类型",
+ "targetAudience": "受众目标",
+ "vulnerabilities": "脆弱点",
+ "topic": "信息标题",
+ "narrate": "叙事主题",
+ "platform": "平台",
+ "persona": "发布人设",
+ "modelType": "信息形式",
+ "quantity": "数量",
+ "publishTime": "发布时间段",
+ "reason": "理由",
+ "ids": "ID列表",
+ "id": "ID",
+ "step": "步骤状态",
+}
+
+SENSITIVE_KEY_PARTS = ("password", "token", "authorization", "access_token", "accesstoken", "authtoken")
+
+# Platform values mirror the frontend dictionaries used by xdfx strategy pages.
PLATFORM_MAP = {
"facebook": 1,
"fb": 1,
@@ -78,6 +165,8 @@ PLATFORM_MAP = {
"yt": 4,
}
+# The model service sometimes returns a successful HTTP response with these
+# strings instead of useful content. Treat them like transient failures.
MODEL_RETRY_MARKERS = (
"y模型没有响应",
"模型没有响应",
@@ -118,6 +207,12 @@ class FlowError(RuntimeError):
@dataclass
class FlowContext:
+ """Runtime options shared by every step.
+
+ Keep this object boring and serializable-ish: it is passed through every
+ helper, while durable progress belongs in the state JSON file.
+ """
+
api_base: str
headers: dict[str, str]
timeout: float
@@ -129,13 +224,15 @@ class FlowContext:
max_pathways: int = 3
max_vulns: int = 3
max_strategies: int = 3
+ downstream_audiences: int = 2
+ downstream_pathways: int = 2
sync_step_status: bool = True
model_retries: int = 1
verify_ssl: bool = False
auto_login: bool = True
login_username: str = "admin"
login_password: str = "ch@user"
- login_path: str = "/api/v1/auth/login/username"
+ login_path: str = "/api/auth/login"
login_attempt_count: int = 0
@@ -153,7 +250,38 @@ def compact(value: Any, limit: int = 1400) -> str:
text = repr(value)
if len(text) <= limit:
return text
- return text[:limit] + f"... "
+ return text[:limit] + f"... <已截断 {len(text) - limit} 个字符>"
+
+
+def is_sensitive_key(key: Any) -> bool:
+ normalized = re.sub(r"[^a-z0-9_]", "", str(key or "").lower())
+ return any(part in normalized for part in SENSITIVE_KEY_PARTS)
+
+
+def redact_sensitive(value: Any) -> Any:
+ if isinstance(value, dict):
+ return {key: "***" if is_sensitive_key(key) else redact_sensitive(item) for key, item in value.items()}
+ if isinstance(value, list):
+ return [redact_sensitive(item) for item in value]
+ if isinstance(value, tuple):
+ return tuple(redact_sensitive(item) for item in value)
+ if isinstance(value, str):
+ text = re.sub(r"(?i)(Bearer\s+)[^\s\",}]+", r"\1***", value)
+ text = re.sub(
+ r"(?i)(\"(?:password|token|authToken|access_token|accessToken|authorization)\"\s*:\s*)\"[^\"]*\"",
+ r'\1"***"',
+ text,
+ )
+ return re.sub(
+ r"(?i)((?:password|token|authToken|access_token|accessToken|authorization)=)[^&\s]+",
+ r"\1***",
+ text,
+ )
+ return value
+
+
+def safe_compact(value: Any, limit: int = 1400) -> str:
+ return compact(redact_sensitive(value), limit)
def stable_json(value: Any) -> str:
@@ -223,6 +351,11 @@ def save_json_file(path: str | Path, value: Any) -> None:
def default_state() -> dict[str, Any]:
+ """Create a resumable state container.
+
+ The script can be run as six independent commands. IDs returned by one step
+ are persisted here so later steps can resume without redoing earlier saves.
+ """
return {
"api_base": "",
"taskId": "",
@@ -247,6 +380,7 @@ def default_state() -> dict[str, Any]:
def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> None:
+ # Accept both frontend-style camelCase and CLI-friendly snake_case inputs.
aliases = {
"task_id": "taskId",
"action_id": "actionId",
@@ -260,16 +394,12 @@ def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> No
continue
if dest in {"taskId", "actionId"}:
state[dest] = str(value)
- elif dest in state or dest in {
- "api_base",
- "headers",
- "auth_token",
- "authorization",
- }:
+ elif dest in state or dest in {"api_base", "headers"}:
state[dest] = value
def record_event(state: dict[str, Any], stage: str, message: str, data: Any | None = None) -> None:
+ """Append a compact diagnostic event to the state file."""
state.setdefault("events", []).append(
{
"time": utc_now(),
@@ -286,9 +416,13 @@ def feedback_step(stage: str) -> str:
return match.group(1) if match else ""
+def feedback_step_label(stage: str) -> str:
+ return STEP_NUMBER_LABELS.get(feedback_step(stage), "")
+
+
def request_keys(payload: Any) -> list[str]:
if isinstance(payload, dict):
- return sorted(str(key) for key in payload.keys())
+ return sorted(REQUEST_FIELD_LABELS.get(str(key), str(key)) for key in payload.keys())
return []
@@ -306,6 +440,77 @@ def endpoint_label(endpoint_key: str | None, method: str, url: str) -> str:
return f"调用接口 {method}"
+def method_label(method: str) -> str:
+ return HTTP_METHOD_LABELS.get(str(method).upper(), str(method))
+
+
+def yes_no(value: bool) -> str:
+ return "是" if value else "否"
+
+
+def stage_label(stage: Any) -> str:
+ text = str(stage or "")
+ if not text:
+ return ""
+ if text in STEP_LABELS:
+ return STEP_LABELS[text]
+ if text == "runner":
+ return "流程执行器"
+ parts = text.split("/")
+ base = parts[0]
+ match = re.search(r"(step[1-6])", base)
+ labels: list[str] = []
+ if match:
+ labels.append(STEP_LABELS.get(match.group(1), match.group(1)))
+ elif base == "runner":
+ labels.append("流程执行器")
+ else:
+ labels.append(base)
+ for segment in parts[1:]:
+ normalized = segment.replace("-", "_")
+ attempt = re.fullmatch(r"model-call-attempt-(\d+)", segment)
+ json_attempt = re.fullmatch(r"model-json-attempt-(\d+)", segment)
+ if attempt:
+ labels.append(f"模型接口第 {attempt.group(1)} 次尝试")
+ elif json_attempt:
+ labels.append(f"JSON解析第 {json_attempt.group(1)} 次尝试")
+ elif segment == "auto-login":
+ labels.append("自动登录")
+ elif segment in STAGE_SEGMENT_LABELS:
+ labels.append(STAGE_SEGMENT_LABELS[segment])
+ elif normalized in ENDPOINT_LABELS:
+ labels.append(ENDPOINT_LABELS[normalized])
+ else:
+ labels.append(segment)
+ return " / ".join(labels)
+
+
+def status_label(status: Any) -> str:
+ if status is None:
+ return ""
+ return STATUS_LABELS.get(str(status), str(status))
+
+
+def mode_label(mode: str) -> str:
+ if mode == "full":
+ return "全流程"
+ return STEP_LABELS.get(mode, mode)
+
+
+def exception_type_label(exception_type: Any) -> str:
+ name = str(exception_type or "")
+ labels = {
+ "HTTPError": "HTTP 调用异常",
+ "URLError": "网络地址异常",
+ "TimeoutError": "请求超时异常",
+ "FlowError": "流程异常",
+ "ValueError": "数据格式异常",
+ "KeyError": "字段缺失异常",
+ "RuntimeError": "运行异常",
+ }
+ return labels.get(name, "程序异常" if name else "")
+
+
def emit_operation_feedback(
state: dict[str, Any],
stage: str,
@@ -317,39 +522,41 @@ def emit_operation_feedback(
attempt: int = 1,
dry_run: bool = False,
) -> None:
+ # Feedback lines are a mini protocol consumed by the calling agent. They
+ # must stay single-line JSON so weak models/tools can relay them reliably.
payload_obj = {
- "type": "OP_FEEDBACK",
- "step": feedback_step(stage),
- "stage": stage,
- "operation": endpoint_label(endpoint_key, method, url),
- "method": method,
- "url": url,
- "endpoint": endpoint_key or "",
- "attempt": attempt,
- "dryRun": dry_run,
- "requestKeys": request_keys(payload),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "操作反馈",
+ "步骤": feedback_step_label(stage),
+ "阶段": stage_label(stage),
+ "操作": endpoint_label(endpoint_key, method, url),
+ "请求方式": method_label(method),
+ "接口地址": url,
+ "接口名称": endpoint_label(endpoint_key, method, url),
+ "第几次尝试": attempt,
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("OP_FEEDBACK " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["operation"] + " " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
def emit_error_feedback(error: dict[str, Any]) -> None:
payload = {
- "type": "ERROR_FEEDBACK",
- "stage": error.get("stage"),
- "step": feedback_step(str(error.get("stage") or "")),
- "message": error.get("message"),
- "url": error.get("url"),
- "status": error.get("status"),
- "request": error.get("request"),
- "response": error.get("response"),
- "exceptionType": error.get("exceptionType"),
- "exception": error.get("exception"),
- "time": error.get("time"),
+ "日志类型": "错误反馈",
+ "阶段": stage_label(error.get("stage")),
+ "步骤": feedback_step_label(str(error.get("stage") or "")),
+ "错误信息": error.get("message"),
+ "接口地址": error.get("url"),
+ "状态码": error.get("status"),
+ "请求摘要": error.get("request"),
+ "响应摘要": error.get("response"),
+ "异常类型": exception_type_label(error.get("exceptionType")),
+ "异常信息": error.get("exception"),
+ "时间": error.get("time"),
}
- print("ERROR_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["error"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def emit_json_parse_review(
@@ -360,18 +567,89 @@ def emit_json_parse_review(
candidate_count: int,
) -> None:
payload = {
- "type": "JSON_PARSE_REVIEW",
- "stage": stage,
- "step": feedback_step(stage),
- "endpoint": endpoint_key,
- "message": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
- "candidateCount": candidate_count,
- "responseExcerpt": compact(text, 3000),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "JSON解析复核",
+ "阶段": stage_label(stage),
+ "步骤": feedback_step_label(stage),
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, endpoint_key),
+ "提示": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
+ "候选数量": candidate_count,
+ "响应片段": compact(text, 3000),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("JSON_PARSE_REVIEW " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["json_review"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
+
+
+SAVE_ENTITY_LABELS = {
+ "save_design": "受众目标",
+ "save_design_account": "受众目标账号",
+ "save_pathway": "关键传播节点",
+ "save_relation": "受众-传播节点关系",
+ "save_design_vuln": "受众脆弱点",
+ "save_pathway_vuln": "传播节点脆弱点",
+ "save_strategy": "策略",
+ "save_channel": "策略平台与人设",
+ "save_narrate": "策略信息主题",
+ "save_message": "策略消息配置",
+ "save_strategy_relation": "策略关联关系",
+}
+
+
+def saved_entity_name(endpoint_key: str, payload: dict[str, Any]) -> str:
+ """Return the most user-readable name for an inserted row."""
+ candidates = [
+ payload.get("targetAudience"),
+ payload.get("accountName"),
+ payload.get("vulnerabilities"),
+ payload.get("topic"),
+ payload.get("narrate"),
+ payload.get("platform"),
+ payload.get("strategyId"),
+ payload.get("designId"),
+ payload.get("pathwayId"),
+ ]
+ for value in candidates:
+ if value is not None and str(value).strip():
+ return str(value).strip()
+ return endpoint_key
+
+
+def emit_save_feedback(
+ state: dict[str, Any],
+ stage: str,
+ endpoint_key: str,
+ payload: dict[str, Any],
+ row_id: int,
+ *,
+ dry_run: bool,
+) -> None:
+ # Emit only after an id is known. Downstream steps use these ids and should
+ # fail loudly rather than inventing relations against missing rows.
+ label = SAVE_ENTITY_LABELS.get(endpoint_key, endpoint_key)
+ name = saved_entity_name(endpoint_key, payload)
+ feedback = {
+ "日志类型": "入库反馈",
+ "步骤": feedback_step(stage),
+ "阶段": stage,
+ "入库对象类型": label,
+ "入库对象名称": name,
+ "返回ID": row_id,
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, label),
+ "接口路径": ENDPOINTS.get(endpoint_key, ""),
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
+ }
+ print(LOG_PREFIXES["save"] + " " + json.dumps(feedback, ensure_ascii=False), flush=True)
+ record_event(
+ state,
+ stage,
+ f"入库{label}成功:{name}",
+ {"id": row_id, "endpoint": endpoint_key, "dryRun": dry_run},
+ )
def record_error(
@@ -385,16 +663,18 @@ def record_error(
response: Any | None = None,
exception: BaseException | None = None,
) -> None:
+ # Persist full-enough error context for post-run diagnosis, then immediately
+ # emit a short line the agent can show to the user.
item = {
"time": utc_now(),
"stage": stage,
"message": message,
"url": url,
"status": status,
- "request": compact(request, 1200) if request is not None else None,
- "response": compact(response, 2000) if response is not None else None,
+ "request": safe_compact(request, 1200) if request is not None else None,
+ "response": safe_compact(response, 2000) if response is not None else None,
"exceptionType": type(exception).__name__ if exception else None,
- "exception": str(exception) if exception else None,
+ "exception": safe_compact(str(exception), 1000) if exception else None,
}
state.setdefault("errors", []).append(item)
state["updatedAt"] = utc_now()
@@ -430,69 +710,29 @@ def url_for_path(ctx: FlowContext, path: str) -> str:
return ctx.api_base.rstrip("/") + "/" + path.lstrip("/")
-def parse_headers(header_values: list[str], auth_token: str | None, authorization: str | None) -> dict[str, str]:
+def parse_headers(header_values: list[str]) -> dict[str, str]:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
- if auth_token:
- headers["Authorization"] = auth_token if auth_token.lower().startswith("bearer ") else f"Bearer {auth_token}"
- if authorization:
- headers["Authorization"] = authorization
for raw in header_values:
if ":" not in raw:
raise FlowError(f"header 格式错误,应为 Key: Value,实际:{raw}")
key, value = raw.split(":", 1)
- headers[key.strip()] = value.strip()
+ normalized = key.strip()
+ if normalized.lower() == "authorization":
+ continue
+ headers[normalized] = value.strip()
return headers
-def has_authorization(headers: dict[str, str]) -> bool:
- return any(key.lower() == "authorization" and str(value).strip() for key, value in headers.items())
-
-
def ssl_context(ctx: FlowContext) -> ssl.SSLContext | None:
if ctx.verify_ssl:
return None
return ssl._create_unverified_context()
-def auth_header_value(token: str) -> str:
- token = token.strip()
- return token if token.lower().startswith("bearer ") else f"Bearer {token}"
-
-
-def extract_access_token(data: Any) -> str | None:
- if isinstance(data, dict):
- for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
- value = data.get(key)
- if isinstance(value, str) and value.strip():
- return value.strip()
- for value in data.values():
- nested = extract_access_token(value)
- if nested:
- return nested
- elif isinstance(data, list):
- for value in data:
- nested = extract_access_token(value)
- if nested:
- return nested
- return None
-
-
-def redact_url(url: str) -> str:
- return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
-
-
-def build_login_url(ctx: FlowContext, path: str) -> str:
- base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else url_for_path(ctx, path)
- if ctx.login_password:
- separator = "&" if "?" in base else "?"
- base = f"{base}{separator}password={urllib.parse.quote(ctx.login_password, safe='')}"
- return base
-
-
def login_candidate_paths(ctx: FlowContext) -> list[str]:
paths = [
- ctx.login_path or "/api/v1/auth/login/username",
- "/api/v1/auth/login/username",
+ ctx.login_path or "/api/auth/login",
+ "/api/auth/login",
]
seen: set[str] = set()
unique: list[str] = []
@@ -506,6 +746,7 @@ def login_candidate_paths(ctx: FlowContext) -> list[str]:
def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force: bool = False) -> bool:
+ """Fetch a fresh token through the local auth package before business calls."""
if not ctx.auto_login:
return False
if ctx.dry_run:
@@ -515,18 +756,17 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
record_error(state, stage, "自动登录失败:缺少 login_username")
return False
if not force and ctx.login_attempt_count > 0:
- return has_authorization(ctx.headers)
+ return any(key.lower() == "authorization" and str(value).strip() for key, value in ctx.headers.items())
if force and ctx.login_attempt_count >= 2:
return False
ctx.login_attempt_count += 1
- payload = {"username": ctx.login_username}
+ payload = {"username": ctx.login_username, "password": ctx.login_password}
last_status: int | None = None
last_response = ""
last_exc: BaseException | None = None
for attempt, path in enumerate(login_candidate_paths(ctx), start=1):
- url = build_login_url(ctx, path)
- display_url = redact_url(url)
+ display_url = xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, path))
emit_operation_feedback(
state,
stage,
@@ -537,63 +777,39 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
attempt=attempt,
)
try:
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- headers = {
- "Content-Type": "application/json",
- "Accept": "application/json",
- }
- req = urllib.request.Request(url, data=data, headers=headers, method="POST")
- with urllib.request.urlopen(req, timeout=ctx.timeout, context=ssl_context(ctx)) as resp:
- raw = resp.read().decode("utf-8", errors="replace")
- parsed = parse_json_lenient(raw)
- token = extract_access_token(parsed)
- if not token:
- last_status = getattr(resp, "status", None)
- last_response = raw
- record_event(
- state,
- stage,
- "登录接口已响应但未找到 access_token",
- {"url": display_url, "status": last_status, "response": compact(raw, 1000)},
- )
- continue
- ctx.headers["Authorization"] = auth_header_value(token)
- record_event(
- state,
- stage,
- "自动登录成功,已写入 Authorization Bearer token",
- {
- "url": display_url,
- "status": getattr(resp, "status", None),
- "username": ctx.login_username,
- },
- )
- return True
- except urllib.error.HTTPError as exc:
- last_status = exc.code
- last_response = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
- last_exc = exc
- record_event(
- state,
- stage,
- "登录接口返回错误,准备尝试下一个候选登录路径",
- {"url": display_url, "status": exc.code, "response": compact(last_response, 1000)},
+ result = xdfx_auth.fetch_access_token(
+ api_base=ctx.api_base,
+ username=ctx.login_username,
+ password=ctx.login_password,
+ login_path=path,
+ timeout=ctx.timeout,
+ verify_ssl=ctx.verify_ssl,
)
- except Exception as exc: # noqa: BLE001 - auto-login must report every failure.
- last_exc = exc
- last_response = traceback.format_exc()
+ ctx.headers["Authorization"] = xdfx_auth.auth_header_value(result.token)
+ state["_tokenSource"] = "xdfx_auth"
record_event(
state,
stage,
- "登录接口调用异常,准备尝试下一个候选登录路径",
- {"url": display_url, "exceptionType": type(exc).__name__, "exception": str(exc)},
+ "启动阶段已通过 xdfx_auth 包获取 token,并写入 Authorization",
+ {"url": result.url, "status": result.status, "username": ctx.login_username},
+ )
+ return True
+ except xdfx_auth.AuthError as exc:
+ last_status = exc.status
+ last_response = exc.response
+ last_exc = exc
+ record_event(
+ state,
+ stage,
+ "xdfx_auth 获取 token 失败,准备尝试下一个候选登录路径",
+ {"url": exc.url, "status": exc.status, "response": safe_compact(exc.response, 1000), "exception": str(exc)},
)
record_error(
state,
stage,
- "自动登录失败,未能获取 access_token",
- url=redact_url(build_login_url(ctx, ctx.login_path or "/api/v1/auth/login/username")),
+ "自动登录失败,未能通过 xdfx_auth 获取 access_token",
+ url=xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, ctx.login_path or "/api/auth/login")),
status=last_status,
request=payload,
response=last_response,
@@ -612,6 +828,7 @@ def http_request_json_url(
endpoint_key: str | None = None,
allow_auto_login: bool = True,
) -> tuple[Any | None, str]:
+ """Call an HTTP JSON endpoint with logging, retries, and 401 recovery."""
method = method.upper()
if ctx.dry_run:
emit_operation_feedback(state, stage, method, url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
@@ -640,6 +857,7 @@ def http_request_json_url(
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
if exc.code == 401 and allow_auto_login and ctx.auto_login:
+ # Refresh once on 401, then replay the original request exactly.
refreshed = try_auto_login(ctx, state, f"{stage}/auto-login", force=True)
if refreshed:
record_event(
@@ -709,6 +927,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
emit_operation_feedback(state, stage, "POST", url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
record_event(state, stage, f"DRY RUN POST {url}", {"payload": payload})
if endpoint_key.startswith("save_"):
+ # Synthetic ids let downstream dry-run code exercise relation wiring.
synthetic_id = abs(hash(stage + stable_json(payload))) % 100000000
return {"id": synthetic_id, "dryRun": True}, json.dumps({"id": synthetic_id, "dryRun": True})
return {"dryRun": True}, json.dumps({"dryRun": True})
@@ -716,6 +935,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
def parse_json_lenient(text: str) -> Any | None:
+ """Parse normal JSON plus adjacent/SSE-like JSON objects."""
stripped = text.strip()
if not stripped:
return None
@@ -730,6 +950,7 @@ def parse_json_lenient(text: str) -> Any | None:
def parse_concatenated_json_objects(text: str) -> list[Any]:
+ """Read streams such as {"answer": "..."}{"answer": "..."} or data: lines."""
decoder = json.JSONDecoder()
objects: list[Any] = []
i = 0
@@ -766,6 +987,7 @@ def parse_concatenated_json_objects(text: str) -> list[Any]:
def model_text_from_response(parsed: Any, raw: str) -> tuple[str, list[Any]]:
+ """Flatten streamed model chunks into one text buffer."""
objects: list[Any]
if isinstance(parsed, list):
objects = parsed
@@ -824,6 +1046,7 @@ def extract_json_from_text(text: str) -> Any | None:
def extract_json_candidates_from_text(text: str) -> list[Any]:
+ """Collect every plausible JSON object/array from messy model text."""
cleaned = strip_think(text)
candidates: list[Any] = []
seen: set[str] = set()
@@ -860,6 +1083,7 @@ def extract_json_candidates_from_text(text: str) -> list[Any]:
def select_json_candidate(candidates: list[Any], endpoint_key: str, stage: str) -> Any | None:
+ """Pick the candidate whose keys best match the current step."""
if not candidates:
return None
scored = [(json_candidate_score(candidate, endpoint_key, stage), idx, candidate) for idx, candidate in enumerate(candidates)]
@@ -1010,6 +1234,7 @@ def extract_first_balanced(text: str, open_ch: str, close_ch: str) -> str | None
def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[str, list[Any], Any | None]:
+ """Call a model endpoint and retry only known empty/timeout/no-response cases."""
max_attempts = max(ctx.model_retries, 0) + 1
last_text = ""
last_objects: list[Any] = []
@@ -1048,6 +1273,11 @@ def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str,
def call_model_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[dict[str, Any], str, list[Any]]:
+ """Return the best structured JSON for a model step.
+
+ If the first parse fails, the second attempt includes an explicit repair
+ instruction so weak models can re-emit only the required JSON.
+ """
last_text = ""
last_objects: list[Any] = []
last_candidate_count = 0
@@ -1202,23 +1432,85 @@ def profile_info(account: dict[str, Any]) -> str:
def parse_index_list(value: Any) -> list[str]:
- if value is None:
- return []
- if isinstance(value, list):
- return [str(v).strip() for v in value if str(v).strip()]
- text = str(value)
- return [part.strip().strip("<>").strip() for part in re.split(r"[,,;;、\s]+", text) if part.strip()]
+ refs: list[str] = []
+
+ def add(raw: Any) -> None:
+ if raw is None:
+ return
+ if isinstance(raw, dict):
+ keys = (
+ "编号id",
+ "编号ID",
+ "用户编号id",
+ "用户编号ID",
+ "用户编号",
+ "账号编号",
+ "引用序号",
+ "索引",
+ "index",
+ "id",
+ "ids",
+ "accountIndex",
+ "accountIndexes",
+ "userIndex",
+ "userIndexes",
+ )
+ for key in keys:
+ if key in raw:
+ add(raw.get(key))
+ return
+ if isinstance(raw, (list, tuple, set)):
+ for item in raw:
+ add(item)
+ return
+ text = str(raw).strip()
+ if not text:
+ return
+ angle_refs = re.findall(r"<<\s*([^<>]+?)\s*>>", text)
+ if angle_refs:
+ for token in angle_refs:
+ add(token)
+ return
+ for part in re.split(r"[,,;;、\s]+", text):
+ token = part.strip().strip("<>").strip()
+ if token:
+ refs.append(token)
+
+ add(value)
+ deduped: list[str] = []
+ seen: set[str] = set()
+ for ref in refs:
+ if ref in seen:
+ continue
+ seen.add(ref)
+ deduped.append(ref)
+ return deduped
+
+
+def account_ref_numbers(account: dict[str, Any]) -> set[int]:
+ numbers: set[int] = set()
+ meta = account.get("metadata") if isinstance(account.get("metadata"), dict) else {}
+ for key in ("编号id", "编号ID", "用户编号id", "用户编号ID", "用户编号", "账号编号", "引用序号", "索引", "index", "accountIndex"):
+ for container in (account, meta):
+ number = as_int(container.get(key)) if isinstance(container, dict) else None
+ if number is not None:
+ numbers.add(number)
+ return numbers
def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, Any] | None:
+ """Resolve model references like <<3>>, index numbers, names, or account ids."""
if not accounts:
return None
text = str(ref).strip().strip("<>").strip()
number = as_int(text)
if number is not None:
for account in accounts:
- if any(as_int(account.get(key)) == number for key in ("引用序号", "索引", "index")):
+ if number in account_ref_numbers(account):
return account
+ # Step1 group extraction returns 编号/id values that refer to the
+ # previous zhanghao array index. Prefer exact index fields above, then
+ # fall back to zero-based and one-based array positions.
if 0 <= number < len(accounts):
return accounts[number]
if 1 <= number <= len(accounts):
@@ -1232,11 +1524,25 @@ def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, A
def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Normalize audience JSON whether the model used numbered fields or items[]."""
items: list[dict[str, Any]] = []
for i in range(1, 21):
group = first_present(data.get(f"群体{i}"), data.get(f"受众{i}"), data.get(f"targetAudience{i}"))
reason = first_present(data.get(f"理由{i}"), data.get(f"推荐理由{i}"), data.get(f"recReason{i}"))
- users = first_present(data.get(f"用户编号列表{i}"), data.get(f"用户列表{i}"), data.get(f"账号编号列表{i}"))
+ users = first_present(
+ data.get(f"用户编号列表{i}"),
+ data.get(f"编号id列表{i}"),
+ data.get(f"编号ID列表{i}"),
+ data.get(f"用户编号id列表{i}"),
+ data.get(f"用户编号ID列表{i}"),
+ data.get(f"用户列表{i}"),
+ data.get(f"账号编号列表{i}"),
+ data.get(f"索引列表{i}"),
+ data.get(f"编号{i}"),
+ data.get(f"id{i}"),
+ data.get(f"ids{i}"),
+ data.get(f"索引{i}"),
+ )
if group:
items.append(
{
@@ -1255,7 +1561,27 @@ def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
{
"targetAudience": str(group).strip(),
"recReason": str(first_present(item.get("理由"), item.get("recReason"), item.get("reason")) or "").strip(),
- "userIndexes": parse_index_list(first_present(item.get("用户编号列表"), item.get("users"), item.get("accounts"))),
+ "userIndexes": parse_index_list(
+ first_present(
+ item.get("用户编号列表"),
+ item.get("编号id列表"),
+ item.get("编号ID列表"),
+ item.get("用户编号id列表"),
+ item.get("用户编号ID列表"),
+ item.get("编号id"),
+ item.get("编号ID"),
+ item.get("用户编号id"),
+ item.get("用户编号ID"),
+ item.get("编号"),
+ item.get("索引"),
+ item.get("id"),
+ item.get("ids"),
+ item.get("users"),
+ item.get("accounts"),
+ item.get("userIndexes"),
+ item.get("accountIndexes"),
+ )
+ ),
"rank": i,
}
)
@@ -1272,6 +1598,7 @@ def audience_score(item: dict[str, Any]) -> tuple[int, int]:
def select_top_audiences(items: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
+ """Deduplicate audience names and keep the strongest candidates."""
unique: dict[str, dict[str, Any]] = {}
for item in items:
key = item.get("targetAudience", "")
@@ -1301,6 +1628,7 @@ def pathway_score(item: dict[str, Any]) -> tuple[int, int, int]:
def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Merge the same account found from different audiences before saving top 3."""
merged: dict[str, dict[str, Any]] = {}
for item in candidates:
account = item.get("account") or {}
@@ -1325,6 +1653,7 @@ def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str,
def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
+ """Normalize weak-point model output into a capped list."""
vulns: list[dict[str, str]] = []
for i in range(1, 21):
vuln = first_present(data.get(f"脆弱点{i}"), data.get(f"vulnerability{i}"), data.get(f"vuln{i}"))
@@ -1339,6 +1668,7 @@ def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
def extract_strategies(data: dict[str, Any], limit: int) -> list[dict[str, Any]]:
+ """Normalize strategy output and preserve the modal text for later mapping."""
strategies: list[dict[str, Any]] = []
for i in range(1, 21):
title = first_present(data.get(f"信息标题{i}"), data.get(f"主题{i}"), data.get(f"topic{i}"))
@@ -1395,11 +1725,29 @@ def response_id(response: Any) -> int | None:
return None
+def response_rows(response: Any) -> list[Any]:
+ """Extract list rows from common CRUD response shapes."""
+ if isinstance(response, list):
+ return response
+ if not isinstance(response, dict):
+ return []
+ for key in ("data", "rows", "items", "records", "list"):
+ value = response.get(key)
+ if isinstance(value, list):
+ return value
+ rows = response_rows(value)
+ if rows:
+ return rows
+ return []
+
+
def save_with_id(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[int | None, Any | None]:
response, _ = http_post_json(ctx, state, endpoint_key, payload, stage)
rid = response_id(response)
if rid is None:
- record_error(state, stage, "保存接口未返回 id,视为失败", request=payload, response=response)
+ record_error(state, stage, "保存接口未返回 ID,视为失败", request=payload, response=response)
+ else:
+ emit_save_feedback(state, stage, endpoint_key, payload, rid, dry_run=ctx.dry_run)
return rid, response
@@ -1429,6 +1777,7 @@ def parse_step_flags(raw: Any) -> dict[str, Any]:
def mark_step_status(ctx: FlowContext, state: dict[str, Any], links: list[str], stage: str) -> None:
+ """Update the frontend step bar without overwriting unrelated step flags."""
if not ctx.sync_step_status:
record_event(state, stage, "跳过 task-action.step 状态同步")
return
@@ -1477,12 +1826,126 @@ def strategy_platform(value: Any) -> Any:
return platform_to_number(value) or 1
-def strategy_model_type(value: Any) -> str:
- # Frontend defaults to text model type "1" when no modal type is selected.
- raw = first_present(value, "1")
- if isinstance(raw, list):
- return ",".join(str(v) for v in raw if str(v).strip()) or "1"
- return str(raw or "1")
+def choice_tokens(value: Any) -> list[Any]:
+ """Split frontend choice values from arrays, JSON strings, and mixed delimiters."""
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return choice_tokens(
+ first_present(value.get("value"), value.get("dictValue"), value.get("label"), value.get("dictLabel"))
+ )
+ if isinstance(value, (list, tuple, set)):
+ tokens: list[Any] = []
+ for item in value:
+ tokens.extend(choice_tokens(item))
+ return tokens
+ text = str(value).strip()
+ if not text:
+ return []
+ if text[0] in "[{":
+ try:
+ parsed = json.loads(text)
+ if parsed != value:
+ return choice_tokens(parsed)
+ except Exception:
+ pass
+ return [part.strip() for part in re.split(r"[,,、;/|]+", text) if part.strip()]
+
+
+def dict_choices(
+ ctx: FlowContext,
+ state: dict[str, Any],
+ dict_type: str,
+ stage: str,
+) -> list[dict[str, Any]]:
+ """Fetch and cache frontend dictionary choices such as modelType."""
+ cache = state.setdefault("_dictChoiceCache", {})
+ if dict_type in cache:
+ cached = cache[dict_type]
+ return cached if isinstance(cached, list) else []
+ if ctx.dry_run:
+ cache[dict_type] = []
+ return []
+
+ query = urllib.parse.urlencode({"filter": f"dictType||$eq||{dict_type}", "limit": 999, "page": 1})
+ url = f"{url_for(ctx, 'dict_data')}?{query}"
+ response, _ = http_request_json_url(ctx, state, "GET", url, None, stage, endpoint_key="dict_data")
+ choices: list[dict[str, Any]] = []
+ for row in response_rows(response):
+ if not isinstance(row, dict):
+ continue
+ label = str(first_present(row.get("dictLabel"), row.get("label"), "") or "").strip()
+ raw_value = first_present(row.get("dictValue"), row.get("value"))
+ if not label or raw_value is None:
+ continue
+ number = as_int(raw_value)
+ choices.append({"label": label, "value": number if number is not None else str(raw_value).strip()})
+ for alias in choice_tokens(row.get("alias")):
+ alias_label = str(alias).strip()
+ if alias_label:
+ choices.append({"label": alias_label, "value": number if number is not None else str(raw_value).strip()})
+ cache[dict_type] = choices
+ return choices
+
+
+def choice_value_by_label(choices: list[dict[str, Any]] | None, label: str) -> Any | None:
+ needle = re.sub(r"\s+", "", str(label or "").lower())
+ if not needle:
+ return None
+ for item in choices or []:
+ item_label = re.sub(r"\s+", "", str(item.get("label") or "").lower())
+ if item_label and (needle == item_label or needle in item_label or item_label in needle):
+ return item.get("value")
+ return None
+
+
+def model_type_token_value(token: Any, choices: list[dict[str, Any]] | None = None) -> Any:
+ """Convert modal labels like 图片/视频 into frontend dictionary values."""
+ number = as_int(token)
+ if number is not None:
+ return number
+
+ text = str(token or "").strip()
+ lower = text.lower()
+ exact = choice_value_by_label(choices, text)
+ if exact is not None:
+ return exact
+
+ label = ""
+ fallback_value = 2
+ if "视" in text or "video" in lower:
+ label, fallback_value = "视频", 3
+ elif "图" in text or "image" in lower or "picture" in lower or "photo" in lower:
+ label, fallback_value = "图片", 2
+ elif "音" in text or "audio" in lower or "sound" in lower:
+ label, fallback_value = "音频", 4
+ elif "发文" in text or "文本" in text or "文字" in text or "text" in lower:
+ label, fallback_value = "文本", 1
+
+ if label:
+ matched = choice_value_by_label(choices, label)
+ if matched is not None:
+ return matched
+ return fallback_value
+
+ # Mirrors the frontend helper's unknown-token default.
+ return 2
+
+
+def strategy_model_type(value: Any, choices: list[dict[str, Any]] | None = None) -> str:
+ """Return the comma-separated value string expected by task-action-message."""
+ tokens = choice_tokens(value)
+ if not tokens:
+ # Frontend save paths fall back to "1" when no modal type is selected.
+ return "1"
+ values: list[str] = []
+ seen: set[str] = set()
+ for token in tokens:
+ normalized = str(model_type_token_value(token, choices)).strip()
+ if normalized and normalized not in seen:
+ values.append(normalized)
+ seen.add(normalized)
+ return ",".join(values) or "1"
def strategy_language(value: Any) -> int:
@@ -1501,11 +1964,28 @@ def save_strategy_details(
*,
audience: bool,
) -> None:
- platform = strategy_platform(first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})))
+ """Save all frontend-readable strategy child rows.
+
+ The strategy table and arrangement page do not read only the main strategy
+ row; they also expect channel, narrate, message, and relation rows.
+ """
+ platform = strategy_platform(
+ first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
+ )
+ account_type = str(
+ first_present(
+ strategy.get("accountType"),
+ item.get("accountType"),
+ item.get("type"),
+ "目标受众账号" if audience else "关键传播节点账号",
+ )
+ or ""
+ ).strip()
persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
title = str(strategy.get("title") or "").strip()
+ model_type_choices = dict_choices(ctx, state, "modelType", stage + "/dict-modelType")
channel_id, _ = save_with_id(
ctx,
@@ -1516,6 +1996,7 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"platform": platform,
+ "accountType": account_type,
"personaJson": json.dumps([{"persona": persona, "size": persona_size}], ensure_ascii=False),
},
stage + "/save-channel",
@@ -1531,7 +2012,10 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"channelId": channel_id,
- "modelType": strategy_model_type(first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType"))),
+ "modelType": strategy_model_type(
+ first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType")),
+ model_type_choices,
+ ),
"language": strategy_language(first_present(strategy.get("language"), strategy.get("语种"))),
"respondingCount": responding_count,
"messageCount": responding_count,
@@ -1564,7 +2048,46 @@ def object_for_audience(audience: dict[str, Any]) -> dict[str, Any]:
}
+def selected_accounts_for_item(item: dict[str, Any], *, audience: bool) -> list[Any]:
+ if audience:
+ accounts = item.get("accounts") or []
+ return accounts or [object_for_audience(item)]
+ return [item.get("account") or item]
+
+
+def downstream_limit(ctx: FlowContext, *, audience: bool) -> int:
+ """Limit expensive downstream analysis while keeping 3 upstream rows stored."""
+ raw = ctx.downstream_audiences if audience else ctx.downstream_pathways
+ return max(1, raw)
+
+
+def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, audience: bool) -> list[dict[str, Any]]:
+ """Select the 1-2 best stored rows for vulnerability/strategy generation."""
+ source_key = "topAudiences" if audience else "topPathways"
+ id_key = "designId" if audience else "pathwayAccountId"
+ selected_key = "selectedDownstreamAudiences" if audience else "selectedDownstreamPathways"
+ items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)]
+ limit = downstream_limit(ctx, audience=audience)
+ selected = items[:limit]
+ state[selected_key] = [
+ {
+ "id": item.get(id_key),
+ "name": item.get("targetAudience") if audience else item.get("accountName"),
+ }
+ for item in selected
+ ]
+ if items:
+ record_event(
+ state,
+ stage,
+ f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}",
+ {"limit": limit, "selected": state[selected_key]},
+ )
+ return selected
+
+
def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze audiences, save top 3, and attach any referenced accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step1-audience"
if state.get("topAudiences") and not ctx.dry_run:
@@ -1582,12 +2105,15 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
state["audienceRawText"] = text
+ # The first model call may return raw text plus embedded account objects.
accounts = accounts_from_response(parsed, objects, text)
state["audienceAccounts"] = accounts
+ # The second model call converts the long audience analysis into numbered
+ # groups that can be saved as task-action-design rows.
group_payload = {
"mubiao": state["mubiao"],
- "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
"zhanghao": accounts,
"history": [],
"stream": True,
@@ -1602,6 +2128,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
top = select_top_audiences(candidates, ctx.max_audiences)
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the audience first; account rows and downstream analyses depend
+ # on the returned designId.
design_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
@@ -1614,6 +2142,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
continue
item = {**item, "designId": design_id, "saveResponse": resp, "accounts": [], "savedAccountIds": []}
for ref in item.get("userIndexes") or []:
+ # Model references are intentionally fuzzy, so resolve them against
+ # both explicit indexes and account names/ids.
account = resolve_account_ref(ref, accounts)
if account is None:
record_error(state, stage + "/save-design-account", f"用户编号 {ref} 未匹配到候选账号")
@@ -1645,6 +2175,7 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze key pathway accounts for saved audiences and store top 3."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step2-pathway"
audiences = [a for a in state.get("topAudiences") or [] if a.get("designId")]
@@ -1656,6 +2187,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
return
all_candidates: list[dict[str, Any]] = []
for audience in audiences:
+ # Analyze pathway candidates in the context of each saved audience.
payload = {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。",
"otherinfo": {
@@ -1673,6 +2205,8 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
accounts = accounts_from_response(parsed, objects, text)
+ # Ask the model to point back to candidate accounts, then resolve those
+ # references to concrete account objects.
spread_payload = {
"mubiao": state["mubiao"],
"user_message": "分析其中关键信息。请从候选账号中抽取核心传播节点,字段必须是:账号1、理由1、账号2、理由2 ...,最多输出5个。账号字段优先返回候选数组的引用编号或索引,只返回JSON。",
@@ -1707,6 +2241,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways]
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the account row before writing the design-pathway relation.
account = item["account"]
aid = account_id(account)
name = account_name(account)
@@ -1757,18 +2292,20 @@ def run_step4(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate and save weak points for selected audiences or pathway accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step3-audience-vulns" if audience else "step4-pathway-vulns"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
output_key = "audienceVulns" if audience else "pathwayVulns"
state.setdefault(output_key, {})
for item in items:
+ # owner_id is designId for audiences and pathwayAccountId for accounts.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
if not owner_id:
continue
if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run:
continue
- zhanghao = item.get("accounts") or [object_for_audience(item)] if audience else [item.get("account") or item]
+ zhanghao = selected_accounts_for_item(item, audience=audience)
prompt = (
"你是中国对台湾方向的认知战专家,你要传播行动要求的叙事主题。请基于该受众目标的画像、关注议题、社会分工定位、利益诉求与上一轮账号佐证,分析该受众目标有哪些脆弱点可以成为后续策略设计的关键要素。请输出理由、脆弱点1、理由1、脆弱点2、理由2、脆弱点3、理由3,只返回JSON。"
if audience
@@ -1789,6 +2326,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) ->
vulns = extract_vulns(data, ctx.max_vulns)
saved: list[dict[str, Any]] = []
for vuln in vulns:
+ # The two weak-point tables have nearly identical payloads but
+ # different foreign-key names.
if audience:
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
@@ -1835,14 +2374,16 @@ def run_step6(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate strategies from saved weak points and persist frontend child rows."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step5-audience-strategy" if audience else "step6-pathway-strategy"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {}
output_key = "audienceStrategies" if audience else "pathwayStrategies"
state.setdefault(output_key, [])
existing_topics = {s.get("topic") for s in state[output_key] if isinstance(s, dict)}
for item in items:
+ # Strategies are only meaningful after the matching weak points exist.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
vulns = vulns_by_owner.get(str(owner_id)) or []
vuln_texts = [v.get("vulnerabilities") for v in vulns if v.get("vulnerabilities")]
@@ -1850,7 +2391,9 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
record_error(state, stage, "没有可用脆弱点,跳过策略生成", request=item)
continue
target_names = [item.get("targetAudience")] if audience else (item.get("relatedAudiences") or [item.get("accountName")])
- pingtai = state.get("platform") or (platform_label(item.get("account") or {}) if not audience else "")
+ zhanghao = selected_accounts_for_item(item, audience=audience)
+ first_account = zhanghao[0] if zhanghao and isinstance(zhanghao[0], dict) else {}
+ pingtai = state.get("platform") or platform_label(first_account)
payload = {
"user_message": DEFAULT_STRATEGY_PROMPT_AUDIENCE if audience else DEFAULT_STRATEGY_PROMPT_PATHWAY,
"cuiruodian": vuln_texts,
@@ -1858,6 +2401,7 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
"shouzhong": [name for name in target_names if name],
"mudi": extract_goal(state["mubiao"]),
"heiliao": [],
+ "zhanghao": zhanghao,
"history": [],
"stream": True,
"model_name": "OpenAI3",
@@ -1876,16 +2420,20 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
continue
if topic in existing_topics:
continue
+ # Save the main row first; channel/narrate/message/relation rows all
+ # require the returned strategy id.
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
"targetAudience": target,
"accountType": account_type,
"topic": topic,
- "strategyTitle": "",
+ "strategyTitle": str(strategy.get("title") or "").strip(),
}
strategy_id, resp = save_with_id(ctx, state, "save_strategy", save_payload, stage + "/save")
if strategy_id is not None:
+ # Frontend strategy pages read these child tables, so a strategy
+ # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience)
existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp})
@@ -1914,13 +2462,14 @@ STEP_LABELS = {
def state_counts(state: dict[str, Any]) -> dict[str, int]:
+ """Summarize stored objects for step feedback."""
return {
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceVulns": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
- "pathwayVulns": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "受众脆弱点数量": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
+ "传播节点脆弱点数量": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
}
@@ -1932,23 +2481,26 @@ def emit_step_feedback(
status: str | None = None,
note: str = "",
) -> None:
+ # One feedback line per step keeps the single-skill and split-skill versions
+ # equally transparent to the user.
total_errors = len(state.get("errors") or [])
new_errors = max(0, total_errors - before_error_count)
final_status = status or ("partial" if new_errors else "ok")
payload = {
- "type": "STEP_FEEDBACK",
- "step": step,
- "label": STEP_LABELS.get(step, step),
- "status": final_status,
- "note": note,
- "newErrors": new_errors,
- "totalErrors": total_errors,
- "counts": state_counts(state),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "updatedAt": state.get("updatedAt"),
+ "日志类型": "步骤反馈",
+ "步骤": STEP_NUMBER_LABELS.get(step, step),
+ "步骤名称": STEP_LABELS.get(step, step),
+ "状态": STATUS_LABELS.get(final_status, final_status),
+ "执行状态": status_label(final_status),
+ "备注": note,
+ "本步骤新增错误数": new_errors,
+ "累计错误数": total_errors,
+ "累计入库数量": state_counts(state),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "更新时间": state.get("updatedAt"),
}
- print("STEP_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["step"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def finish_step(
@@ -1960,6 +2512,8 @@ def finish_step(
status: str | None = None,
note: str = "",
) -> None:
+ # Always persist before emitting step feedback so the user can inspect files
+ # immediately after a reported step completes.
state["updatedAt"] = utc_now()
save_json_file(ctx.state_path, state)
if ctx.summary_path:
@@ -1980,6 +2534,11 @@ def skip_step_with_feedback(ctx: FlowContext, state: dict[str, Any], step: str,
def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Legacy full runner.
+
+ The wrapper script is preferred for real-time logs, but this remains useful
+ for dry-runs and unattended local troubleshooting.
+ """
run_step_with_feedback(ctx, state, "step1")
run_step_with_feedback(ctx, state, "step2")
run_step_with_feedback(ctx, state, "step3")
@@ -1995,6 +2554,7 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
def write_summary(path: Path, state: dict[str, Any]) -> None:
+ """Write a human-readable checkpoint beside the machine state JSON."""
audience_vuln_count = sum(len(v) for v in (state.get("audienceVulns") or {}).values())
pathway_vuln_count = sum(len(v) for v in (state.get("pathwayVulns") or {}).values())
lines = [
@@ -2005,6 +2565,8 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
f"- actionId:{state.get('actionId')}",
f"- 受众入库:{len(state.get('topAudiences') or [])}",
f"- 传播节点入库:{len(state.get('topPathways') or [])}",
+ f"- 下游受众选择:{len(state.get('selectedDownstreamAudiences') or [])}",
+ f"- 下游传播节点选择:{len(state.get('selectedDownstreamPathways') or [])}",
f"- 受众脆弱点入库:{audience_vuln_count}",
f"- 传播节点脆弱点入库:{pathway_vuln_count}",
f"- 受众策略入库:{len(state.get('audienceStrategies') or [])}",
@@ -2021,9 +2583,9 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
if state.get("errors"):
lines += ["", "## 失败明细"]
for err in state["errors"]:
- lines.append(f"- [{err.get('stage')}] {err.get('message')} status={err.get('status')} url={err.get('url')}")
+ lines.append(f"- [{stage_label(err.get('stage'))}] {err.get('message')} 状态码={err.get('status')} 接口={err.get('url')}")
if err.get("response"):
- lines.append(f" - response: {err.get('response')}")
+ lines.append(f" - 响应摘要:{err.get('response')}")
text = "\n".join(lines) + "\n"
try:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -2034,34 +2596,34 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
def build_arg_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(description="Run xdfx analysis/storage flow.")
+ parser = argparse.ArgumentParser(description="运行 xdfx 分析与入库流程。")
parser.add_argument("--mode", choices=["full", *STEP_FUNCS.keys()], default="full")
- parser.add_argument("--input", help="JSON config file. Keys: api_base, taskId, actionId, mubiao, direction, platform, headers.")
- parser.add_argument("--state", default="xdfx-flow-state.json", help="State JSON path for resume and step handoff.")
- parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown summary output path.")
+ parser.add_argument("--input", help="JSON 配置文件。常用字段:api_base、taskId、actionId、mubiao、direction、platform、headers。")
+ parser.add_argument("--state", default="xdfx-flow-state.json", help="用于断点续跑和步骤交接的状态 JSON 文件路径。")
+ parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown 执行摘要输出路径。")
parser.add_argument("--api-base")
parser.add_argument("--task-id")
parser.add_argument("--action-id")
parser.add_argument("--mubiao")
parser.add_argument("--direction", default=None)
parser.add_argument("--platform", default=None)
- parser.add_argument("--auth-token", default=os.getenv("XDFX_AUTH_TOKEN"))
- parser.add_argument("--authorization", default=os.getenv("AUTHORIZATION"))
- parser.add_argument("--header", action="append", default=[], help="Extra HTTP header, format 'Key: Value'.")
- parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="Username for automatic login when Authorization is missing or stale.")
- parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="Password query parameter for automatic username login.")
- parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="Login endpoint path or absolute URL.")
- parser.add_argument("--no-auto-login", action="store_true", help="Disable automatic username login and 401 token refresh.")
- parser.add_argument("--verify-ssl", action="store_true", help="Verify HTTPS certificates. Default is disabled for internal/UAT certificates.")
+ parser.add_argument("--header", action="append", default=[], help="附加非鉴权 HTTP 请求头,格式为 'Key: Value';Authorization 会被忽略。")
+ parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="xdfx_auth 启动阶段自动登录使用的用户名。")
+ parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="登录接口 JSON 请求体中的 password。")
+ parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/auth/login"), help="登录接口路径或完整 URL。")
+ parser.add_argument("--no-auto-login", action="store_true", help="关闭 xdfx_auth 自动登录和 401 刷新。")
+ parser.add_argument("--verify-ssl", action="store_true", help="校验 HTTPS 证书。默认不校验,以兼容内网/UAT 自签证书。")
parser.add_argument("--timeout", type=float, default=90)
parser.add_argument("--retries", type=int, default=2)
parser.add_argument("--dry-run", action="store_true")
- parser.add_argument("--no-step-status", action="store_true", help="Do not update task/task-action.step after completed steps.")
- parser.add_argument("--model-retries", type=int, default=1, help="Retry model/stream endpoints when the first response is empty, timeout-like, or says the model did not respond.")
+ parser.add_argument("--no-step-status", action="store_true", help="步骤完成后不更新 task/task-action.step。")
+ parser.add_argument("--model-retries", type=int, default=1, help="模型/流式接口首次空响应、超时或提示模型无响应时的重试次数。")
parser.add_argument("--max-audiences", type=int, default=3)
parser.add_argument("--max-pathways", type=int, default=3)
parser.add_argument("--max-vulns", type=int, default=3)
parser.add_argument("--max-strategies", type=int, default=3)
+ parser.add_argument("--downstream-audiences", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库受众数量,默认 2。")
+ parser.add_argument("--downstream-pathways", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库传播节点数量,默认 2。")
return parser
@@ -2070,6 +2632,8 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
state_path = Path(args.state)
state = default_state()
+ # Load previous state first, then overlay input JSON and finally CLI flags.
+ # That ordering makes resume safe while still allowing explicit overrides.
if state_path.exists():
loaded = load_json_file(state_path)
if isinstance(loaded, dict):
@@ -2081,9 +2645,7 @@ def main(argv: list[str] | None = None) -> int:
merge_config_into_state(state, config)
input_headers = config.get("headers")
if isinstance(input_headers, dict):
- args.header.extend(f"{k}: {v}" for k, v in input_headers.items())
- args.auth_token = args.auth_token or config.get("auth_token")
- args.authorization = args.authorization or config.get("authorization")
+ args.header.extend(f"{k}: {v}" for k, v in input_headers.items() if str(k).lower() != "authorization")
if config.get("login_username") is not None:
args.login_username = str(config.get("login_username") or "")
if config.get("login_password") is not None:
@@ -2094,6 +2656,10 @@ def main(argv: list[str] | None = None) -> int:
args.no_auto_login = True
if config.get("verify_ssl") is not None:
args.verify_ssl = bool(config.get("verify_ssl"))
+ if config.get("downstream_audiences") is not None:
+ args.downstream_audiences = as_int(config.get("downstream_audiences")) or args.downstream_audiences
+ if config.get("downstream_pathways") is not None:
+ args.downstream_pathways = as_int(config.get("downstream_pathways")) or args.downstream_pathways
cli_config = {
"api_base": args.api_base,
@@ -2105,7 +2671,8 @@ def main(argv: list[str] | None = None) -> int:
}
merge_config_into_state(state, {k: v for k, v in cli_config.items() if v is not None})
api_base = state.get("api_base") or "https://ch1.b.uat.4.cn"
- headers = parse_headers(args.header, args.auth_token, args.authorization)
+ headers = parse_headers(args.header)
+ # ctx owns runtime knobs; state owns business data and resumable ids.
ctx = FlowContext(
api_base=api_base,
headers=headers,
@@ -2118,6 +2685,8 @@ def main(argv: list[str] | None = None) -> int:
max_pathways=max(args.max_pathways, 1),
max_vulns=max(args.max_vulns, 1),
max_strategies=max(args.max_strategies, 1),
+ downstream_audiences=max(args.downstream_audiences, 1),
+ downstream_pathways=max(args.downstream_pathways, 1),
sync_step_status=not args.no_step_status,
model_retries=max(args.model_retries, 0),
verify_ssl=bool(args.verify_ssl),
@@ -2131,11 +2700,17 @@ def main(argv: list[str] | None = None) -> int:
state["_summaryPath"] = str(ctx.summary_path) if ctx.summary_path else ""
state["_autoLogin"] = ctx.auto_login
state["_verifySsl"] = ctx.verify_ssl
+ state["_downstreamLimits"] = {
+ "audiences": ctx.downstream_audiences,
+ "pathways": ctx.downstream_pathways,
+ }
record_event(state, "runner", f"start mode={args.mode} dry_run={ctx.dry_run}")
try:
require_context(state, "taskId", "actionId", "mubiao")
- if ctx.auto_login and not has_authorization(ctx.headers):
- try_auto_login(ctx, state, "runner/auto-login")
+ if ctx.auto_login and not ctx.dry_run:
+ # Always acquire a token through the imported auth package before the first business call.
+ if not try_auto_login(ctx, state, "runner/initial-login", force=True):
+ raise FlowError("启动阶段获取 token 失败,已停止执行,避免无 token 调用业务接口")
if args.mode == "full":
run_full(ctx, state)
else:
@@ -2145,13 +2720,14 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(f"ERROR: {exc}", file=sys.stderr)
+ print(f"错误:{exc}", file=sys.stderr)
return 2
except Exception as exc: # noqa: BLE001
record_error(state, "runner", "未捕获异常", response=traceback.format_exc(), exception=exc)
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
+ print("未捕获异常堆栈:", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
return 1
@@ -2159,20 +2735,23 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(json.dumps(
- {
- "ok": True,
- "mode": args.mode,
- "state": str(state_path),
- "summary": str(ctx.summary_path) if ctx.summary_path else "",
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
- "errors": len(state.get("errors") or []),
- },
- ensure_ascii=False,
- ))
+ # Final JSON is intentionally compact; detailed per-step status has already
+ # been emitted as Chinese step feedback.
+ final_payload = {
+ "日志类型": "执行完成",
+ "是否成功": "是",
+ "执行模式": mode_label(args.mode),
+ "状态文件": str(state_path),
+ "摘要文件": str(ctx.summary_path) if ctx.summary_path else "",
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "已选择受众数": len(state.get("selectedDownstreamAudiences") or []),
+ "已选择传播节点数": len(state.get("selectedDownstreamPathways") or []),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
+ "错误数": len(state.get("errors") or []),
+ }
+ print(LOG_PREFIXES["done"] + " " + json.dumps(final_payload, ensure_ascii=False))
return 0
diff --git a/skills-data/xdfx-step4-pathway-vulns/SKILL.md b/skills-data/xdfx-step4-pathway-vulns/SKILL.md
index 98374914..f845c8fe 100644
--- a/skills-data/xdfx-step4-pathway-vulns/SKILL.md
+++ b/skills-data/xdfx-step4-pathway-vulns/SKILL.md
@@ -13,26 +13,37 @@ description: xdfx 拆分流程 step4,为已入库关键传播节点抽取并
python scripts/xdfx_flow.py --mode step4 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
-鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
+鉴权和证书由脚本兜底处理:脚本启动时先 `import scripts/xdfx_auth.py`,自动用 `admin / ch@user` 获取 `access_token`,并在第一次业务接口调用前写入内部 Authorization;不要通过命令行传 token。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
-- 对每个已入库关键传播节点抽取最多 3 个脆弱点。
+- 从已入库关键传播节点中默认选择前 2 个继续分析;可用 `--downstream-pathways 1` 调成只处理 1 个。
+- 对每个被选中的关键传播节点抽取并入库最多 3 个脆弱点。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 自动修复和解析模型 JSON。
- 调用传播节点脆弱点保存接口。
- 只有保存成功并返回 `id` 的脆弱点才进入后续策略阶段。
- 同步前端步骤状态 `step-analysis-weakpoint`。
-- 单个节点失败不影响其他节点。
+- 单个节点失败会写入错误;智能体必须按失败重试规则先重跑 Step4,不能直接跳到 Step5/Step6。
完成后查看 `pathwayVulns` 和 `errors`。
+## 失败重试规则
+
+- 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前 Step4 命令。
+- 重试前必须告诉用户失败阶段、接口 URL、状态码、响应摘要和“正在重试 Step4”,不能静默重试。
+- Step4 未重试成功前,不允许继续 Step5/Step6;不要因为 Step4 失败就跳过传播节点脆弱点分析。
+- Step4 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
+
## 步骤反馈
-命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step4 的完成状态、当前传播节点脆弱点数量和新增错误数,再决定是否继续下一步。
+命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step4 的完成状态、当前传播节点脆弱点数量和新增错误数,再决定是否继续下一步。
-执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
+执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。
-如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的传播节点脆弱点和返回 ID。
+
+如果出现 `错误反馈 {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+
+如果出现 `JSON解析复核 {...}`,必须查看 `响应片段`,判断是否能识别传播节点脆弱点 JSON;无法确认时重跑 Step4。
-如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别传播节点脆弱点 JSON;无法确认时重跑 Step4。
diff --git a/skills-data/xdfx-step4-pathway-vulns/scripts/xdfx_auth.py b/skills-data/xdfx-step4-pathway-vulns/scripts/xdfx_auth.py
new file mode 100644
index 00000000..4003e55d
--- /dev/null
+++ b/skills-data/xdfx-step4-pathway-vulns/scripts/xdfx_auth.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+"""Authentication helper for xdfx skill scripts.
+
+The caller should not pass tokens through command-line arguments. This module
+owns the fixed admin login flow and returns a Bearer token before business
+interfaces are called.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import ssl
+import urllib.error
+import urllib.request
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass
+class AuthResult:
+ token: str
+ url: str
+ status: int | None
+ raw: str
+
+
+class AuthError(RuntimeError):
+ def __init__(
+ self,
+ message: str,
+ *,
+ url: str,
+ status: int | None = None,
+ response: str = "",
+ exception: BaseException | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.url = url
+ self.status = status
+ self.response = response
+ self.exception = exception
+
+
+def auth_header_value(token: str) -> str:
+ token = token.strip()
+ return token if token.lower().startswith("bearer ") else f"Bearer {token}"
+
+
+def redact_url(url: str) -> str:
+ return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
+
+
+def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
+ if verify_ssl:
+ return None
+ return ssl._create_unverified_context()
+
+
+def build_login_url(api_base: str, path: str) -> str:
+ return path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
+
+
+def extract_access_token(data: Any) -> str | None:
+ if isinstance(data, dict):
+ for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
+ value = data.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ for value in data.values():
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ elif isinstance(data, list):
+ for value in data:
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ return None
+
+
+def parse_json_loose(text: str) -> Any:
+ try:
+ return json.loads(text)
+ except Exception:
+ pass
+ match = re.search(r"[\[{]", text or "")
+ if not match:
+ return {}
+ start = match.start()
+ for end in range(len(text), start, -1):
+ try:
+ return json.loads(text[start:end])
+ except Exception:
+ continue
+ return {}
+
+
+def fetch_access_token(
+ *,
+ api_base: str,
+ username: str = "admin",
+ password: str = "ch@user",
+ login_path: str = "/api/auth/login",
+ timeout: float = 90,
+ verify_ssl: bool = False,
+) -> AuthResult:
+ """Login once and return the extracted access token."""
+ url = build_login_url(api_base, login_path)
+ payload = {"username": username, "password": password}
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ parsed = parse_json_loose(raw)
+ token = extract_access_token(parsed)
+ if not token:
+ raise AuthError(
+ "登录接口已响应但未找到 access_token",
+ url=redact_url(url),
+ status=getattr(resp, "status", None),
+ response=raw,
+ )
+ return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
+ raise AuthError(
+ "登录接口返回错误",
+ url=redact_url(url),
+ status=exc.code,
+ response=body,
+ exception=exc,
+ ) from exc
+ except AuthError:
+ raise
+ except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
+ raise AuthError(
+ "登录接口调用异常",
+ url=redact_url(url),
+ response=str(exc),
+ exception=exc,
+ ) from exc
diff --git a/skills-data/xdfx-step4-pathway-vulns/scripts/xdfx_flow.py b/skills-data/xdfx-step4-pathway-vulns/scripts/xdfx_flow.py
index 52880e82..d9580e2f 100644
--- a/skills-data/xdfx-step4-pathway-vulns/scripts/xdfx_flow.py
+++ b/skills-data/xdfx-step4-pathway-vulns/scripts/xdfx_flow.py
@@ -26,7 +26,10 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
+import xdfx_auth
+
+# Keep every backend path in one table so endpoint drift is easy to audit.
ENDPOINTS = {
"aiuser": "/gpt/chat/chat_aiuser",
"group": "/gpt/chat/chat_qianqian_group_chat",
@@ -44,10 +47,12 @@ ENDPOINTS = {
"save_narrate": "/api/base/base-narrate",
"save_message": "/api/task/task-action-message",
"save_strategy_relation": "/api/task/task-action-design-strategy-relation",
+ "dict_data": "/api/sys/sys-dict-data",
}
+# Human-readable names are used only in the Chinese feedback logs.
ENDPOINT_LABELS = {
- "login": "登录获取访问 token",
+ "login": "登录获取访问令牌",
"aiuser": "调用候选账号/受众分析模型接口",
"group": "调用受众目标结构化抽取接口",
"spread": "调用关键传播节点结构化抽取接口",
@@ -64,8 +69,90 @@ ENDPOINT_LABELS = {
"save_narrate": "保存策略信息主题",
"save_message": "保存策略消息配置",
"save_strategy_relation": "保存策略与受众/传播节点关系",
+ "dict_data": "读取前端字典数据",
}
+LOG_PREFIXES = {
+ "operation": "操作反馈",
+ "error": "错误反馈",
+ "json_review": "JSON解析复核",
+ "save": "入库反馈",
+ "step": "步骤反馈",
+ "done": "执行完成",
+}
+
+STATUS_LABELS = {
+ "ok": "成功",
+ "partial": "部分完成",
+ "skipped": "已跳过",
+}
+
+STEP_NUMBER_LABELS = {
+ "step1": "第一步",
+ "step2": "第二步",
+ "step3": "第三步",
+ "step4": "第四步",
+ "step5": "第五步",
+ "step6": "第六步",
+}
+
+HTTP_METHOD_LABELS = {
+ "GET": "读取(GET)",
+ "POST": "提交(POST)",
+ "PUT": "更新(PUT)",
+ "PATCH": "局部更新(PATCH)",
+ "DELETE": "删除(DELETE)",
+}
+
+STAGE_SEGMENT_LABELS = {
+ "analyze": "模型分析",
+ "extract": "结构化抽取",
+ "save": "保存数据",
+ "read": "读取数据",
+ "status": "同步状态",
+}
+
+REQUEST_FIELD_LABELS = {
+ "history": "历史消息",
+ "max_tokens": "最大生成令牌数",
+ "model_name": "模型名称",
+ "otherinfo": "其他信息",
+ "prompt_name": "提示词名称",
+ "stream": "是否流式",
+ "temperature": "温度",
+ "usermessage": "用户消息",
+ "user_message": "用户消息",
+ "username": "登录用户名",
+ "password": "登录密码",
+ "mubiao": "行动目标",
+ "zhanghao": "账号列表",
+ "taskId": "任务ID",
+ "actionId": "行动ID",
+ "taskActionId": "行动ID",
+ "designId": "受众ID",
+ "pathwayId": "传播节点ID",
+ "strategyId": "策略ID",
+ "accountId": "账号ID",
+ "accountName": "账号名称",
+ "accountType": "账号类型",
+ "targetAudience": "受众目标",
+ "vulnerabilities": "脆弱点",
+ "topic": "信息标题",
+ "narrate": "叙事主题",
+ "platform": "平台",
+ "persona": "发布人设",
+ "modelType": "信息形式",
+ "quantity": "数量",
+ "publishTime": "发布时间段",
+ "reason": "理由",
+ "ids": "ID列表",
+ "id": "ID",
+ "step": "步骤状态",
+}
+
+SENSITIVE_KEY_PARTS = ("password", "token", "authorization", "access_token", "accesstoken", "authtoken")
+
+# Platform values mirror the frontend dictionaries used by xdfx strategy pages.
PLATFORM_MAP = {
"facebook": 1,
"fb": 1,
@@ -78,6 +165,8 @@ PLATFORM_MAP = {
"yt": 4,
}
+# The model service sometimes returns a successful HTTP response with these
+# strings instead of useful content. Treat them like transient failures.
MODEL_RETRY_MARKERS = (
"y模型没有响应",
"模型没有响应",
@@ -118,6 +207,12 @@ class FlowError(RuntimeError):
@dataclass
class FlowContext:
+ """Runtime options shared by every step.
+
+ Keep this object boring and serializable-ish: it is passed through every
+ helper, while durable progress belongs in the state JSON file.
+ """
+
api_base: str
headers: dict[str, str]
timeout: float
@@ -129,13 +224,15 @@ class FlowContext:
max_pathways: int = 3
max_vulns: int = 3
max_strategies: int = 3
+ downstream_audiences: int = 2
+ downstream_pathways: int = 2
sync_step_status: bool = True
model_retries: int = 1
verify_ssl: bool = False
auto_login: bool = True
login_username: str = "admin"
login_password: str = "ch@user"
- login_path: str = "/api/v1/auth/login/username"
+ login_path: str = "/api/auth/login"
login_attempt_count: int = 0
@@ -153,7 +250,38 @@ def compact(value: Any, limit: int = 1400) -> str:
text = repr(value)
if len(text) <= limit:
return text
- return text[:limit] + f"... "
+ return text[:limit] + f"... <已截断 {len(text) - limit} 个字符>"
+
+
+def is_sensitive_key(key: Any) -> bool:
+ normalized = re.sub(r"[^a-z0-9_]", "", str(key or "").lower())
+ return any(part in normalized for part in SENSITIVE_KEY_PARTS)
+
+
+def redact_sensitive(value: Any) -> Any:
+ if isinstance(value, dict):
+ return {key: "***" if is_sensitive_key(key) else redact_sensitive(item) for key, item in value.items()}
+ if isinstance(value, list):
+ return [redact_sensitive(item) for item in value]
+ if isinstance(value, tuple):
+ return tuple(redact_sensitive(item) for item in value)
+ if isinstance(value, str):
+ text = re.sub(r"(?i)(Bearer\s+)[^\s\",}]+", r"\1***", value)
+ text = re.sub(
+ r"(?i)(\"(?:password|token|authToken|access_token|accessToken|authorization)\"\s*:\s*)\"[^\"]*\"",
+ r'\1"***"',
+ text,
+ )
+ return re.sub(
+ r"(?i)((?:password|token|authToken|access_token|accessToken|authorization)=)[^&\s]+",
+ r"\1***",
+ text,
+ )
+ return value
+
+
+def safe_compact(value: Any, limit: int = 1400) -> str:
+ return compact(redact_sensitive(value), limit)
def stable_json(value: Any) -> str:
@@ -223,6 +351,11 @@ def save_json_file(path: str | Path, value: Any) -> None:
def default_state() -> dict[str, Any]:
+ """Create a resumable state container.
+
+ The script can be run as six independent commands. IDs returned by one step
+ are persisted here so later steps can resume without redoing earlier saves.
+ """
return {
"api_base": "",
"taskId": "",
@@ -247,6 +380,7 @@ def default_state() -> dict[str, Any]:
def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> None:
+ # Accept both frontend-style camelCase and CLI-friendly snake_case inputs.
aliases = {
"task_id": "taskId",
"action_id": "actionId",
@@ -260,16 +394,12 @@ def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> No
continue
if dest in {"taskId", "actionId"}:
state[dest] = str(value)
- elif dest in state or dest in {
- "api_base",
- "headers",
- "auth_token",
- "authorization",
- }:
+ elif dest in state or dest in {"api_base", "headers"}:
state[dest] = value
def record_event(state: dict[str, Any], stage: str, message: str, data: Any | None = None) -> None:
+ """Append a compact diagnostic event to the state file."""
state.setdefault("events", []).append(
{
"time": utc_now(),
@@ -286,9 +416,13 @@ def feedback_step(stage: str) -> str:
return match.group(1) if match else ""
+def feedback_step_label(stage: str) -> str:
+ return STEP_NUMBER_LABELS.get(feedback_step(stage), "")
+
+
def request_keys(payload: Any) -> list[str]:
if isinstance(payload, dict):
- return sorted(str(key) for key in payload.keys())
+ return sorted(REQUEST_FIELD_LABELS.get(str(key), str(key)) for key in payload.keys())
return []
@@ -306,6 +440,77 @@ def endpoint_label(endpoint_key: str | None, method: str, url: str) -> str:
return f"调用接口 {method}"
+def method_label(method: str) -> str:
+ return HTTP_METHOD_LABELS.get(str(method).upper(), str(method))
+
+
+def yes_no(value: bool) -> str:
+ return "是" if value else "否"
+
+
+def stage_label(stage: Any) -> str:
+ text = str(stage or "")
+ if not text:
+ return ""
+ if text in STEP_LABELS:
+ return STEP_LABELS[text]
+ if text == "runner":
+ return "流程执行器"
+ parts = text.split("/")
+ base = parts[0]
+ match = re.search(r"(step[1-6])", base)
+ labels: list[str] = []
+ if match:
+ labels.append(STEP_LABELS.get(match.group(1), match.group(1)))
+ elif base == "runner":
+ labels.append("流程执行器")
+ else:
+ labels.append(base)
+ for segment in parts[1:]:
+ normalized = segment.replace("-", "_")
+ attempt = re.fullmatch(r"model-call-attempt-(\d+)", segment)
+ json_attempt = re.fullmatch(r"model-json-attempt-(\d+)", segment)
+ if attempt:
+ labels.append(f"模型接口第 {attempt.group(1)} 次尝试")
+ elif json_attempt:
+ labels.append(f"JSON解析第 {json_attempt.group(1)} 次尝试")
+ elif segment == "auto-login":
+ labels.append("自动登录")
+ elif segment in STAGE_SEGMENT_LABELS:
+ labels.append(STAGE_SEGMENT_LABELS[segment])
+ elif normalized in ENDPOINT_LABELS:
+ labels.append(ENDPOINT_LABELS[normalized])
+ else:
+ labels.append(segment)
+ return " / ".join(labels)
+
+
+def status_label(status: Any) -> str:
+ if status is None:
+ return ""
+ return STATUS_LABELS.get(str(status), str(status))
+
+
+def mode_label(mode: str) -> str:
+ if mode == "full":
+ return "全流程"
+ return STEP_LABELS.get(mode, mode)
+
+
+def exception_type_label(exception_type: Any) -> str:
+ name = str(exception_type or "")
+ labels = {
+ "HTTPError": "HTTP 调用异常",
+ "URLError": "网络地址异常",
+ "TimeoutError": "请求超时异常",
+ "FlowError": "流程异常",
+ "ValueError": "数据格式异常",
+ "KeyError": "字段缺失异常",
+ "RuntimeError": "运行异常",
+ }
+ return labels.get(name, "程序异常" if name else "")
+
+
def emit_operation_feedback(
state: dict[str, Any],
stage: str,
@@ -317,39 +522,41 @@ def emit_operation_feedback(
attempt: int = 1,
dry_run: bool = False,
) -> None:
+ # Feedback lines are a mini protocol consumed by the calling agent. They
+ # must stay single-line JSON so weak models/tools can relay them reliably.
payload_obj = {
- "type": "OP_FEEDBACK",
- "step": feedback_step(stage),
- "stage": stage,
- "operation": endpoint_label(endpoint_key, method, url),
- "method": method,
- "url": url,
- "endpoint": endpoint_key or "",
- "attempt": attempt,
- "dryRun": dry_run,
- "requestKeys": request_keys(payload),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "操作反馈",
+ "步骤": feedback_step_label(stage),
+ "阶段": stage_label(stage),
+ "操作": endpoint_label(endpoint_key, method, url),
+ "请求方式": method_label(method),
+ "接口地址": url,
+ "接口名称": endpoint_label(endpoint_key, method, url),
+ "第几次尝试": attempt,
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("OP_FEEDBACK " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["operation"] + " " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
def emit_error_feedback(error: dict[str, Any]) -> None:
payload = {
- "type": "ERROR_FEEDBACK",
- "stage": error.get("stage"),
- "step": feedback_step(str(error.get("stage") or "")),
- "message": error.get("message"),
- "url": error.get("url"),
- "status": error.get("status"),
- "request": error.get("request"),
- "response": error.get("response"),
- "exceptionType": error.get("exceptionType"),
- "exception": error.get("exception"),
- "time": error.get("time"),
+ "日志类型": "错误反馈",
+ "阶段": stage_label(error.get("stage")),
+ "步骤": feedback_step_label(str(error.get("stage") or "")),
+ "错误信息": error.get("message"),
+ "接口地址": error.get("url"),
+ "状态码": error.get("status"),
+ "请求摘要": error.get("request"),
+ "响应摘要": error.get("response"),
+ "异常类型": exception_type_label(error.get("exceptionType")),
+ "异常信息": error.get("exception"),
+ "时间": error.get("time"),
}
- print("ERROR_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["error"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def emit_json_parse_review(
@@ -360,18 +567,89 @@ def emit_json_parse_review(
candidate_count: int,
) -> None:
payload = {
- "type": "JSON_PARSE_REVIEW",
- "stage": stage,
- "step": feedback_step(stage),
- "endpoint": endpoint_key,
- "message": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
- "candidateCount": candidate_count,
- "responseExcerpt": compact(text, 3000),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "JSON解析复核",
+ "阶段": stage_label(stage),
+ "步骤": feedback_step_label(stage),
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, endpoint_key),
+ "提示": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
+ "候选数量": candidate_count,
+ "响应片段": compact(text, 3000),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("JSON_PARSE_REVIEW " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["json_review"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
+
+
+SAVE_ENTITY_LABELS = {
+ "save_design": "受众目标",
+ "save_design_account": "受众目标账号",
+ "save_pathway": "关键传播节点",
+ "save_relation": "受众-传播节点关系",
+ "save_design_vuln": "受众脆弱点",
+ "save_pathway_vuln": "传播节点脆弱点",
+ "save_strategy": "策略",
+ "save_channel": "策略平台与人设",
+ "save_narrate": "策略信息主题",
+ "save_message": "策略消息配置",
+ "save_strategy_relation": "策略关联关系",
+}
+
+
+def saved_entity_name(endpoint_key: str, payload: dict[str, Any]) -> str:
+ """Return the most user-readable name for an inserted row."""
+ candidates = [
+ payload.get("targetAudience"),
+ payload.get("accountName"),
+ payload.get("vulnerabilities"),
+ payload.get("topic"),
+ payload.get("narrate"),
+ payload.get("platform"),
+ payload.get("strategyId"),
+ payload.get("designId"),
+ payload.get("pathwayId"),
+ ]
+ for value in candidates:
+ if value is not None and str(value).strip():
+ return str(value).strip()
+ return endpoint_key
+
+
+def emit_save_feedback(
+ state: dict[str, Any],
+ stage: str,
+ endpoint_key: str,
+ payload: dict[str, Any],
+ row_id: int,
+ *,
+ dry_run: bool,
+) -> None:
+ # Emit only after an id is known. Downstream steps use these ids and should
+ # fail loudly rather than inventing relations against missing rows.
+ label = SAVE_ENTITY_LABELS.get(endpoint_key, endpoint_key)
+ name = saved_entity_name(endpoint_key, payload)
+ feedback = {
+ "日志类型": "入库反馈",
+ "步骤": feedback_step(stage),
+ "阶段": stage,
+ "入库对象类型": label,
+ "入库对象名称": name,
+ "返回ID": row_id,
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, label),
+ "接口路径": ENDPOINTS.get(endpoint_key, ""),
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
+ }
+ print(LOG_PREFIXES["save"] + " " + json.dumps(feedback, ensure_ascii=False), flush=True)
+ record_event(
+ state,
+ stage,
+ f"入库{label}成功:{name}",
+ {"id": row_id, "endpoint": endpoint_key, "dryRun": dry_run},
+ )
def record_error(
@@ -385,16 +663,18 @@ def record_error(
response: Any | None = None,
exception: BaseException | None = None,
) -> None:
+ # Persist full-enough error context for post-run diagnosis, then immediately
+ # emit a short line the agent can show to the user.
item = {
"time": utc_now(),
"stage": stage,
"message": message,
"url": url,
"status": status,
- "request": compact(request, 1200) if request is not None else None,
- "response": compact(response, 2000) if response is not None else None,
+ "request": safe_compact(request, 1200) if request is not None else None,
+ "response": safe_compact(response, 2000) if response is not None else None,
"exceptionType": type(exception).__name__ if exception else None,
- "exception": str(exception) if exception else None,
+ "exception": safe_compact(str(exception), 1000) if exception else None,
}
state.setdefault("errors", []).append(item)
state["updatedAt"] = utc_now()
@@ -430,69 +710,29 @@ def url_for_path(ctx: FlowContext, path: str) -> str:
return ctx.api_base.rstrip("/") + "/" + path.lstrip("/")
-def parse_headers(header_values: list[str], auth_token: str | None, authorization: str | None) -> dict[str, str]:
+def parse_headers(header_values: list[str]) -> dict[str, str]:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
- if auth_token:
- headers["Authorization"] = auth_token if auth_token.lower().startswith("bearer ") else f"Bearer {auth_token}"
- if authorization:
- headers["Authorization"] = authorization
for raw in header_values:
if ":" not in raw:
raise FlowError(f"header 格式错误,应为 Key: Value,实际:{raw}")
key, value = raw.split(":", 1)
- headers[key.strip()] = value.strip()
+ normalized = key.strip()
+ if normalized.lower() == "authorization":
+ continue
+ headers[normalized] = value.strip()
return headers
-def has_authorization(headers: dict[str, str]) -> bool:
- return any(key.lower() == "authorization" and str(value).strip() for key, value in headers.items())
-
-
def ssl_context(ctx: FlowContext) -> ssl.SSLContext | None:
if ctx.verify_ssl:
return None
return ssl._create_unverified_context()
-def auth_header_value(token: str) -> str:
- token = token.strip()
- return token if token.lower().startswith("bearer ") else f"Bearer {token}"
-
-
-def extract_access_token(data: Any) -> str | None:
- if isinstance(data, dict):
- for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
- value = data.get(key)
- if isinstance(value, str) and value.strip():
- return value.strip()
- for value in data.values():
- nested = extract_access_token(value)
- if nested:
- return nested
- elif isinstance(data, list):
- for value in data:
- nested = extract_access_token(value)
- if nested:
- return nested
- return None
-
-
-def redact_url(url: str) -> str:
- return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
-
-
-def build_login_url(ctx: FlowContext, path: str) -> str:
- base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else url_for_path(ctx, path)
- if ctx.login_password:
- separator = "&" if "?" in base else "?"
- base = f"{base}{separator}password={urllib.parse.quote(ctx.login_password, safe='')}"
- return base
-
-
def login_candidate_paths(ctx: FlowContext) -> list[str]:
paths = [
- ctx.login_path or "/api/v1/auth/login/username",
- "/api/v1/auth/login/username",
+ ctx.login_path or "/api/auth/login",
+ "/api/auth/login",
]
seen: set[str] = set()
unique: list[str] = []
@@ -506,6 +746,7 @@ def login_candidate_paths(ctx: FlowContext) -> list[str]:
def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force: bool = False) -> bool:
+ """Fetch a fresh token through the local auth package before business calls."""
if not ctx.auto_login:
return False
if ctx.dry_run:
@@ -515,18 +756,17 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
record_error(state, stage, "自动登录失败:缺少 login_username")
return False
if not force and ctx.login_attempt_count > 0:
- return has_authorization(ctx.headers)
+ return any(key.lower() == "authorization" and str(value).strip() for key, value in ctx.headers.items())
if force and ctx.login_attempt_count >= 2:
return False
ctx.login_attempt_count += 1
- payload = {"username": ctx.login_username}
+ payload = {"username": ctx.login_username, "password": ctx.login_password}
last_status: int | None = None
last_response = ""
last_exc: BaseException | None = None
for attempt, path in enumerate(login_candidate_paths(ctx), start=1):
- url = build_login_url(ctx, path)
- display_url = redact_url(url)
+ display_url = xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, path))
emit_operation_feedback(
state,
stage,
@@ -537,63 +777,39 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
attempt=attempt,
)
try:
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- headers = {
- "Content-Type": "application/json",
- "Accept": "application/json",
- }
- req = urllib.request.Request(url, data=data, headers=headers, method="POST")
- with urllib.request.urlopen(req, timeout=ctx.timeout, context=ssl_context(ctx)) as resp:
- raw = resp.read().decode("utf-8", errors="replace")
- parsed = parse_json_lenient(raw)
- token = extract_access_token(parsed)
- if not token:
- last_status = getattr(resp, "status", None)
- last_response = raw
- record_event(
- state,
- stage,
- "登录接口已响应但未找到 access_token",
- {"url": display_url, "status": last_status, "response": compact(raw, 1000)},
- )
- continue
- ctx.headers["Authorization"] = auth_header_value(token)
- record_event(
- state,
- stage,
- "自动登录成功,已写入 Authorization Bearer token",
- {
- "url": display_url,
- "status": getattr(resp, "status", None),
- "username": ctx.login_username,
- },
- )
- return True
- except urllib.error.HTTPError as exc:
- last_status = exc.code
- last_response = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
- last_exc = exc
- record_event(
- state,
- stage,
- "登录接口返回错误,准备尝试下一个候选登录路径",
- {"url": display_url, "status": exc.code, "response": compact(last_response, 1000)},
+ result = xdfx_auth.fetch_access_token(
+ api_base=ctx.api_base,
+ username=ctx.login_username,
+ password=ctx.login_password,
+ login_path=path,
+ timeout=ctx.timeout,
+ verify_ssl=ctx.verify_ssl,
)
- except Exception as exc: # noqa: BLE001 - auto-login must report every failure.
- last_exc = exc
- last_response = traceback.format_exc()
+ ctx.headers["Authorization"] = xdfx_auth.auth_header_value(result.token)
+ state["_tokenSource"] = "xdfx_auth"
record_event(
state,
stage,
- "登录接口调用异常,准备尝试下一个候选登录路径",
- {"url": display_url, "exceptionType": type(exc).__name__, "exception": str(exc)},
+ "启动阶段已通过 xdfx_auth 包获取 token,并写入 Authorization",
+ {"url": result.url, "status": result.status, "username": ctx.login_username},
+ )
+ return True
+ except xdfx_auth.AuthError as exc:
+ last_status = exc.status
+ last_response = exc.response
+ last_exc = exc
+ record_event(
+ state,
+ stage,
+ "xdfx_auth 获取 token 失败,准备尝试下一个候选登录路径",
+ {"url": exc.url, "status": exc.status, "response": safe_compact(exc.response, 1000), "exception": str(exc)},
)
record_error(
state,
stage,
- "自动登录失败,未能获取 access_token",
- url=redact_url(build_login_url(ctx, ctx.login_path or "/api/v1/auth/login/username")),
+ "自动登录失败,未能通过 xdfx_auth 获取 access_token",
+ url=xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, ctx.login_path or "/api/auth/login")),
status=last_status,
request=payload,
response=last_response,
@@ -612,6 +828,7 @@ def http_request_json_url(
endpoint_key: str | None = None,
allow_auto_login: bool = True,
) -> tuple[Any | None, str]:
+ """Call an HTTP JSON endpoint with logging, retries, and 401 recovery."""
method = method.upper()
if ctx.dry_run:
emit_operation_feedback(state, stage, method, url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
@@ -640,6 +857,7 @@ def http_request_json_url(
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
if exc.code == 401 and allow_auto_login and ctx.auto_login:
+ # Refresh once on 401, then replay the original request exactly.
refreshed = try_auto_login(ctx, state, f"{stage}/auto-login", force=True)
if refreshed:
record_event(
@@ -709,6 +927,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
emit_operation_feedback(state, stage, "POST", url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
record_event(state, stage, f"DRY RUN POST {url}", {"payload": payload})
if endpoint_key.startswith("save_"):
+ # Synthetic ids let downstream dry-run code exercise relation wiring.
synthetic_id = abs(hash(stage + stable_json(payload))) % 100000000
return {"id": synthetic_id, "dryRun": True}, json.dumps({"id": synthetic_id, "dryRun": True})
return {"dryRun": True}, json.dumps({"dryRun": True})
@@ -716,6 +935,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
def parse_json_lenient(text: str) -> Any | None:
+ """Parse normal JSON plus adjacent/SSE-like JSON objects."""
stripped = text.strip()
if not stripped:
return None
@@ -730,6 +950,7 @@ def parse_json_lenient(text: str) -> Any | None:
def parse_concatenated_json_objects(text: str) -> list[Any]:
+ """Read streams such as {"answer": "..."}{"answer": "..."} or data: lines."""
decoder = json.JSONDecoder()
objects: list[Any] = []
i = 0
@@ -766,6 +987,7 @@ def parse_concatenated_json_objects(text: str) -> list[Any]:
def model_text_from_response(parsed: Any, raw: str) -> tuple[str, list[Any]]:
+ """Flatten streamed model chunks into one text buffer."""
objects: list[Any]
if isinstance(parsed, list):
objects = parsed
@@ -824,6 +1046,7 @@ def extract_json_from_text(text: str) -> Any | None:
def extract_json_candidates_from_text(text: str) -> list[Any]:
+ """Collect every plausible JSON object/array from messy model text."""
cleaned = strip_think(text)
candidates: list[Any] = []
seen: set[str] = set()
@@ -860,6 +1083,7 @@ def extract_json_candidates_from_text(text: str) -> list[Any]:
def select_json_candidate(candidates: list[Any], endpoint_key: str, stage: str) -> Any | None:
+ """Pick the candidate whose keys best match the current step."""
if not candidates:
return None
scored = [(json_candidate_score(candidate, endpoint_key, stage), idx, candidate) for idx, candidate in enumerate(candidates)]
@@ -1010,6 +1234,7 @@ def extract_first_balanced(text: str, open_ch: str, close_ch: str) -> str | None
def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[str, list[Any], Any | None]:
+ """Call a model endpoint and retry only known empty/timeout/no-response cases."""
max_attempts = max(ctx.model_retries, 0) + 1
last_text = ""
last_objects: list[Any] = []
@@ -1048,6 +1273,11 @@ def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str,
def call_model_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[dict[str, Any], str, list[Any]]:
+ """Return the best structured JSON for a model step.
+
+ If the first parse fails, the second attempt includes an explicit repair
+ instruction so weak models can re-emit only the required JSON.
+ """
last_text = ""
last_objects: list[Any] = []
last_candidate_count = 0
@@ -1202,23 +1432,85 @@ def profile_info(account: dict[str, Any]) -> str:
def parse_index_list(value: Any) -> list[str]:
- if value is None:
- return []
- if isinstance(value, list):
- return [str(v).strip() for v in value if str(v).strip()]
- text = str(value)
- return [part.strip().strip("<>").strip() for part in re.split(r"[,,;;、\s]+", text) if part.strip()]
+ refs: list[str] = []
+
+ def add(raw: Any) -> None:
+ if raw is None:
+ return
+ if isinstance(raw, dict):
+ keys = (
+ "编号id",
+ "编号ID",
+ "用户编号id",
+ "用户编号ID",
+ "用户编号",
+ "账号编号",
+ "引用序号",
+ "索引",
+ "index",
+ "id",
+ "ids",
+ "accountIndex",
+ "accountIndexes",
+ "userIndex",
+ "userIndexes",
+ )
+ for key in keys:
+ if key in raw:
+ add(raw.get(key))
+ return
+ if isinstance(raw, (list, tuple, set)):
+ for item in raw:
+ add(item)
+ return
+ text = str(raw).strip()
+ if not text:
+ return
+ angle_refs = re.findall(r"<<\s*([^<>]+?)\s*>>", text)
+ if angle_refs:
+ for token in angle_refs:
+ add(token)
+ return
+ for part in re.split(r"[,,;;、\s]+", text):
+ token = part.strip().strip("<>").strip()
+ if token:
+ refs.append(token)
+
+ add(value)
+ deduped: list[str] = []
+ seen: set[str] = set()
+ for ref in refs:
+ if ref in seen:
+ continue
+ seen.add(ref)
+ deduped.append(ref)
+ return deduped
+
+
+def account_ref_numbers(account: dict[str, Any]) -> set[int]:
+ numbers: set[int] = set()
+ meta = account.get("metadata") if isinstance(account.get("metadata"), dict) else {}
+ for key in ("编号id", "编号ID", "用户编号id", "用户编号ID", "用户编号", "账号编号", "引用序号", "索引", "index", "accountIndex"):
+ for container in (account, meta):
+ number = as_int(container.get(key)) if isinstance(container, dict) else None
+ if number is not None:
+ numbers.add(number)
+ return numbers
def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, Any] | None:
+ """Resolve model references like <<3>>, index numbers, names, or account ids."""
if not accounts:
return None
text = str(ref).strip().strip("<>").strip()
number = as_int(text)
if number is not None:
for account in accounts:
- if any(as_int(account.get(key)) == number for key in ("引用序号", "索引", "index")):
+ if number in account_ref_numbers(account):
return account
+ # Step1 group extraction returns 编号/id values that refer to the
+ # previous zhanghao array index. Prefer exact index fields above, then
+ # fall back to zero-based and one-based array positions.
if 0 <= number < len(accounts):
return accounts[number]
if 1 <= number <= len(accounts):
@@ -1232,11 +1524,25 @@ def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, A
def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Normalize audience JSON whether the model used numbered fields or items[]."""
items: list[dict[str, Any]] = []
for i in range(1, 21):
group = first_present(data.get(f"群体{i}"), data.get(f"受众{i}"), data.get(f"targetAudience{i}"))
reason = first_present(data.get(f"理由{i}"), data.get(f"推荐理由{i}"), data.get(f"recReason{i}"))
- users = first_present(data.get(f"用户编号列表{i}"), data.get(f"用户列表{i}"), data.get(f"账号编号列表{i}"))
+ users = first_present(
+ data.get(f"用户编号列表{i}"),
+ data.get(f"编号id列表{i}"),
+ data.get(f"编号ID列表{i}"),
+ data.get(f"用户编号id列表{i}"),
+ data.get(f"用户编号ID列表{i}"),
+ data.get(f"用户列表{i}"),
+ data.get(f"账号编号列表{i}"),
+ data.get(f"索引列表{i}"),
+ data.get(f"编号{i}"),
+ data.get(f"id{i}"),
+ data.get(f"ids{i}"),
+ data.get(f"索引{i}"),
+ )
if group:
items.append(
{
@@ -1255,7 +1561,27 @@ def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
{
"targetAudience": str(group).strip(),
"recReason": str(first_present(item.get("理由"), item.get("recReason"), item.get("reason")) or "").strip(),
- "userIndexes": parse_index_list(first_present(item.get("用户编号列表"), item.get("users"), item.get("accounts"))),
+ "userIndexes": parse_index_list(
+ first_present(
+ item.get("用户编号列表"),
+ item.get("编号id列表"),
+ item.get("编号ID列表"),
+ item.get("用户编号id列表"),
+ item.get("用户编号ID列表"),
+ item.get("编号id"),
+ item.get("编号ID"),
+ item.get("用户编号id"),
+ item.get("用户编号ID"),
+ item.get("编号"),
+ item.get("索引"),
+ item.get("id"),
+ item.get("ids"),
+ item.get("users"),
+ item.get("accounts"),
+ item.get("userIndexes"),
+ item.get("accountIndexes"),
+ )
+ ),
"rank": i,
}
)
@@ -1272,6 +1598,7 @@ def audience_score(item: dict[str, Any]) -> tuple[int, int]:
def select_top_audiences(items: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
+ """Deduplicate audience names and keep the strongest candidates."""
unique: dict[str, dict[str, Any]] = {}
for item in items:
key = item.get("targetAudience", "")
@@ -1301,6 +1628,7 @@ def pathway_score(item: dict[str, Any]) -> tuple[int, int, int]:
def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Merge the same account found from different audiences before saving top 3."""
merged: dict[str, dict[str, Any]] = {}
for item in candidates:
account = item.get("account") or {}
@@ -1325,6 +1653,7 @@ def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str,
def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
+ """Normalize weak-point model output into a capped list."""
vulns: list[dict[str, str]] = []
for i in range(1, 21):
vuln = first_present(data.get(f"脆弱点{i}"), data.get(f"vulnerability{i}"), data.get(f"vuln{i}"))
@@ -1339,6 +1668,7 @@ def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
def extract_strategies(data: dict[str, Any], limit: int) -> list[dict[str, Any]]:
+ """Normalize strategy output and preserve the modal text for later mapping."""
strategies: list[dict[str, Any]] = []
for i in range(1, 21):
title = first_present(data.get(f"信息标题{i}"), data.get(f"主题{i}"), data.get(f"topic{i}"))
@@ -1395,11 +1725,29 @@ def response_id(response: Any) -> int | None:
return None
+def response_rows(response: Any) -> list[Any]:
+ """Extract list rows from common CRUD response shapes."""
+ if isinstance(response, list):
+ return response
+ if not isinstance(response, dict):
+ return []
+ for key in ("data", "rows", "items", "records", "list"):
+ value = response.get(key)
+ if isinstance(value, list):
+ return value
+ rows = response_rows(value)
+ if rows:
+ return rows
+ return []
+
+
def save_with_id(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[int | None, Any | None]:
response, _ = http_post_json(ctx, state, endpoint_key, payload, stage)
rid = response_id(response)
if rid is None:
- record_error(state, stage, "保存接口未返回 id,视为失败", request=payload, response=response)
+ record_error(state, stage, "保存接口未返回 ID,视为失败", request=payload, response=response)
+ else:
+ emit_save_feedback(state, stage, endpoint_key, payload, rid, dry_run=ctx.dry_run)
return rid, response
@@ -1429,6 +1777,7 @@ def parse_step_flags(raw: Any) -> dict[str, Any]:
def mark_step_status(ctx: FlowContext, state: dict[str, Any], links: list[str], stage: str) -> None:
+ """Update the frontend step bar without overwriting unrelated step flags."""
if not ctx.sync_step_status:
record_event(state, stage, "跳过 task-action.step 状态同步")
return
@@ -1477,12 +1826,126 @@ def strategy_platform(value: Any) -> Any:
return platform_to_number(value) or 1
-def strategy_model_type(value: Any) -> str:
- # Frontend defaults to text model type "1" when no modal type is selected.
- raw = first_present(value, "1")
- if isinstance(raw, list):
- return ",".join(str(v) for v in raw if str(v).strip()) or "1"
- return str(raw or "1")
+def choice_tokens(value: Any) -> list[Any]:
+ """Split frontend choice values from arrays, JSON strings, and mixed delimiters."""
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return choice_tokens(
+ first_present(value.get("value"), value.get("dictValue"), value.get("label"), value.get("dictLabel"))
+ )
+ if isinstance(value, (list, tuple, set)):
+ tokens: list[Any] = []
+ for item in value:
+ tokens.extend(choice_tokens(item))
+ return tokens
+ text = str(value).strip()
+ if not text:
+ return []
+ if text[0] in "[{":
+ try:
+ parsed = json.loads(text)
+ if parsed != value:
+ return choice_tokens(parsed)
+ except Exception:
+ pass
+ return [part.strip() for part in re.split(r"[,,、;/|]+", text) if part.strip()]
+
+
+def dict_choices(
+ ctx: FlowContext,
+ state: dict[str, Any],
+ dict_type: str,
+ stage: str,
+) -> list[dict[str, Any]]:
+ """Fetch and cache frontend dictionary choices such as modelType."""
+ cache = state.setdefault("_dictChoiceCache", {})
+ if dict_type in cache:
+ cached = cache[dict_type]
+ return cached if isinstance(cached, list) else []
+ if ctx.dry_run:
+ cache[dict_type] = []
+ return []
+
+ query = urllib.parse.urlencode({"filter": f"dictType||$eq||{dict_type}", "limit": 999, "page": 1})
+ url = f"{url_for(ctx, 'dict_data')}?{query}"
+ response, _ = http_request_json_url(ctx, state, "GET", url, None, stage, endpoint_key="dict_data")
+ choices: list[dict[str, Any]] = []
+ for row in response_rows(response):
+ if not isinstance(row, dict):
+ continue
+ label = str(first_present(row.get("dictLabel"), row.get("label"), "") or "").strip()
+ raw_value = first_present(row.get("dictValue"), row.get("value"))
+ if not label or raw_value is None:
+ continue
+ number = as_int(raw_value)
+ choices.append({"label": label, "value": number if number is not None else str(raw_value).strip()})
+ for alias in choice_tokens(row.get("alias")):
+ alias_label = str(alias).strip()
+ if alias_label:
+ choices.append({"label": alias_label, "value": number if number is not None else str(raw_value).strip()})
+ cache[dict_type] = choices
+ return choices
+
+
+def choice_value_by_label(choices: list[dict[str, Any]] | None, label: str) -> Any | None:
+ needle = re.sub(r"\s+", "", str(label or "").lower())
+ if not needle:
+ return None
+ for item in choices or []:
+ item_label = re.sub(r"\s+", "", str(item.get("label") or "").lower())
+ if item_label and (needle == item_label or needle in item_label or item_label in needle):
+ return item.get("value")
+ return None
+
+
+def model_type_token_value(token: Any, choices: list[dict[str, Any]] | None = None) -> Any:
+ """Convert modal labels like 图片/视频 into frontend dictionary values."""
+ number = as_int(token)
+ if number is not None:
+ return number
+
+ text = str(token or "").strip()
+ lower = text.lower()
+ exact = choice_value_by_label(choices, text)
+ if exact is not None:
+ return exact
+
+ label = ""
+ fallback_value = 2
+ if "视" in text or "video" in lower:
+ label, fallback_value = "视频", 3
+ elif "图" in text or "image" in lower or "picture" in lower or "photo" in lower:
+ label, fallback_value = "图片", 2
+ elif "音" in text or "audio" in lower or "sound" in lower:
+ label, fallback_value = "音频", 4
+ elif "发文" in text or "文本" in text or "文字" in text or "text" in lower:
+ label, fallback_value = "文本", 1
+
+ if label:
+ matched = choice_value_by_label(choices, label)
+ if matched is not None:
+ return matched
+ return fallback_value
+
+ # Mirrors the frontend helper's unknown-token default.
+ return 2
+
+
+def strategy_model_type(value: Any, choices: list[dict[str, Any]] | None = None) -> str:
+ """Return the comma-separated value string expected by task-action-message."""
+ tokens = choice_tokens(value)
+ if not tokens:
+ # Frontend save paths fall back to "1" when no modal type is selected.
+ return "1"
+ values: list[str] = []
+ seen: set[str] = set()
+ for token in tokens:
+ normalized = str(model_type_token_value(token, choices)).strip()
+ if normalized and normalized not in seen:
+ values.append(normalized)
+ seen.add(normalized)
+ return ",".join(values) or "1"
def strategy_language(value: Any) -> int:
@@ -1501,11 +1964,28 @@ def save_strategy_details(
*,
audience: bool,
) -> None:
- platform = strategy_platform(first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})))
+ """Save all frontend-readable strategy child rows.
+
+ The strategy table and arrangement page do not read only the main strategy
+ row; they also expect channel, narrate, message, and relation rows.
+ """
+ platform = strategy_platform(
+ first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
+ )
+ account_type = str(
+ first_present(
+ strategy.get("accountType"),
+ item.get("accountType"),
+ item.get("type"),
+ "目标受众账号" if audience else "关键传播节点账号",
+ )
+ or ""
+ ).strip()
persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
title = str(strategy.get("title") or "").strip()
+ model_type_choices = dict_choices(ctx, state, "modelType", stage + "/dict-modelType")
channel_id, _ = save_with_id(
ctx,
@@ -1516,6 +1996,7 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"platform": platform,
+ "accountType": account_type,
"personaJson": json.dumps([{"persona": persona, "size": persona_size}], ensure_ascii=False),
},
stage + "/save-channel",
@@ -1531,7 +2012,10 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"channelId": channel_id,
- "modelType": strategy_model_type(first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType"))),
+ "modelType": strategy_model_type(
+ first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType")),
+ model_type_choices,
+ ),
"language": strategy_language(first_present(strategy.get("language"), strategy.get("语种"))),
"respondingCount": responding_count,
"messageCount": responding_count,
@@ -1564,7 +2048,46 @@ def object_for_audience(audience: dict[str, Any]) -> dict[str, Any]:
}
+def selected_accounts_for_item(item: dict[str, Any], *, audience: bool) -> list[Any]:
+ if audience:
+ accounts = item.get("accounts") or []
+ return accounts or [object_for_audience(item)]
+ return [item.get("account") or item]
+
+
+def downstream_limit(ctx: FlowContext, *, audience: bool) -> int:
+ """Limit expensive downstream analysis while keeping 3 upstream rows stored."""
+ raw = ctx.downstream_audiences if audience else ctx.downstream_pathways
+ return max(1, raw)
+
+
+def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, audience: bool) -> list[dict[str, Any]]:
+ """Select the 1-2 best stored rows for vulnerability/strategy generation."""
+ source_key = "topAudiences" if audience else "topPathways"
+ id_key = "designId" if audience else "pathwayAccountId"
+ selected_key = "selectedDownstreamAudiences" if audience else "selectedDownstreamPathways"
+ items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)]
+ limit = downstream_limit(ctx, audience=audience)
+ selected = items[:limit]
+ state[selected_key] = [
+ {
+ "id": item.get(id_key),
+ "name": item.get("targetAudience") if audience else item.get("accountName"),
+ }
+ for item in selected
+ ]
+ if items:
+ record_event(
+ state,
+ stage,
+ f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}",
+ {"limit": limit, "selected": state[selected_key]},
+ )
+ return selected
+
+
def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze audiences, save top 3, and attach any referenced accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step1-audience"
if state.get("topAudiences") and not ctx.dry_run:
@@ -1582,12 +2105,15 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
state["audienceRawText"] = text
+ # The first model call may return raw text plus embedded account objects.
accounts = accounts_from_response(parsed, objects, text)
state["audienceAccounts"] = accounts
+ # The second model call converts the long audience analysis into numbered
+ # groups that can be saved as task-action-design rows.
group_payload = {
"mubiao": state["mubiao"],
- "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
"zhanghao": accounts,
"history": [],
"stream": True,
@@ -1602,6 +2128,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
top = select_top_audiences(candidates, ctx.max_audiences)
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the audience first; account rows and downstream analyses depend
+ # on the returned designId.
design_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
@@ -1614,6 +2142,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
continue
item = {**item, "designId": design_id, "saveResponse": resp, "accounts": [], "savedAccountIds": []}
for ref in item.get("userIndexes") or []:
+ # Model references are intentionally fuzzy, so resolve them against
+ # both explicit indexes and account names/ids.
account = resolve_account_ref(ref, accounts)
if account is None:
record_error(state, stage + "/save-design-account", f"用户编号 {ref} 未匹配到候选账号")
@@ -1645,6 +2175,7 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze key pathway accounts for saved audiences and store top 3."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step2-pathway"
audiences = [a for a in state.get("topAudiences") or [] if a.get("designId")]
@@ -1656,6 +2187,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
return
all_candidates: list[dict[str, Any]] = []
for audience in audiences:
+ # Analyze pathway candidates in the context of each saved audience.
payload = {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。",
"otherinfo": {
@@ -1673,6 +2205,8 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
accounts = accounts_from_response(parsed, objects, text)
+ # Ask the model to point back to candidate accounts, then resolve those
+ # references to concrete account objects.
spread_payload = {
"mubiao": state["mubiao"],
"user_message": "分析其中关键信息。请从候选账号中抽取核心传播节点,字段必须是:账号1、理由1、账号2、理由2 ...,最多输出5个。账号字段优先返回候选数组的引用编号或索引,只返回JSON。",
@@ -1707,6 +2241,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways]
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the account row before writing the design-pathway relation.
account = item["account"]
aid = account_id(account)
name = account_name(account)
@@ -1757,18 +2292,20 @@ def run_step4(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate and save weak points for selected audiences or pathway accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step3-audience-vulns" if audience else "step4-pathway-vulns"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
output_key = "audienceVulns" if audience else "pathwayVulns"
state.setdefault(output_key, {})
for item in items:
+ # owner_id is designId for audiences and pathwayAccountId for accounts.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
if not owner_id:
continue
if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run:
continue
- zhanghao = item.get("accounts") or [object_for_audience(item)] if audience else [item.get("account") or item]
+ zhanghao = selected_accounts_for_item(item, audience=audience)
prompt = (
"你是中国对台湾方向的认知战专家,你要传播行动要求的叙事主题。请基于该受众目标的画像、关注议题、社会分工定位、利益诉求与上一轮账号佐证,分析该受众目标有哪些脆弱点可以成为后续策略设计的关键要素。请输出理由、脆弱点1、理由1、脆弱点2、理由2、脆弱点3、理由3,只返回JSON。"
if audience
@@ -1789,6 +2326,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) ->
vulns = extract_vulns(data, ctx.max_vulns)
saved: list[dict[str, Any]] = []
for vuln in vulns:
+ # The two weak-point tables have nearly identical payloads but
+ # different foreign-key names.
if audience:
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
@@ -1835,14 +2374,16 @@ def run_step6(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate strategies from saved weak points and persist frontend child rows."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step5-audience-strategy" if audience else "step6-pathway-strategy"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {}
output_key = "audienceStrategies" if audience else "pathwayStrategies"
state.setdefault(output_key, [])
existing_topics = {s.get("topic") for s in state[output_key] if isinstance(s, dict)}
for item in items:
+ # Strategies are only meaningful after the matching weak points exist.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
vulns = vulns_by_owner.get(str(owner_id)) or []
vuln_texts = [v.get("vulnerabilities") for v in vulns if v.get("vulnerabilities")]
@@ -1850,7 +2391,9 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
record_error(state, stage, "没有可用脆弱点,跳过策略生成", request=item)
continue
target_names = [item.get("targetAudience")] if audience else (item.get("relatedAudiences") or [item.get("accountName")])
- pingtai = state.get("platform") or (platform_label(item.get("account") or {}) if not audience else "")
+ zhanghao = selected_accounts_for_item(item, audience=audience)
+ first_account = zhanghao[0] if zhanghao and isinstance(zhanghao[0], dict) else {}
+ pingtai = state.get("platform") or platform_label(first_account)
payload = {
"user_message": DEFAULT_STRATEGY_PROMPT_AUDIENCE if audience else DEFAULT_STRATEGY_PROMPT_PATHWAY,
"cuiruodian": vuln_texts,
@@ -1858,6 +2401,7 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
"shouzhong": [name for name in target_names if name],
"mudi": extract_goal(state["mubiao"]),
"heiliao": [],
+ "zhanghao": zhanghao,
"history": [],
"stream": True,
"model_name": "OpenAI3",
@@ -1876,16 +2420,20 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
continue
if topic in existing_topics:
continue
+ # Save the main row first; channel/narrate/message/relation rows all
+ # require the returned strategy id.
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
"targetAudience": target,
"accountType": account_type,
"topic": topic,
- "strategyTitle": "",
+ "strategyTitle": str(strategy.get("title") or "").strip(),
}
strategy_id, resp = save_with_id(ctx, state, "save_strategy", save_payload, stage + "/save")
if strategy_id is not None:
+ # Frontend strategy pages read these child tables, so a strategy
+ # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience)
existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp})
@@ -1914,13 +2462,14 @@ STEP_LABELS = {
def state_counts(state: dict[str, Any]) -> dict[str, int]:
+ """Summarize stored objects for step feedback."""
return {
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceVulns": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
- "pathwayVulns": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "受众脆弱点数量": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
+ "传播节点脆弱点数量": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
}
@@ -1932,23 +2481,26 @@ def emit_step_feedback(
status: str | None = None,
note: str = "",
) -> None:
+ # One feedback line per step keeps the single-skill and split-skill versions
+ # equally transparent to the user.
total_errors = len(state.get("errors") or [])
new_errors = max(0, total_errors - before_error_count)
final_status = status or ("partial" if new_errors else "ok")
payload = {
- "type": "STEP_FEEDBACK",
- "step": step,
- "label": STEP_LABELS.get(step, step),
- "status": final_status,
- "note": note,
- "newErrors": new_errors,
- "totalErrors": total_errors,
- "counts": state_counts(state),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "updatedAt": state.get("updatedAt"),
+ "日志类型": "步骤反馈",
+ "步骤": STEP_NUMBER_LABELS.get(step, step),
+ "步骤名称": STEP_LABELS.get(step, step),
+ "状态": STATUS_LABELS.get(final_status, final_status),
+ "执行状态": status_label(final_status),
+ "备注": note,
+ "本步骤新增错误数": new_errors,
+ "累计错误数": total_errors,
+ "累计入库数量": state_counts(state),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "更新时间": state.get("updatedAt"),
}
- print("STEP_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["step"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def finish_step(
@@ -1960,6 +2512,8 @@ def finish_step(
status: str | None = None,
note: str = "",
) -> None:
+ # Always persist before emitting step feedback so the user can inspect files
+ # immediately after a reported step completes.
state["updatedAt"] = utc_now()
save_json_file(ctx.state_path, state)
if ctx.summary_path:
@@ -1980,6 +2534,11 @@ def skip_step_with_feedback(ctx: FlowContext, state: dict[str, Any], step: str,
def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Legacy full runner.
+
+ The wrapper script is preferred for real-time logs, but this remains useful
+ for dry-runs and unattended local troubleshooting.
+ """
run_step_with_feedback(ctx, state, "step1")
run_step_with_feedback(ctx, state, "step2")
run_step_with_feedback(ctx, state, "step3")
@@ -1995,6 +2554,7 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
def write_summary(path: Path, state: dict[str, Any]) -> None:
+ """Write a human-readable checkpoint beside the machine state JSON."""
audience_vuln_count = sum(len(v) for v in (state.get("audienceVulns") or {}).values())
pathway_vuln_count = sum(len(v) for v in (state.get("pathwayVulns") or {}).values())
lines = [
@@ -2005,6 +2565,8 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
f"- actionId:{state.get('actionId')}",
f"- 受众入库:{len(state.get('topAudiences') or [])}",
f"- 传播节点入库:{len(state.get('topPathways') or [])}",
+ f"- 下游受众选择:{len(state.get('selectedDownstreamAudiences') or [])}",
+ f"- 下游传播节点选择:{len(state.get('selectedDownstreamPathways') or [])}",
f"- 受众脆弱点入库:{audience_vuln_count}",
f"- 传播节点脆弱点入库:{pathway_vuln_count}",
f"- 受众策略入库:{len(state.get('audienceStrategies') or [])}",
@@ -2021,9 +2583,9 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
if state.get("errors"):
lines += ["", "## 失败明细"]
for err in state["errors"]:
- lines.append(f"- [{err.get('stage')}] {err.get('message')} status={err.get('status')} url={err.get('url')}")
+ lines.append(f"- [{stage_label(err.get('stage'))}] {err.get('message')} 状态码={err.get('status')} 接口={err.get('url')}")
if err.get("response"):
- lines.append(f" - response: {err.get('response')}")
+ lines.append(f" - 响应摘要:{err.get('response')}")
text = "\n".join(lines) + "\n"
try:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -2034,34 +2596,34 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
def build_arg_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(description="Run xdfx analysis/storage flow.")
+ parser = argparse.ArgumentParser(description="运行 xdfx 分析与入库流程。")
parser.add_argument("--mode", choices=["full", *STEP_FUNCS.keys()], default="full")
- parser.add_argument("--input", help="JSON config file. Keys: api_base, taskId, actionId, mubiao, direction, platform, headers.")
- parser.add_argument("--state", default="xdfx-flow-state.json", help="State JSON path for resume and step handoff.")
- parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown summary output path.")
+ parser.add_argument("--input", help="JSON 配置文件。常用字段:api_base、taskId、actionId、mubiao、direction、platform、headers。")
+ parser.add_argument("--state", default="xdfx-flow-state.json", help="用于断点续跑和步骤交接的状态 JSON 文件路径。")
+ parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown 执行摘要输出路径。")
parser.add_argument("--api-base")
parser.add_argument("--task-id")
parser.add_argument("--action-id")
parser.add_argument("--mubiao")
parser.add_argument("--direction", default=None)
parser.add_argument("--platform", default=None)
- parser.add_argument("--auth-token", default=os.getenv("XDFX_AUTH_TOKEN"))
- parser.add_argument("--authorization", default=os.getenv("AUTHORIZATION"))
- parser.add_argument("--header", action="append", default=[], help="Extra HTTP header, format 'Key: Value'.")
- parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="Username for automatic login when Authorization is missing or stale.")
- parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="Password query parameter for automatic username login.")
- parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="Login endpoint path or absolute URL.")
- parser.add_argument("--no-auto-login", action="store_true", help="Disable automatic username login and 401 token refresh.")
- parser.add_argument("--verify-ssl", action="store_true", help="Verify HTTPS certificates. Default is disabled for internal/UAT certificates.")
+ parser.add_argument("--header", action="append", default=[], help="附加非鉴权 HTTP 请求头,格式为 'Key: Value';Authorization 会被忽略。")
+ parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="xdfx_auth 启动阶段自动登录使用的用户名。")
+ parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="登录接口 JSON 请求体中的 password。")
+ parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/auth/login"), help="登录接口路径或完整 URL。")
+ parser.add_argument("--no-auto-login", action="store_true", help="关闭 xdfx_auth 自动登录和 401 刷新。")
+ parser.add_argument("--verify-ssl", action="store_true", help="校验 HTTPS 证书。默认不校验,以兼容内网/UAT 自签证书。")
parser.add_argument("--timeout", type=float, default=90)
parser.add_argument("--retries", type=int, default=2)
parser.add_argument("--dry-run", action="store_true")
- parser.add_argument("--no-step-status", action="store_true", help="Do not update task/task-action.step after completed steps.")
- parser.add_argument("--model-retries", type=int, default=1, help="Retry model/stream endpoints when the first response is empty, timeout-like, or says the model did not respond.")
+ parser.add_argument("--no-step-status", action="store_true", help="步骤完成后不更新 task/task-action.step。")
+ parser.add_argument("--model-retries", type=int, default=1, help="模型/流式接口首次空响应、超时或提示模型无响应时的重试次数。")
parser.add_argument("--max-audiences", type=int, default=3)
parser.add_argument("--max-pathways", type=int, default=3)
parser.add_argument("--max-vulns", type=int, default=3)
parser.add_argument("--max-strategies", type=int, default=3)
+ parser.add_argument("--downstream-audiences", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库受众数量,默认 2。")
+ parser.add_argument("--downstream-pathways", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库传播节点数量,默认 2。")
return parser
@@ -2070,6 +2632,8 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
state_path = Path(args.state)
state = default_state()
+ # Load previous state first, then overlay input JSON and finally CLI flags.
+ # That ordering makes resume safe while still allowing explicit overrides.
if state_path.exists():
loaded = load_json_file(state_path)
if isinstance(loaded, dict):
@@ -2081,9 +2645,7 @@ def main(argv: list[str] | None = None) -> int:
merge_config_into_state(state, config)
input_headers = config.get("headers")
if isinstance(input_headers, dict):
- args.header.extend(f"{k}: {v}" for k, v in input_headers.items())
- args.auth_token = args.auth_token or config.get("auth_token")
- args.authorization = args.authorization or config.get("authorization")
+ args.header.extend(f"{k}: {v}" for k, v in input_headers.items() if str(k).lower() != "authorization")
if config.get("login_username") is not None:
args.login_username = str(config.get("login_username") or "")
if config.get("login_password") is not None:
@@ -2094,6 +2656,10 @@ def main(argv: list[str] | None = None) -> int:
args.no_auto_login = True
if config.get("verify_ssl") is not None:
args.verify_ssl = bool(config.get("verify_ssl"))
+ if config.get("downstream_audiences") is not None:
+ args.downstream_audiences = as_int(config.get("downstream_audiences")) or args.downstream_audiences
+ if config.get("downstream_pathways") is not None:
+ args.downstream_pathways = as_int(config.get("downstream_pathways")) or args.downstream_pathways
cli_config = {
"api_base": args.api_base,
@@ -2105,7 +2671,8 @@ def main(argv: list[str] | None = None) -> int:
}
merge_config_into_state(state, {k: v for k, v in cli_config.items() if v is not None})
api_base = state.get("api_base") or "https://ch1.b.uat.4.cn"
- headers = parse_headers(args.header, args.auth_token, args.authorization)
+ headers = parse_headers(args.header)
+ # ctx owns runtime knobs; state owns business data and resumable ids.
ctx = FlowContext(
api_base=api_base,
headers=headers,
@@ -2118,6 +2685,8 @@ def main(argv: list[str] | None = None) -> int:
max_pathways=max(args.max_pathways, 1),
max_vulns=max(args.max_vulns, 1),
max_strategies=max(args.max_strategies, 1),
+ downstream_audiences=max(args.downstream_audiences, 1),
+ downstream_pathways=max(args.downstream_pathways, 1),
sync_step_status=not args.no_step_status,
model_retries=max(args.model_retries, 0),
verify_ssl=bool(args.verify_ssl),
@@ -2131,11 +2700,17 @@ def main(argv: list[str] | None = None) -> int:
state["_summaryPath"] = str(ctx.summary_path) if ctx.summary_path else ""
state["_autoLogin"] = ctx.auto_login
state["_verifySsl"] = ctx.verify_ssl
+ state["_downstreamLimits"] = {
+ "audiences": ctx.downstream_audiences,
+ "pathways": ctx.downstream_pathways,
+ }
record_event(state, "runner", f"start mode={args.mode} dry_run={ctx.dry_run}")
try:
require_context(state, "taskId", "actionId", "mubiao")
- if ctx.auto_login and not has_authorization(ctx.headers):
- try_auto_login(ctx, state, "runner/auto-login")
+ if ctx.auto_login and not ctx.dry_run:
+ # Always acquire a token through the imported auth package before the first business call.
+ if not try_auto_login(ctx, state, "runner/initial-login", force=True):
+ raise FlowError("启动阶段获取 token 失败,已停止执行,避免无 token 调用业务接口")
if args.mode == "full":
run_full(ctx, state)
else:
@@ -2145,13 +2720,14 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(f"ERROR: {exc}", file=sys.stderr)
+ print(f"错误:{exc}", file=sys.stderr)
return 2
except Exception as exc: # noqa: BLE001
record_error(state, "runner", "未捕获异常", response=traceback.format_exc(), exception=exc)
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
+ print("未捕获异常堆栈:", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
return 1
@@ -2159,20 +2735,23 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(json.dumps(
- {
- "ok": True,
- "mode": args.mode,
- "state": str(state_path),
- "summary": str(ctx.summary_path) if ctx.summary_path else "",
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
- "errors": len(state.get("errors") or []),
- },
- ensure_ascii=False,
- ))
+ # Final JSON is intentionally compact; detailed per-step status has already
+ # been emitted as Chinese step feedback.
+ final_payload = {
+ "日志类型": "执行完成",
+ "是否成功": "是",
+ "执行模式": mode_label(args.mode),
+ "状态文件": str(state_path),
+ "摘要文件": str(ctx.summary_path) if ctx.summary_path else "",
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "已选择受众数": len(state.get("selectedDownstreamAudiences") or []),
+ "已选择传播节点数": len(state.get("selectedDownstreamPathways") or []),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
+ "错误数": len(state.get("errors") or []),
+ }
+ print(LOG_PREFIXES["done"] + " " + json.dumps(final_payload, ensure_ascii=False))
return 0
diff --git a/skills-data/xdfx-step5-audience-strategy/SKILL.md b/skills-data/xdfx-step5-audience-strategy/SKILL.md
index b0f8de85..9bba2a5a 100644
--- a/skills-data/xdfx-step5-audience-strategy/SKILL.md
+++ b/skills-data/xdfx-step5-audience-strategy/SKILL.md
@@ -13,27 +13,39 @@ description: xdfx 拆分流程 step5,基于受众目标与受众脆弱点生
python scripts/xdfx_flow.py --mode step5 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
-鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
+鉴权和证书由脚本兜底处理:脚本启动时先 `import scripts/xdfx_auth.py`,自动用 `admin / ch@user` 获取 `access_token`,并在第一次业务接口调用前写入内部 Authorization;不要通过命令行传 token。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
-- 按受众读取已入库脆弱点。
+- 从已入库受众中默认选择前 2 个继续生成策略;可用 `--downstream-audiences 1` 调成只处理 1 个。
+- 对每个被选中的受众读取已入库脆弱点并默认生成最多 3 个策略。
- 调用策略生成模型接口。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 自动解析 `发布人设N / 账号类型N / 信息标题N` 等字段。
- 调用策略保存接口。
- 同步写入 `task-action-channel`、`base-narrate`、`task-action-message`、`task-action-design-strategy-relation`。
+- 保存 `task-action-message.modelType` 前会读取前端 `modelType` 字典并把“图片,视频/文本,图片”等模态文字转成 value 逗号串,避免策略表格回显为空。
- 同步前端步骤状态 `step-analysis-strategy` 与 `step-analysis-strategy-gantt`。
- 保存成功后写入 `audienceStrategies`。
-如果某个受众没有可用脆弱点,脚本会跳过并在 `errors` 中记录。
+如果某个受众没有可用脆弱点,脚本会记录错误;智能体必须先回到 Step3 或重跑 Step5 修复当前策略环节,不能直接跳过。
+
+## 失败重试规则
+
+- 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前 Step5 命令。
+- 如果失败原因是缺少受众脆弱点,先重跑 Step3 生成脆弱点,再重跑 Step5;重试前必须告诉用户原因和重试动作。
+- Step5 未重试成功前,不允许继续 Step6 或做最终汇总;不要因为 Step5 失败就跳过受众策略分析。
+- Step5 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
## 步骤反馈
-命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step5 的完成状态、当前受众策略数量和新增错误数,再决定是否继续下一步。
+命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step5 的完成状态、当前受众策略数量和新增错误数,再决定是否继续下一步。
-执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
+执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。
-如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的策略、策略子表和返回 ID。
+
+如果出现 `错误反馈 {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+
+如果出现 `JSON解析复核 {...}`,必须查看 `响应片段`,判断是否能识别受众策略 JSON;无法确认时重跑 Step5。
-如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别受众策略 JSON;无法确认时重跑 Step5。
diff --git a/skills-data/xdfx-step5-audience-strategy/scripts/xdfx_auth.py b/skills-data/xdfx-step5-audience-strategy/scripts/xdfx_auth.py
new file mode 100644
index 00000000..4003e55d
--- /dev/null
+++ b/skills-data/xdfx-step5-audience-strategy/scripts/xdfx_auth.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+"""Authentication helper for xdfx skill scripts.
+
+The caller should not pass tokens through command-line arguments. This module
+owns the fixed admin login flow and returns a Bearer token before business
+interfaces are called.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import ssl
+import urllib.error
+import urllib.request
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass
+class AuthResult:
+ token: str
+ url: str
+ status: int | None
+ raw: str
+
+
+class AuthError(RuntimeError):
+ def __init__(
+ self,
+ message: str,
+ *,
+ url: str,
+ status: int | None = None,
+ response: str = "",
+ exception: BaseException | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.url = url
+ self.status = status
+ self.response = response
+ self.exception = exception
+
+
+def auth_header_value(token: str) -> str:
+ token = token.strip()
+ return token if token.lower().startswith("bearer ") else f"Bearer {token}"
+
+
+def redact_url(url: str) -> str:
+ return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
+
+
+def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
+ if verify_ssl:
+ return None
+ return ssl._create_unverified_context()
+
+
+def build_login_url(api_base: str, path: str) -> str:
+ return path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
+
+
+def extract_access_token(data: Any) -> str | None:
+ if isinstance(data, dict):
+ for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
+ value = data.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ for value in data.values():
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ elif isinstance(data, list):
+ for value in data:
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ return None
+
+
+def parse_json_loose(text: str) -> Any:
+ try:
+ return json.loads(text)
+ except Exception:
+ pass
+ match = re.search(r"[\[{]", text or "")
+ if not match:
+ return {}
+ start = match.start()
+ for end in range(len(text), start, -1):
+ try:
+ return json.loads(text[start:end])
+ except Exception:
+ continue
+ return {}
+
+
+def fetch_access_token(
+ *,
+ api_base: str,
+ username: str = "admin",
+ password: str = "ch@user",
+ login_path: str = "/api/auth/login",
+ timeout: float = 90,
+ verify_ssl: bool = False,
+) -> AuthResult:
+ """Login once and return the extracted access token."""
+ url = build_login_url(api_base, login_path)
+ payload = {"username": username, "password": password}
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ parsed = parse_json_loose(raw)
+ token = extract_access_token(parsed)
+ if not token:
+ raise AuthError(
+ "登录接口已响应但未找到 access_token",
+ url=redact_url(url),
+ status=getattr(resp, "status", None),
+ response=raw,
+ )
+ return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
+ raise AuthError(
+ "登录接口返回错误",
+ url=redact_url(url),
+ status=exc.code,
+ response=body,
+ exception=exc,
+ ) from exc
+ except AuthError:
+ raise
+ except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
+ raise AuthError(
+ "登录接口调用异常",
+ url=redact_url(url),
+ response=str(exc),
+ exception=exc,
+ ) from exc
diff --git a/skills-data/xdfx-step5-audience-strategy/scripts/xdfx_flow.py b/skills-data/xdfx-step5-audience-strategy/scripts/xdfx_flow.py
index 52880e82..d9580e2f 100644
--- a/skills-data/xdfx-step5-audience-strategy/scripts/xdfx_flow.py
+++ b/skills-data/xdfx-step5-audience-strategy/scripts/xdfx_flow.py
@@ -26,7 +26,10 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
+import xdfx_auth
+
+# Keep every backend path in one table so endpoint drift is easy to audit.
ENDPOINTS = {
"aiuser": "/gpt/chat/chat_aiuser",
"group": "/gpt/chat/chat_qianqian_group_chat",
@@ -44,10 +47,12 @@ ENDPOINTS = {
"save_narrate": "/api/base/base-narrate",
"save_message": "/api/task/task-action-message",
"save_strategy_relation": "/api/task/task-action-design-strategy-relation",
+ "dict_data": "/api/sys/sys-dict-data",
}
+# Human-readable names are used only in the Chinese feedback logs.
ENDPOINT_LABELS = {
- "login": "登录获取访问 token",
+ "login": "登录获取访问令牌",
"aiuser": "调用候选账号/受众分析模型接口",
"group": "调用受众目标结构化抽取接口",
"spread": "调用关键传播节点结构化抽取接口",
@@ -64,8 +69,90 @@ ENDPOINT_LABELS = {
"save_narrate": "保存策略信息主题",
"save_message": "保存策略消息配置",
"save_strategy_relation": "保存策略与受众/传播节点关系",
+ "dict_data": "读取前端字典数据",
}
+LOG_PREFIXES = {
+ "operation": "操作反馈",
+ "error": "错误反馈",
+ "json_review": "JSON解析复核",
+ "save": "入库反馈",
+ "step": "步骤反馈",
+ "done": "执行完成",
+}
+
+STATUS_LABELS = {
+ "ok": "成功",
+ "partial": "部分完成",
+ "skipped": "已跳过",
+}
+
+STEP_NUMBER_LABELS = {
+ "step1": "第一步",
+ "step2": "第二步",
+ "step3": "第三步",
+ "step4": "第四步",
+ "step5": "第五步",
+ "step6": "第六步",
+}
+
+HTTP_METHOD_LABELS = {
+ "GET": "读取(GET)",
+ "POST": "提交(POST)",
+ "PUT": "更新(PUT)",
+ "PATCH": "局部更新(PATCH)",
+ "DELETE": "删除(DELETE)",
+}
+
+STAGE_SEGMENT_LABELS = {
+ "analyze": "模型分析",
+ "extract": "结构化抽取",
+ "save": "保存数据",
+ "read": "读取数据",
+ "status": "同步状态",
+}
+
+REQUEST_FIELD_LABELS = {
+ "history": "历史消息",
+ "max_tokens": "最大生成令牌数",
+ "model_name": "模型名称",
+ "otherinfo": "其他信息",
+ "prompt_name": "提示词名称",
+ "stream": "是否流式",
+ "temperature": "温度",
+ "usermessage": "用户消息",
+ "user_message": "用户消息",
+ "username": "登录用户名",
+ "password": "登录密码",
+ "mubiao": "行动目标",
+ "zhanghao": "账号列表",
+ "taskId": "任务ID",
+ "actionId": "行动ID",
+ "taskActionId": "行动ID",
+ "designId": "受众ID",
+ "pathwayId": "传播节点ID",
+ "strategyId": "策略ID",
+ "accountId": "账号ID",
+ "accountName": "账号名称",
+ "accountType": "账号类型",
+ "targetAudience": "受众目标",
+ "vulnerabilities": "脆弱点",
+ "topic": "信息标题",
+ "narrate": "叙事主题",
+ "platform": "平台",
+ "persona": "发布人设",
+ "modelType": "信息形式",
+ "quantity": "数量",
+ "publishTime": "发布时间段",
+ "reason": "理由",
+ "ids": "ID列表",
+ "id": "ID",
+ "step": "步骤状态",
+}
+
+SENSITIVE_KEY_PARTS = ("password", "token", "authorization", "access_token", "accesstoken", "authtoken")
+
+# Platform values mirror the frontend dictionaries used by xdfx strategy pages.
PLATFORM_MAP = {
"facebook": 1,
"fb": 1,
@@ -78,6 +165,8 @@ PLATFORM_MAP = {
"yt": 4,
}
+# The model service sometimes returns a successful HTTP response with these
+# strings instead of useful content. Treat them like transient failures.
MODEL_RETRY_MARKERS = (
"y模型没有响应",
"模型没有响应",
@@ -118,6 +207,12 @@ class FlowError(RuntimeError):
@dataclass
class FlowContext:
+ """Runtime options shared by every step.
+
+ Keep this object boring and serializable-ish: it is passed through every
+ helper, while durable progress belongs in the state JSON file.
+ """
+
api_base: str
headers: dict[str, str]
timeout: float
@@ -129,13 +224,15 @@ class FlowContext:
max_pathways: int = 3
max_vulns: int = 3
max_strategies: int = 3
+ downstream_audiences: int = 2
+ downstream_pathways: int = 2
sync_step_status: bool = True
model_retries: int = 1
verify_ssl: bool = False
auto_login: bool = True
login_username: str = "admin"
login_password: str = "ch@user"
- login_path: str = "/api/v1/auth/login/username"
+ login_path: str = "/api/auth/login"
login_attempt_count: int = 0
@@ -153,7 +250,38 @@ def compact(value: Any, limit: int = 1400) -> str:
text = repr(value)
if len(text) <= limit:
return text
- return text[:limit] + f"... "
+ return text[:limit] + f"... <已截断 {len(text) - limit} 个字符>"
+
+
+def is_sensitive_key(key: Any) -> bool:
+ normalized = re.sub(r"[^a-z0-9_]", "", str(key or "").lower())
+ return any(part in normalized for part in SENSITIVE_KEY_PARTS)
+
+
+def redact_sensitive(value: Any) -> Any:
+ if isinstance(value, dict):
+ return {key: "***" if is_sensitive_key(key) else redact_sensitive(item) for key, item in value.items()}
+ if isinstance(value, list):
+ return [redact_sensitive(item) for item in value]
+ if isinstance(value, tuple):
+ return tuple(redact_sensitive(item) for item in value)
+ if isinstance(value, str):
+ text = re.sub(r"(?i)(Bearer\s+)[^\s\",}]+", r"\1***", value)
+ text = re.sub(
+ r"(?i)(\"(?:password|token|authToken|access_token|accessToken|authorization)\"\s*:\s*)\"[^\"]*\"",
+ r'\1"***"',
+ text,
+ )
+ return re.sub(
+ r"(?i)((?:password|token|authToken|access_token|accessToken|authorization)=)[^&\s]+",
+ r"\1***",
+ text,
+ )
+ return value
+
+
+def safe_compact(value: Any, limit: int = 1400) -> str:
+ return compact(redact_sensitive(value), limit)
def stable_json(value: Any) -> str:
@@ -223,6 +351,11 @@ def save_json_file(path: str | Path, value: Any) -> None:
def default_state() -> dict[str, Any]:
+ """Create a resumable state container.
+
+ The script can be run as six independent commands. IDs returned by one step
+ are persisted here so later steps can resume without redoing earlier saves.
+ """
return {
"api_base": "",
"taskId": "",
@@ -247,6 +380,7 @@ def default_state() -> dict[str, Any]:
def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> None:
+ # Accept both frontend-style camelCase and CLI-friendly snake_case inputs.
aliases = {
"task_id": "taskId",
"action_id": "actionId",
@@ -260,16 +394,12 @@ def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> No
continue
if dest in {"taskId", "actionId"}:
state[dest] = str(value)
- elif dest in state or dest in {
- "api_base",
- "headers",
- "auth_token",
- "authorization",
- }:
+ elif dest in state or dest in {"api_base", "headers"}:
state[dest] = value
def record_event(state: dict[str, Any], stage: str, message: str, data: Any | None = None) -> None:
+ """Append a compact diagnostic event to the state file."""
state.setdefault("events", []).append(
{
"time": utc_now(),
@@ -286,9 +416,13 @@ def feedback_step(stage: str) -> str:
return match.group(1) if match else ""
+def feedback_step_label(stage: str) -> str:
+ return STEP_NUMBER_LABELS.get(feedback_step(stage), "")
+
+
def request_keys(payload: Any) -> list[str]:
if isinstance(payload, dict):
- return sorted(str(key) for key in payload.keys())
+ return sorted(REQUEST_FIELD_LABELS.get(str(key), str(key)) for key in payload.keys())
return []
@@ -306,6 +440,77 @@ def endpoint_label(endpoint_key: str | None, method: str, url: str) -> str:
return f"调用接口 {method}"
+def method_label(method: str) -> str:
+ return HTTP_METHOD_LABELS.get(str(method).upper(), str(method))
+
+
+def yes_no(value: bool) -> str:
+ return "是" if value else "否"
+
+
+def stage_label(stage: Any) -> str:
+ text = str(stage or "")
+ if not text:
+ return ""
+ if text in STEP_LABELS:
+ return STEP_LABELS[text]
+ if text == "runner":
+ return "流程执行器"
+ parts = text.split("/")
+ base = parts[0]
+ match = re.search(r"(step[1-6])", base)
+ labels: list[str] = []
+ if match:
+ labels.append(STEP_LABELS.get(match.group(1), match.group(1)))
+ elif base == "runner":
+ labels.append("流程执行器")
+ else:
+ labels.append(base)
+ for segment in parts[1:]:
+ normalized = segment.replace("-", "_")
+ attempt = re.fullmatch(r"model-call-attempt-(\d+)", segment)
+ json_attempt = re.fullmatch(r"model-json-attempt-(\d+)", segment)
+ if attempt:
+ labels.append(f"模型接口第 {attempt.group(1)} 次尝试")
+ elif json_attempt:
+ labels.append(f"JSON解析第 {json_attempt.group(1)} 次尝试")
+ elif segment == "auto-login":
+ labels.append("自动登录")
+ elif segment in STAGE_SEGMENT_LABELS:
+ labels.append(STAGE_SEGMENT_LABELS[segment])
+ elif normalized in ENDPOINT_LABELS:
+ labels.append(ENDPOINT_LABELS[normalized])
+ else:
+ labels.append(segment)
+ return " / ".join(labels)
+
+
+def status_label(status: Any) -> str:
+ if status is None:
+ return ""
+ return STATUS_LABELS.get(str(status), str(status))
+
+
+def mode_label(mode: str) -> str:
+ if mode == "full":
+ return "全流程"
+ return STEP_LABELS.get(mode, mode)
+
+
+def exception_type_label(exception_type: Any) -> str:
+ name = str(exception_type or "")
+ labels = {
+ "HTTPError": "HTTP 调用异常",
+ "URLError": "网络地址异常",
+ "TimeoutError": "请求超时异常",
+ "FlowError": "流程异常",
+ "ValueError": "数据格式异常",
+ "KeyError": "字段缺失异常",
+ "RuntimeError": "运行异常",
+ }
+ return labels.get(name, "程序异常" if name else "")
+
+
def emit_operation_feedback(
state: dict[str, Any],
stage: str,
@@ -317,39 +522,41 @@ def emit_operation_feedback(
attempt: int = 1,
dry_run: bool = False,
) -> None:
+ # Feedback lines are a mini protocol consumed by the calling agent. They
+ # must stay single-line JSON so weak models/tools can relay them reliably.
payload_obj = {
- "type": "OP_FEEDBACK",
- "step": feedback_step(stage),
- "stage": stage,
- "operation": endpoint_label(endpoint_key, method, url),
- "method": method,
- "url": url,
- "endpoint": endpoint_key or "",
- "attempt": attempt,
- "dryRun": dry_run,
- "requestKeys": request_keys(payload),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "操作反馈",
+ "步骤": feedback_step_label(stage),
+ "阶段": stage_label(stage),
+ "操作": endpoint_label(endpoint_key, method, url),
+ "请求方式": method_label(method),
+ "接口地址": url,
+ "接口名称": endpoint_label(endpoint_key, method, url),
+ "第几次尝试": attempt,
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("OP_FEEDBACK " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["operation"] + " " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
def emit_error_feedback(error: dict[str, Any]) -> None:
payload = {
- "type": "ERROR_FEEDBACK",
- "stage": error.get("stage"),
- "step": feedback_step(str(error.get("stage") or "")),
- "message": error.get("message"),
- "url": error.get("url"),
- "status": error.get("status"),
- "request": error.get("request"),
- "response": error.get("response"),
- "exceptionType": error.get("exceptionType"),
- "exception": error.get("exception"),
- "time": error.get("time"),
+ "日志类型": "错误反馈",
+ "阶段": stage_label(error.get("stage")),
+ "步骤": feedback_step_label(str(error.get("stage") or "")),
+ "错误信息": error.get("message"),
+ "接口地址": error.get("url"),
+ "状态码": error.get("status"),
+ "请求摘要": error.get("request"),
+ "响应摘要": error.get("response"),
+ "异常类型": exception_type_label(error.get("exceptionType")),
+ "异常信息": error.get("exception"),
+ "时间": error.get("time"),
}
- print("ERROR_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["error"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def emit_json_parse_review(
@@ -360,18 +567,89 @@ def emit_json_parse_review(
candidate_count: int,
) -> None:
payload = {
- "type": "JSON_PARSE_REVIEW",
- "stage": stage,
- "step": feedback_step(stage),
- "endpoint": endpoint_key,
- "message": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
- "candidateCount": candidate_count,
- "responseExcerpt": compact(text, 3000),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "JSON解析复核",
+ "阶段": stage_label(stage),
+ "步骤": feedback_step_label(stage),
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, endpoint_key),
+ "提示": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
+ "候选数量": candidate_count,
+ "响应片段": compact(text, 3000),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("JSON_PARSE_REVIEW " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["json_review"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
+
+
+SAVE_ENTITY_LABELS = {
+ "save_design": "受众目标",
+ "save_design_account": "受众目标账号",
+ "save_pathway": "关键传播节点",
+ "save_relation": "受众-传播节点关系",
+ "save_design_vuln": "受众脆弱点",
+ "save_pathway_vuln": "传播节点脆弱点",
+ "save_strategy": "策略",
+ "save_channel": "策略平台与人设",
+ "save_narrate": "策略信息主题",
+ "save_message": "策略消息配置",
+ "save_strategy_relation": "策略关联关系",
+}
+
+
+def saved_entity_name(endpoint_key: str, payload: dict[str, Any]) -> str:
+ """Return the most user-readable name for an inserted row."""
+ candidates = [
+ payload.get("targetAudience"),
+ payload.get("accountName"),
+ payload.get("vulnerabilities"),
+ payload.get("topic"),
+ payload.get("narrate"),
+ payload.get("platform"),
+ payload.get("strategyId"),
+ payload.get("designId"),
+ payload.get("pathwayId"),
+ ]
+ for value in candidates:
+ if value is not None and str(value).strip():
+ return str(value).strip()
+ return endpoint_key
+
+
+def emit_save_feedback(
+ state: dict[str, Any],
+ stage: str,
+ endpoint_key: str,
+ payload: dict[str, Any],
+ row_id: int,
+ *,
+ dry_run: bool,
+) -> None:
+ # Emit only after an id is known. Downstream steps use these ids and should
+ # fail loudly rather than inventing relations against missing rows.
+ label = SAVE_ENTITY_LABELS.get(endpoint_key, endpoint_key)
+ name = saved_entity_name(endpoint_key, payload)
+ feedback = {
+ "日志类型": "入库反馈",
+ "步骤": feedback_step(stage),
+ "阶段": stage,
+ "入库对象类型": label,
+ "入库对象名称": name,
+ "返回ID": row_id,
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, label),
+ "接口路径": ENDPOINTS.get(endpoint_key, ""),
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
+ }
+ print(LOG_PREFIXES["save"] + " " + json.dumps(feedback, ensure_ascii=False), flush=True)
+ record_event(
+ state,
+ stage,
+ f"入库{label}成功:{name}",
+ {"id": row_id, "endpoint": endpoint_key, "dryRun": dry_run},
+ )
def record_error(
@@ -385,16 +663,18 @@ def record_error(
response: Any | None = None,
exception: BaseException | None = None,
) -> None:
+ # Persist full-enough error context for post-run diagnosis, then immediately
+ # emit a short line the agent can show to the user.
item = {
"time": utc_now(),
"stage": stage,
"message": message,
"url": url,
"status": status,
- "request": compact(request, 1200) if request is not None else None,
- "response": compact(response, 2000) if response is not None else None,
+ "request": safe_compact(request, 1200) if request is not None else None,
+ "response": safe_compact(response, 2000) if response is not None else None,
"exceptionType": type(exception).__name__ if exception else None,
- "exception": str(exception) if exception else None,
+ "exception": safe_compact(str(exception), 1000) if exception else None,
}
state.setdefault("errors", []).append(item)
state["updatedAt"] = utc_now()
@@ -430,69 +710,29 @@ def url_for_path(ctx: FlowContext, path: str) -> str:
return ctx.api_base.rstrip("/") + "/" + path.lstrip("/")
-def parse_headers(header_values: list[str], auth_token: str | None, authorization: str | None) -> dict[str, str]:
+def parse_headers(header_values: list[str]) -> dict[str, str]:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
- if auth_token:
- headers["Authorization"] = auth_token if auth_token.lower().startswith("bearer ") else f"Bearer {auth_token}"
- if authorization:
- headers["Authorization"] = authorization
for raw in header_values:
if ":" not in raw:
raise FlowError(f"header 格式错误,应为 Key: Value,实际:{raw}")
key, value = raw.split(":", 1)
- headers[key.strip()] = value.strip()
+ normalized = key.strip()
+ if normalized.lower() == "authorization":
+ continue
+ headers[normalized] = value.strip()
return headers
-def has_authorization(headers: dict[str, str]) -> bool:
- return any(key.lower() == "authorization" and str(value).strip() for key, value in headers.items())
-
-
def ssl_context(ctx: FlowContext) -> ssl.SSLContext | None:
if ctx.verify_ssl:
return None
return ssl._create_unverified_context()
-def auth_header_value(token: str) -> str:
- token = token.strip()
- return token if token.lower().startswith("bearer ") else f"Bearer {token}"
-
-
-def extract_access_token(data: Any) -> str | None:
- if isinstance(data, dict):
- for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
- value = data.get(key)
- if isinstance(value, str) and value.strip():
- return value.strip()
- for value in data.values():
- nested = extract_access_token(value)
- if nested:
- return nested
- elif isinstance(data, list):
- for value in data:
- nested = extract_access_token(value)
- if nested:
- return nested
- return None
-
-
-def redact_url(url: str) -> str:
- return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
-
-
-def build_login_url(ctx: FlowContext, path: str) -> str:
- base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else url_for_path(ctx, path)
- if ctx.login_password:
- separator = "&" if "?" in base else "?"
- base = f"{base}{separator}password={urllib.parse.quote(ctx.login_password, safe='')}"
- return base
-
-
def login_candidate_paths(ctx: FlowContext) -> list[str]:
paths = [
- ctx.login_path or "/api/v1/auth/login/username",
- "/api/v1/auth/login/username",
+ ctx.login_path or "/api/auth/login",
+ "/api/auth/login",
]
seen: set[str] = set()
unique: list[str] = []
@@ -506,6 +746,7 @@ def login_candidate_paths(ctx: FlowContext) -> list[str]:
def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force: bool = False) -> bool:
+ """Fetch a fresh token through the local auth package before business calls."""
if not ctx.auto_login:
return False
if ctx.dry_run:
@@ -515,18 +756,17 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
record_error(state, stage, "自动登录失败:缺少 login_username")
return False
if not force and ctx.login_attempt_count > 0:
- return has_authorization(ctx.headers)
+ return any(key.lower() == "authorization" and str(value).strip() for key, value in ctx.headers.items())
if force and ctx.login_attempt_count >= 2:
return False
ctx.login_attempt_count += 1
- payload = {"username": ctx.login_username}
+ payload = {"username": ctx.login_username, "password": ctx.login_password}
last_status: int | None = None
last_response = ""
last_exc: BaseException | None = None
for attempt, path in enumerate(login_candidate_paths(ctx), start=1):
- url = build_login_url(ctx, path)
- display_url = redact_url(url)
+ display_url = xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, path))
emit_operation_feedback(
state,
stage,
@@ -537,63 +777,39 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
attempt=attempt,
)
try:
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- headers = {
- "Content-Type": "application/json",
- "Accept": "application/json",
- }
- req = urllib.request.Request(url, data=data, headers=headers, method="POST")
- with urllib.request.urlopen(req, timeout=ctx.timeout, context=ssl_context(ctx)) as resp:
- raw = resp.read().decode("utf-8", errors="replace")
- parsed = parse_json_lenient(raw)
- token = extract_access_token(parsed)
- if not token:
- last_status = getattr(resp, "status", None)
- last_response = raw
- record_event(
- state,
- stage,
- "登录接口已响应但未找到 access_token",
- {"url": display_url, "status": last_status, "response": compact(raw, 1000)},
- )
- continue
- ctx.headers["Authorization"] = auth_header_value(token)
- record_event(
- state,
- stage,
- "自动登录成功,已写入 Authorization Bearer token",
- {
- "url": display_url,
- "status": getattr(resp, "status", None),
- "username": ctx.login_username,
- },
- )
- return True
- except urllib.error.HTTPError as exc:
- last_status = exc.code
- last_response = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
- last_exc = exc
- record_event(
- state,
- stage,
- "登录接口返回错误,准备尝试下一个候选登录路径",
- {"url": display_url, "status": exc.code, "response": compact(last_response, 1000)},
+ result = xdfx_auth.fetch_access_token(
+ api_base=ctx.api_base,
+ username=ctx.login_username,
+ password=ctx.login_password,
+ login_path=path,
+ timeout=ctx.timeout,
+ verify_ssl=ctx.verify_ssl,
)
- except Exception as exc: # noqa: BLE001 - auto-login must report every failure.
- last_exc = exc
- last_response = traceback.format_exc()
+ ctx.headers["Authorization"] = xdfx_auth.auth_header_value(result.token)
+ state["_tokenSource"] = "xdfx_auth"
record_event(
state,
stage,
- "登录接口调用异常,准备尝试下一个候选登录路径",
- {"url": display_url, "exceptionType": type(exc).__name__, "exception": str(exc)},
+ "启动阶段已通过 xdfx_auth 包获取 token,并写入 Authorization",
+ {"url": result.url, "status": result.status, "username": ctx.login_username},
+ )
+ return True
+ except xdfx_auth.AuthError as exc:
+ last_status = exc.status
+ last_response = exc.response
+ last_exc = exc
+ record_event(
+ state,
+ stage,
+ "xdfx_auth 获取 token 失败,准备尝试下一个候选登录路径",
+ {"url": exc.url, "status": exc.status, "response": safe_compact(exc.response, 1000), "exception": str(exc)},
)
record_error(
state,
stage,
- "自动登录失败,未能获取 access_token",
- url=redact_url(build_login_url(ctx, ctx.login_path or "/api/v1/auth/login/username")),
+ "自动登录失败,未能通过 xdfx_auth 获取 access_token",
+ url=xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, ctx.login_path or "/api/auth/login")),
status=last_status,
request=payload,
response=last_response,
@@ -612,6 +828,7 @@ def http_request_json_url(
endpoint_key: str | None = None,
allow_auto_login: bool = True,
) -> tuple[Any | None, str]:
+ """Call an HTTP JSON endpoint with logging, retries, and 401 recovery."""
method = method.upper()
if ctx.dry_run:
emit_operation_feedback(state, stage, method, url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
@@ -640,6 +857,7 @@ def http_request_json_url(
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
if exc.code == 401 and allow_auto_login and ctx.auto_login:
+ # Refresh once on 401, then replay the original request exactly.
refreshed = try_auto_login(ctx, state, f"{stage}/auto-login", force=True)
if refreshed:
record_event(
@@ -709,6 +927,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
emit_operation_feedback(state, stage, "POST", url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
record_event(state, stage, f"DRY RUN POST {url}", {"payload": payload})
if endpoint_key.startswith("save_"):
+ # Synthetic ids let downstream dry-run code exercise relation wiring.
synthetic_id = abs(hash(stage + stable_json(payload))) % 100000000
return {"id": synthetic_id, "dryRun": True}, json.dumps({"id": synthetic_id, "dryRun": True})
return {"dryRun": True}, json.dumps({"dryRun": True})
@@ -716,6 +935,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
def parse_json_lenient(text: str) -> Any | None:
+ """Parse normal JSON plus adjacent/SSE-like JSON objects."""
stripped = text.strip()
if not stripped:
return None
@@ -730,6 +950,7 @@ def parse_json_lenient(text: str) -> Any | None:
def parse_concatenated_json_objects(text: str) -> list[Any]:
+ """Read streams such as {"answer": "..."}{"answer": "..."} or data: lines."""
decoder = json.JSONDecoder()
objects: list[Any] = []
i = 0
@@ -766,6 +987,7 @@ def parse_concatenated_json_objects(text: str) -> list[Any]:
def model_text_from_response(parsed: Any, raw: str) -> tuple[str, list[Any]]:
+ """Flatten streamed model chunks into one text buffer."""
objects: list[Any]
if isinstance(parsed, list):
objects = parsed
@@ -824,6 +1046,7 @@ def extract_json_from_text(text: str) -> Any | None:
def extract_json_candidates_from_text(text: str) -> list[Any]:
+ """Collect every plausible JSON object/array from messy model text."""
cleaned = strip_think(text)
candidates: list[Any] = []
seen: set[str] = set()
@@ -860,6 +1083,7 @@ def extract_json_candidates_from_text(text: str) -> list[Any]:
def select_json_candidate(candidates: list[Any], endpoint_key: str, stage: str) -> Any | None:
+ """Pick the candidate whose keys best match the current step."""
if not candidates:
return None
scored = [(json_candidate_score(candidate, endpoint_key, stage), idx, candidate) for idx, candidate in enumerate(candidates)]
@@ -1010,6 +1234,7 @@ def extract_first_balanced(text: str, open_ch: str, close_ch: str) -> str | None
def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[str, list[Any], Any | None]:
+ """Call a model endpoint and retry only known empty/timeout/no-response cases."""
max_attempts = max(ctx.model_retries, 0) + 1
last_text = ""
last_objects: list[Any] = []
@@ -1048,6 +1273,11 @@ def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str,
def call_model_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[dict[str, Any], str, list[Any]]:
+ """Return the best structured JSON for a model step.
+
+ If the first parse fails, the second attempt includes an explicit repair
+ instruction so weak models can re-emit only the required JSON.
+ """
last_text = ""
last_objects: list[Any] = []
last_candidate_count = 0
@@ -1202,23 +1432,85 @@ def profile_info(account: dict[str, Any]) -> str:
def parse_index_list(value: Any) -> list[str]:
- if value is None:
- return []
- if isinstance(value, list):
- return [str(v).strip() for v in value if str(v).strip()]
- text = str(value)
- return [part.strip().strip("<>").strip() for part in re.split(r"[,,;;、\s]+", text) if part.strip()]
+ refs: list[str] = []
+
+ def add(raw: Any) -> None:
+ if raw is None:
+ return
+ if isinstance(raw, dict):
+ keys = (
+ "编号id",
+ "编号ID",
+ "用户编号id",
+ "用户编号ID",
+ "用户编号",
+ "账号编号",
+ "引用序号",
+ "索引",
+ "index",
+ "id",
+ "ids",
+ "accountIndex",
+ "accountIndexes",
+ "userIndex",
+ "userIndexes",
+ )
+ for key in keys:
+ if key in raw:
+ add(raw.get(key))
+ return
+ if isinstance(raw, (list, tuple, set)):
+ for item in raw:
+ add(item)
+ return
+ text = str(raw).strip()
+ if not text:
+ return
+ angle_refs = re.findall(r"<<\s*([^<>]+?)\s*>>", text)
+ if angle_refs:
+ for token in angle_refs:
+ add(token)
+ return
+ for part in re.split(r"[,,;;、\s]+", text):
+ token = part.strip().strip("<>").strip()
+ if token:
+ refs.append(token)
+
+ add(value)
+ deduped: list[str] = []
+ seen: set[str] = set()
+ for ref in refs:
+ if ref in seen:
+ continue
+ seen.add(ref)
+ deduped.append(ref)
+ return deduped
+
+
+def account_ref_numbers(account: dict[str, Any]) -> set[int]:
+ numbers: set[int] = set()
+ meta = account.get("metadata") if isinstance(account.get("metadata"), dict) else {}
+ for key in ("编号id", "编号ID", "用户编号id", "用户编号ID", "用户编号", "账号编号", "引用序号", "索引", "index", "accountIndex"):
+ for container in (account, meta):
+ number = as_int(container.get(key)) if isinstance(container, dict) else None
+ if number is not None:
+ numbers.add(number)
+ return numbers
def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, Any] | None:
+ """Resolve model references like <<3>>, index numbers, names, or account ids."""
if not accounts:
return None
text = str(ref).strip().strip("<>").strip()
number = as_int(text)
if number is not None:
for account in accounts:
- if any(as_int(account.get(key)) == number for key in ("引用序号", "索引", "index")):
+ if number in account_ref_numbers(account):
return account
+ # Step1 group extraction returns 编号/id values that refer to the
+ # previous zhanghao array index. Prefer exact index fields above, then
+ # fall back to zero-based and one-based array positions.
if 0 <= number < len(accounts):
return accounts[number]
if 1 <= number <= len(accounts):
@@ -1232,11 +1524,25 @@ def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, A
def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Normalize audience JSON whether the model used numbered fields or items[]."""
items: list[dict[str, Any]] = []
for i in range(1, 21):
group = first_present(data.get(f"群体{i}"), data.get(f"受众{i}"), data.get(f"targetAudience{i}"))
reason = first_present(data.get(f"理由{i}"), data.get(f"推荐理由{i}"), data.get(f"recReason{i}"))
- users = first_present(data.get(f"用户编号列表{i}"), data.get(f"用户列表{i}"), data.get(f"账号编号列表{i}"))
+ users = first_present(
+ data.get(f"用户编号列表{i}"),
+ data.get(f"编号id列表{i}"),
+ data.get(f"编号ID列表{i}"),
+ data.get(f"用户编号id列表{i}"),
+ data.get(f"用户编号ID列表{i}"),
+ data.get(f"用户列表{i}"),
+ data.get(f"账号编号列表{i}"),
+ data.get(f"索引列表{i}"),
+ data.get(f"编号{i}"),
+ data.get(f"id{i}"),
+ data.get(f"ids{i}"),
+ data.get(f"索引{i}"),
+ )
if group:
items.append(
{
@@ -1255,7 +1561,27 @@ def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
{
"targetAudience": str(group).strip(),
"recReason": str(first_present(item.get("理由"), item.get("recReason"), item.get("reason")) or "").strip(),
- "userIndexes": parse_index_list(first_present(item.get("用户编号列表"), item.get("users"), item.get("accounts"))),
+ "userIndexes": parse_index_list(
+ first_present(
+ item.get("用户编号列表"),
+ item.get("编号id列表"),
+ item.get("编号ID列表"),
+ item.get("用户编号id列表"),
+ item.get("用户编号ID列表"),
+ item.get("编号id"),
+ item.get("编号ID"),
+ item.get("用户编号id"),
+ item.get("用户编号ID"),
+ item.get("编号"),
+ item.get("索引"),
+ item.get("id"),
+ item.get("ids"),
+ item.get("users"),
+ item.get("accounts"),
+ item.get("userIndexes"),
+ item.get("accountIndexes"),
+ )
+ ),
"rank": i,
}
)
@@ -1272,6 +1598,7 @@ def audience_score(item: dict[str, Any]) -> tuple[int, int]:
def select_top_audiences(items: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
+ """Deduplicate audience names and keep the strongest candidates."""
unique: dict[str, dict[str, Any]] = {}
for item in items:
key = item.get("targetAudience", "")
@@ -1301,6 +1628,7 @@ def pathway_score(item: dict[str, Any]) -> tuple[int, int, int]:
def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Merge the same account found from different audiences before saving top 3."""
merged: dict[str, dict[str, Any]] = {}
for item in candidates:
account = item.get("account") or {}
@@ -1325,6 +1653,7 @@ def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str,
def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
+ """Normalize weak-point model output into a capped list."""
vulns: list[dict[str, str]] = []
for i in range(1, 21):
vuln = first_present(data.get(f"脆弱点{i}"), data.get(f"vulnerability{i}"), data.get(f"vuln{i}"))
@@ -1339,6 +1668,7 @@ def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
def extract_strategies(data: dict[str, Any], limit: int) -> list[dict[str, Any]]:
+ """Normalize strategy output and preserve the modal text for later mapping."""
strategies: list[dict[str, Any]] = []
for i in range(1, 21):
title = first_present(data.get(f"信息标题{i}"), data.get(f"主题{i}"), data.get(f"topic{i}"))
@@ -1395,11 +1725,29 @@ def response_id(response: Any) -> int | None:
return None
+def response_rows(response: Any) -> list[Any]:
+ """Extract list rows from common CRUD response shapes."""
+ if isinstance(response, list):
+ return response
+ if not isinstance(response, dict):
+ return []
+ for key in ("data", "rows", "items", "records", "list"):
+ value = response.get(key)
+ if isinstance(value, list):
+ return value
+ rows = response_rows(value)
+ if rows:
+ return rows
+ return []
+
+
def save_with_id(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[int | None, Any | None]:
response, _ = http_post_json(ctx, state, endpoint_key, payload, stage)
rid = response_id(response)
if rid is None:
- record_error(state, stage, "保存接口未返回 id,视为失败", request=payload, response=response)
+ record_error(state, stage, "保存接口未返回 ID,视为失败", request=payload, response=response)
+ else:
+ emit_save_feedback(state, stage, endpoint_key, payload, rid, dry_run=ctx.dry_run)
return rid, response
@@ -1429,6 +1777,7 @@ def parse_step_flags(raw: Any) -> dict[str, Any]:
def mark_step_status(ctx: FlowContext, state: dict[str, Any], links: list[str], stage: str) -> None:
+ """Update the frontend step bar without overwriting unrelated step flags."""
if not ctx.sync_step_status:
record_event(state, stage, "跳过 task-action.step 状态同步")
return
@@ -1477,12 +1826,126 @@ def strategy_platform(value: Any) -> Any:
return platform_to_number(value) or 1
-def strategy_model_type(value: Any) -> str:
- # Frontend defaults to text model type "1" when no modal type is selected.
- raw = first_present(value, "1")
- if isinstance(raw, list):
- return ",".join(str(v) for v in raw if str(v).strip()) or "1"
- return str(raw or "1")
+def choice_tokens(value: Any) -> list[Any]:
+ """Split frontend choice values from arrays, JSON strings, and mixed delimiters."""
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return choice_tokens(
+ first_present(value.get("value"), value.get("dictValue"), value.get("label"), value.get("dictLabel"))
+ )
+ if isinstance(value, (list, tuple, set)):
+ tokens: list[Any] = []
+ for item in value:
+ tokens.extend(choice_tokens(item))
+ return tokens
+ text = str(value).strip()
+ if not text:
+ return []
+ if text[0] in "[{":
+ try:
+ parsed = json.loads(text)
+ if parsed != value:
+ return choice_tokens(parsed)
+ except Exception:
+ pass
+ return [part.strip() for part in re.split(r"[,,、;/|]+", text) if part.strip()]
+
+
+def dict_choices(
+ ctx: FlowContext,
+ state: dict[str, Any],
+ dict_type: str,
+ stage: str,
+) -> list[dict[str, Any]]:
+ """Fetch and cache frontend dictionary choices such as modelType."""
+ cache = state.setdefault("_dictChoiceCache", {})
+ if dict_type in cache:
+ cached = cache[dict_type]
+ return cached if isinstance(cached, list) else []
+ if ctx.dry_run:
+ cache[dict_type] = []
+ return []
+
+ query = urllib.parse.urlencode({"filter": f"dictType||$eq||{dict_type}", "limit": 999, "page": 1})
+ url = f"{url_for(ctx, 'dict_data')}?{query}"
+ response, _ = http_request_json_url(ctx, state, "GET", url, None, stage, endpoint_key="dict_data")
+ choices: list[dict[str, Any]] = []
+ for row in response_rows(response):
+ if not isinstance(row, dict):
+ continue
+ label = str(first_present(row.get("dictLabel"), row.get("label"), "") or "").strip()
+ raw_value = first_present(row.get("dictValue"), row.get("value"))
+ if not label or raw_value is None:
+ continue
+ number = as_int(raw_value)
+ choices.append({"label": label, "value": number if number is not None else str(raw_value).strip()})
+ for alias in choice_tokens(row.get("alias")):
+ alias_label = str(alias).strip()
+ if alias_label:
+ choices.append({"label": alias_label, "value": number if number is not None else str(raw_value).strip()})
+ cache[dict_type] = choices
+ return choices
+
+
+def choice_value_by_label(choices: list[dict[str, Any]] | None, label: str) -> Any | None:
+ needle = re.sub(r"\s+", "", str(label or "").lower())
+ if not needle:
+ return None
+ for item in choices or []:
+ item_label = re.sub(r"\s+", "", str(item.get("label") or "").lower())
+ if item_label and (needle == item_label or needle in item_label or item_label in needle):
+ return item.get("value")
+ return None
+
+
+def model_type_token_value(token: Any, choices: list[dict[str, Any]] | None = None) -> Any:
+ """Convert modal labels like 图片/视频 into frontend dictionary values."""
+ number = as_int(token)
+ if number is not None:
+ return number
+
+ text = str(token or "").strip()
+ lower = text.lower()
+ exact = choice_value_by_label(choices, text)
+ if exact is not None:
+ return exact
+
+ label = ""
+ fallback_value = 2
+ if "视" in text or "video" in lower:
+ label, fallback_value = "视频", 3
+ elif "图" in text or "image" in lower or "picture" in lower or "photo" in lower:
+ label, fallback_value = "图片", 2
+ elif "音" in text or "audio" in lower or "sound" in lower:
+ label, fallback_value = "音频", 4
+ elif "发文" in text or "文本" in text or "文字" in text or "text" in lower:
+ label, fallback_value = "文本", 1
+
+ if label:
+ matched = choice_value_by_label(choices, label)
+ if matched is not None:
+ return matched
+ return fallback_value
+
+ # Mirrors the frontend helper's unknown-token default.
+ return 2
+
+
+def strategy_model_type(value: Any, choices: list[dict[str, Any]] | None = None) -> str:
+ """Return the comma-separated value string expected by task-action-message."""
+ tokens = choice_tokens(value)
+ if not tokens:
+ # Frontend save paths fall back to "1" when no modal type is selected.
+ return "1"
+ values: list[str] = []
+ seen: set[str] = set()
+ for token in tokens:
+ normalized = str(model_type_token_value(token, choices)).strip()
+ if normalized and normalized not in seen:
+ values.append(normalized)
+ seen.add(normalized)
+ return ",".join(values) or "1"
def strategy_language(value: Any) -> int:
@@ -1501,11 +1964,28 @@ def save_strategy_details(
*,
audience: bool,
) -> None:
- platform = strategy_platform(first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})))
+ """Save all frontend-readable strategy child rows.
+
+ The strategy table and arrangement page do not read only the main strategy
+ row; they also expect channel, narrate, message, and relation rows.
+ """
+ platform = strategy_platform(
+ first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
+ )
+ account_type = str(
+ first_present(
+ strategy.get("accountType"),
+ item.get("accountType"),
+ item.get("type"),
+ "目标受众账号" if audience else "关键传播节点账号",
+ )
+ or ""
+ ).strip()
persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
title = str(strategy.get("title") or "").strip()
+ model_type_choices = dict_choices(ctx, state, "modelType", stage + "/dict-modelType")
channel_id, _ = save_with_id(
ctx,
@@ -1516,6 +1996,7 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"platform": platform,
+ "accountType": account_type,
"personaJson": json.dumps([{"persona": persona, "size": persona_size}], ensure_ascii=False),
},
stage + "/save-channel",
@@ -1531,7 +2012,10 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"channelId": channel_id,
- "modelType": strategy_model_type(first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType"))),
+ "modelType": strategy_model_type(
+ first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType")),
+ model_type_choices,
+ ),
"language": strategy_language(first_present(strategy.get("language"), strategy.get("语种"))),
"respondingCount": responding_count,
"messageCount": responding_count,
@@ -1564,7 +2048,46 @@ def object_for_audience(audience: dict[str, Any]) -> dict[str, Any]:
}
+def selected_accounts_for_item(item: dict[str, Any], *, audience: bool) -> list[Any]:
+ if audience:
+ accounts = item.get("accounts") or []
+ return accounts or [object_for_audience(item)]
+ return [item.get("account") or item]
+
+
+def downstream_limit(ctx: FlowContext, *, audience: bool) -> int:
+ """Limit expensive downstream analysis while keeping 3 upstream rows stored."""
+ raw = ctx.downstream_audiences if audience else ctx.downstream_pathways
+ return max(1, raw)
+
+
+def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, audience: bool) -> list[dict[str, Any]]:
+ """Select the 1-2 best stored rows for vulnerability/strategy generation."""
+ source_key = "topAudiences" if audience else "topPathways"
+ id_key = "designId" if audience else "pathwayAccountId"
+ selected_key = "selectedDownstreamAudiences" if audience else "selectedDownstreamPathways"
+ items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)]
+ limit = downstream_limit(ctx, audience=audience)
+ selected = items[:limit]
+ state[selected_key] = [
+ {
+ "id": item.get(id_key),
+ "name": item.get("targetAudience") if audience else item.get("accountName"),
+ }
+ for item in selected
+ ]
+ if items:
+ record_event(
+ state,
+ stage,
+ f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}",
+ {"limit": limit, "selected": state[selected_key]},
+ )
+ return selected
+
+
def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze audiences, save top 3, and attach any referenced accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step1-audience"
if state.get("topAudiences") and not ctx.dry_run:
@@ -1582,12 +2105,15 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
state["audienceRawText"] = text
+ # The first model call may return raw text plus embedded account objects.
accounts = accounts_from_response(parsed, objects, text)
state["audienceAccounts"] = accounts
+ # The second model call converts the long audience analysis into numbered
+ # groups that can be saved as task-action-design rows.
group_payload = {
"mubiao": state["mubiao"],
- "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
"zhanghao": accounts,
"history": [],
"stream": True,
@@ -1602,6 +2128,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
top = select_top_audiences(candidates, ctx.max_audiences)
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the audience first; account rows and downstream analyses depend
+ # on the returned designId.
design_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
@@ -1614,6 +2142,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
continue
item = {**item, "designId": design_id, "saveResponse": resp, "accounts": [], "savedAccountIds": []}
for ref in item.get("userIndexes") or []:
+ # Model references are intentionally fuzzy, so resolve them against
+ # both explicit indexes and account names/ids.
account = resolve_account_ref(ref, accounts)
if account is None:
record_error(state, stage + "/save-design-account", f"用户编号 {ref} 未匹配到候选账号")
@@ -1645,6 +2175,7 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze key pathway accounts for saved audiences and store top 3."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step2-pathway"
audiences = [a for a in state.get("topAudiences") or [] if a.get("designId")]
@@ -1656,6 +2187,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
return
all_candidates: list[dict[str, Any]] = []
for audience in audiences:
+ # Analyze pathway candidates in the context of each saved audience.
payload = {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。",
"otherinfo": {
@@ -1673,6 +2205,8 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
accounts = accounts_from_response(parsed, objects, text)
+ # Ask the model to point back to candidate accounts, then resolve those
+ # references to concrete account objects.
spread_payload = {
"mubiao": state["mubiao"],
"user_message": "分析其中关键信息。请从候选账号中抽取核心传播节点,字段必须是:账号1、理由1、账号2、理由2 ...,最多输出5个。账号字段优先返回候选数组的引用编号或索引,只返回JSON。",
@@ -1707,6 +2241,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways]
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the account row before writing the design-pathway relation.
account = item["account"]
aid = account_id(account)
name = account_name(account)
@@ -1757,18 +2292,20 @@ def run_step4(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate and save weak points for selected audiences or pathway accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step3-audience-vulns" if audience else "step4-pathway-vulns"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
output_key = "audienceVulns" if audience else "pathwayVulns"
state.setdefault(output_key, {})
for item in items:
+ # owner_id is designId for audiences and pathwayAccountId for accounts.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
if not owner_id:
continue
if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run:
continue
- zhanghao = item.get("accounts") or [object_for_audience(item)] if audience else [item.get("account") or item]
+ zhanghao = selected_accounts_for_item(item, audience=audience)
prompt = (
"你是中国对台湾方向的认知战专家,你要传播行动要求的叙事主题。请基于该受众目标的画像、关注议题、社会分工定位、利益诉求与上一轮账号佐证,分析该受众目标有哪些脆弱点可以成为后续策略设计的关键要素。请输出理由、脆弱点1、理由1、脆弱点2、理由2、脆弱点3、理由3,只返回JSON。"
if audience
@@ -1789,6 +2326,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) ->
vulns = extract_vulns(data, ctx.max_vulns)
saved: list[dict[str, Any]] = []
for vuln in vulns:
+ # The two weak-point tables have nearly identical payloads but
+ # different foreign-key names.
if audience:
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
@@ -1835,14 +2374,16 @@ def run_step6(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate strategies from saved weak points and persist frontend child rows."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step5-audience-strategy" if audience else "step6-pathway-strategy"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {}
output_key = "audienceStrategies" if audience else "pathwayStrategies"
state.setdefault(output_key, [])
existing_topics = {s.get("topic") for s in state[output_key] if isinstance(s, dict)}
for item in items:
+ # Strategies are only meaningful after the matching weak points exist.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
vulns = vulns_by_owner.get(str(owner_id)) or []
vuln_texts = [v.get("vulnerabilities") for v in vulns if v.get("vulnerabilities")]
@@ -1850,7 +2391,9 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
record_error(state, stage, "没有可用脆弱点,跳过策略生成", request=item)
continue
target_names = [item.get("targetAudience")] if audience else (item.get("relatedAudiences") or [item.get("accountName")])
- pingtai = state.get("platform") or (platform_label(item.get("account") or {}) if not audience else "")
+ zhanghao = selected_accounts_for_item(item, audience=audience)
+ first_account = zhanghao[0] if zhanghao and isinstance(zhanghao[0], dict) else {}
+ pingtai = state.get("platform") or platform_label(first_account)
payload = {
"user_message": DEFAULT_STRATEGY_PROMPT_AUDIENCE if audience else DEFAULT_STRATEGY_PROMPT_PATHWAY,
"cuiruodian": vuln_texts,
@@ -1858,6 +2401,7 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
"shouzhong": [name for name in target_names if name],
"mudi": extract_goal(state["mubiao"]),
"heiliao": [],
+ "zhanghao": zhanghao,
"history": [],
"stream": True,
"model_name": "OpenAI3",
@@ -1876,16 +2420,20 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
continue
if topic in existing_topics:
continue
+ # Save the main row first; channel/narrate/message/relation rows all
+ # require the returned strategy id.
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
"targetAudience": target,
"accountType": account_type,
"topic": topic,
- "strategyTitle": "",
+ "strategyTitle": str(strategy.get("title") or "").strip(),
}
strategy_id, resp = save_with_id(ctx, state, "save_strategy", save_payload, stage + "/save")
if strategy_id is not None:
+ # Frontend strategy pages read these child tables, so a strategy
+ # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience)
existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp})
@@ -1914,13 +2462,14 @@ STEP_LABELS = {
def state_counts(state: dict[str, Any]) -> dict[str, int]:
+ """Summarize stored objects for step feedback."""
return {
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceVulns": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
- "pathwayVulns": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "受众脆弱点数量": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
+ "传播节点脆弱点数量": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
}
@@ -1932,23 +2481,26 @@ def emit_step_feedback(
status: str | None = None,
note: str = "",
) -> None:
+ # One feedback line per step keeps the single-skill and split-skill versions
+ # equally transparent to the user.
total_errors = len(state.get("errors") or [])
new_errors = max(0, total_errors - before_error_count)
final_status = status or ("partial" if new_errors else "ok")
payload = {
- "type": "STEP_FEEDBACK",
- "step": step,
- "label": STEP_LABELS.get(step, step),
- "status": final_status,
- "note": note,
- "newErrors": new_errors,
- "totalErrors": total_errors,
- "counts": state_counts(state),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "updatedAt": state.get("updatedAt"),
+ "日志类型": "步骤反馈",
+ "步骤": STEP_NUMBER_LABELS.get(step, step),
+ "步骤名称": STEP_LABELS.get(step, step),
+ "状态": STATUS_LABELS.get(final_status, final_status),
+ "执行状态": status_label(final_status),
+ "备注": note,
+ "本步骤新增错误数": new_errors,
+ "累计错误数": total_errors,
+ "累计入库数量": state_counts(state),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "更新时间": state.get("updatedAt"),
}
- print("STEP_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["step"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def finish_step(
@@ -1960,6 +2512,8 @@ def finish_step(
status: str | None = None,
note: str = "",
) -> None:
+ # Always persist before emitting step feedback so the user can inspect files
+ # immediately after a reported step completes.
state["updatedAt"] = utc_now()
save_json_file(ctx.state_path, state)
if ctx.summary_path:
@@ -1980,6 +2534,11 @@ def skip_step_with_feedback(ctx: FlowContext, state: dict[str, Any], step: str,
def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Legacy full runner.
+
+ The wrapper script is preferred for real-time logs, but this remains useful
+ for dry-runs and unattended local troubleshooting.
+ """
run_step_with_feedback(ctx, state, "step1")
run_step_with_feedback(ctx, state, "step2")
run_step_with_feedback(ctx, state, "step3")
@@ -1995,6 +2554,7 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
def write_summary(path: Path, state: dict[str, Any]) -> None:
+ """Write a human-readable checkpoint beside the machine state JSON."""
audience_vuln_count = sum(len(v) for v in (state.get("audienceVulns") or {}).values())
pathway_vuln_count = sum(len(v) for v in (state.get("pathwayVulns") or {}).values())
lines = [
@@ -2005,6 +2565,8 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
f"- actionId:{state.get('actionId')}",
f"- 受众入库:{len(state.get('topAudiences') or [])}",
f"- 传播节点入库:{len(state.get('topPathways') or [])}",
+ f"- 下游受众选择:{len(state.get('selectedDownstreamAudiences') or [])}",
+ f"- 下游传播节点选择:{len(state.get('selectedDownstreamPathways') or [])}",
f"- 受众脆弱点入库:{audience_vuln_count}",
f"- 传播节点脆弱点入库:{pathway_vuln_count}",
f"- 受众策略入库:{len(state.get('audienceStrategies') or [])}",
@@ -2021,9 +2583,9 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
if state.get("errors"):
lines += ["", "## 失败明细"]
for err in state["errors"]:
- lines.append(f"- [{err.get('stage')}] {err.get('message')} status={err.get('status')} url={err.get('url')}")
+ lines.append(f"- [{stage_label(err.get('stage'))}] {err.get('message')} 状态码={err.get('status')} 接口={err.get('url')}")
if err.get("response"):
- lines.append(f" - response: {err.get('response')}")
+ lines.append(f" - 响应摘要:{err.get('response')}")
text = "\n".join(lines) + "\n"
try:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -2034,34 +2596,34 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
def build_arg_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(description="Run xdfx analysis/storage flow.")
+ parser = argparse.ArgumentParser(description="运行 xdfx 分析与入库流程。")
parser.add_argument("--mode", choices=["full", *STEP_FUNCS.keys()], default="full")
- parser.add_argument("--input", help="JSON config file. Keys: api_base, taskId, actionId, mubiao, direction, platform, headers.")
- parser.add_argument("--state", default="xdfx-flow-state.json", help="State JSON path for resume and step handoff.")
- parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown summary output path.")
+ parser.add_argument("--input", help="JSON 配置文件。常用字段:api_base、taskId、actionId、mubiao、direction、platform、headers。")
+ parser.add_argument("--state", default="xdfx-flow-state.json", help="用于断点续跑和步骤交接的状态 JSON 文件路径。")
+ parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown 执行摘要输出路径。")
parser.add_argument("--api-base")
parser.add_argument("--task-id")
parser.add_argument("--action-id")
parser.add_argument("--mubiao")
parser.add_argument("--direction", default=None)
parser.add_argument("--platform", default=None)
- parser.add_argument("--auth-token", default=os.getenv("XDFX_AUTH_TOKEN"))
- parser.add_argument("--authorization", default=os.getenv("AUTHORIZATION"))
- parser.add_argument("--header", action="append", default=[], help="Extra HTTP header, format 'Key: Value'.")
- parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="Username for automatic login when Authorization is missing or stale.")
- parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="Password query parameter for automatic username login.")
- parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="Login endpoint path or absolute URL.")
- parser.add_argument("--no-auto-login", action="store_true", help="Disable automatic username login and 401 token refresh.")
- parser.add_argument("--verify-ssl", action="store_true", help="Verify HTTPS certificates. Default is disabled for internal/UAT certificates.")
+ parser.add_argument("--header", action="append", default=[], help="附加非鉴权 HTTP 请求头,格式为 'Key: Value';Authorization 会被忽略。")
+ parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="xdfx_auth 启动阶段自动登录使用的用户名。")
+ parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="登录接口 JSON 请求体中的 password。")
+ parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/auth/login"), help="登录接口路径或完整 URL。")
+ parser.add_argument("--no-auto-login", action="store_true", help="关闭 xdfx_auth 自动登录和 401 刷新。")
+ parser.add_argument("--verify-ssl", action="store_true", help="校验 HTTPS 证书。默认不校验,以兼容内网/UAT 自签证书。")
parser.add_argument("--timeout", type=float, default=90)
parser.add_argument("--retries", type=int, default=2)
parser.add_argument("--dry-run", action="store_true")
- parser.add_argument("--no-step-status", action="store_true", help="Do not update task/task-action.step after completed steps.")
- parser.add_argument("--model-retries", type=int, default=1, help="Retry model/stream endpoints when the first response is empty, timeout-like, or says the model did not respond.")
+ parser.add_argument("--no-step-status", action="store_true", help="步骤完成后不更新 task/task-action.step。")
+ parser.add_argument("--model-retries", type=int, default=1, help="模型/流式接口首次空响应、超时或提示模型无响应时的重试次数。")
parser.add_argument("--max-audiences", type=int, default=3)
parser.add_argument("--max-pathways", type=int, default=3)
parser.add_argument("--max-vulns", type=int, default=3)
parser.add_argument("--max-strategies", type=int, default=3)
+ parser.add_argument("--downstream-audiences", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库受众数量,默认 2。")
+ parser.add_argument("--downstream-pathways", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库传播节点数量,默认 2。")
return parser
@@ -2070,6 +2632,8 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
state_path = Path(args.state)
state = default_state()
+ # Load previous state first, then overlay input JSON and finally CLI flags.
+ # That ordering makes resume safe while still allowing explicit overrides.
if state_path.exists():
loaded = load_json_file(state_path)
if isinstance(loaded, dict):
@@ -2081,9 +2645,7 @@ def main(argv: list[str] | None = None) -> int:
merge_config_into_state(state, config)
input_headers = config.get("headers")
if isinstance(input_headers, dict):
- args.header.extend(f"{k}: {v}" for k, v in input_headers.items())
- args.auth_token = args.auth_token or config.get("auth_token")
- args.authorization = args.authorization or config.get("authorization")
+ args.header.extend(f"{k}: {v}" for k, v in input_headers.items() if str(k).lower() != "authorization")
if config.get("login_username") is not None:
args.login_username = str(config.get("login_username") or "")
if config.get("login_password") is not None:
@@ -2094,6 +2656,10 @@ def main(argv: list[str] | None = None) -> int:
args.no_auto_login = True
if config.get("verify_ssl") is not None:
args.verify_ssl = bool(config.get("verify_ssl"))
+ if config.get("downstream_audiences") is not None:
+ args.downstream_audiences = as_int(config.get("downstream_audiences")) or args.downstream_audiences
+ if config.get("downstream_pathways") is not None:
+ args.downstream_pathways = as_int(config.get("downstream_pathways")) or args.downstream_pathways
cli_config = {
"api_base": args.api_base,
@@ -2105,7 +2671,8 @@ def main(argv: list[str] | None = None) -> int:
}
merge_config_into_state(state, {k: v for k, v in cli_config.items() if v is not None})
api_base = state.get("api_base") or "https://ch1.b.uat.4.cn"
- headers = parse_headers(args.header, args.auth_token, args.authorization)
+ headers = parse_headers(args.header)
+ # ctx owns runtime knobs; state owns business data and resumable ids.
ctx = FlowContext(
api_base=api_base,
headers=headers,
@@ -2118,6 +2685,8 @@ def main(argv: list[str] | None = None) -> int:
max_pathways=max(args.max_pathways, 1),
max_vulns=max(args.max_vulns, 1),
max_strategies=max(args.max_strategies, 1),
+ downstream_audiences=max(args.downstream_audiences, 1),
+ downstream_pathways=max(args.downstream_pathways, 1),
sync_step_status=not args.no_step_status,
model_retries=max(args.model_retries, 0),
verify_ssl=bool(args.verify_ssl),
@@ -2131,11 +2700,17 @@ def main(argv: list[str] | None = None) -> int:
state["_summaryPath"] = str(ctx.summary_path) if ctx.summary_path else ""
state["_autoLogin"] = ctx.auto_login
state["_verifySsl"] = ctx.verify_ssl
+ state["_downstreamLimits"] = {
+ "audiences": ctx.downstream_audiences,
+ "pathways": ctx.downstream_pathways,
+ }
record_event(state, "runner", f"start mode={args.mode} dry_run={ctx.dry_run}")
try:
require_context(state, "taskId", "actionId", "mubiao")
- if ctx.auto_login and not has_authorization(ctx.headers):
- try_auto_login(ctx, state, "runner/auto-login")
+ if ctx.auto_login and not ctx.dry_run:
+ # Always acquire a token through the imported auth package before the first business call.
+ if not try_auto_login(ctx, state, "runner/initial-login", force=True):
+ raise FlowError("启动阶段获取 token 失败,已停止执行,避免无 token 调用业务接口")
if args.mode == "full":
run_full(ctx, state)
else:
@@ -2145,13 +2720,14 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(f"ERROR: {exc}", file=sys.stderr)
+ print(f"错误:{exc}", file=sys.stderr)
return 2
except Exception as exc: # noqa: BLE001
record_error(state, "runner", "未捕获异常", response=traceback.format_exc(), exception=exc)
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
+ print("未捕获异常堆栈:", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
return 1
@@ -2159,20 +2735,23 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(json.dumps(
- {
- "ok": True,
- "mode": args.mode,
- "state": str(state_path),
- "summary": str(ctx.summary_path) if ctx.summary_path else "",
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
- "errors": len(state.get("errors") or []),
- },
- ensure_ascii=False,
- ))
+ # Final JSON is intentionally compact; detailed per-step status has already
+ # been emitted as Chinese step feedback.
+ final_payload = {
+ "日志类型": "执行完成",
+ "是否成功": "是",
+ "执行模式": mode_label(args.mode),
+ "状态文件": str(state_path),
+ "摘要文件": str(ctx.summary_path) if ctx.summary_path else "",
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "已选择受众数": len(state.get("selectedDownstreamAudiences") or []),
+ "已选择传播节点数": len(state.get("selectedDownstreamPathways") or []),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
+ "错误数": len(state.get("errors") or []),
+ }
+ print(LOG_PREFIXES["done"] + " " + json.dumps(final_payload, ensure_ascii=False))
return 0
diff --git a/skills-data/xdfx-step6-pathway-strategy/SKILL.md b/skills-data/xdfx-step6-pathway-strategy/SKILL.md
index a185e631..54492394 100644
--- a/skills-data/xdfx-step6-pathway-strategy/SKILL.md
+++ b/skills-data/xdfx-step6-pathway-strategy/SKILL.md
@@ -13,27 +13,39 @@ description: xdfx 拆分流程 step6,基于关键传播节点与节点脆弱
python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
-鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
+鉴权和证书由脚本兜底处理:脚本启动时先 `import scripts/xdfx_auth.py`,自动用 `admin / ch@user` 获取 `access_token`,并在第一次业务接口调用前写入内部 Authorization;不要通过命令行传 token。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
-- 按关键传播节点读取已入库脆弱点。
+- 从已入库关键传播节点中默认选择前 2 个继续生成策略;可用 `--downstream-pathways 1` 调成只处理 1 个。
+- 对每个被选中的关键传播节点读取已入库脆弱点并默认生成最多 3 个策略。
- 调用策略生成模型接口。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 自动解析 `发布人设N / 账号类型N / 信息标题N` 等字段。
- 调用策略保存接口。
- 同步写入 `task-action-channel`、`base-narrate`、`task-action-message`、`task-action-design-strategy-relation`。
+- 保存 `task-action-message.modelType` 前会读取前端 `modelType` 字典并把“图片,视频/文本,图片”等模态文字转成 value 逗号串,避免策略表格回显为空。
- 同步前端步骤状态 `step-analysis-strategy` 与 `step-analysis-strategy-gantt`。
- 保存成功后写入 `pathwayStrategies`。
-如果某个传播节点没有可用脆弱点,脚本会跳过并在 `errors` 中记录。
+如果某个传播节点没有可用脆弱点,脚本会记录错误;智能体必须先回到 Step4 或重跑 Step6 修复当前策略环节,不能直接跳过。
+
+## 失败重试规则
+
+- 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前 Step6 命令。
+- 如果失败原因是缺少传播节点脆弱点,先重跑 Step4 生成脆弱点,再重跑 Step6;重试前必须告诉用户原因和重试动作。
+- Step6 未重试成功前,不允许做最终成功汇总;不要因为 Step6 失败就跳过传播节点策略分析。
+- Step6 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
## 步骤反馈
-命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step6 的完成状态、当前传播节点策略数量和新增错误数,再做最终汇总。
+命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step6 的完成状态、当前传播节点策略数量和新增错误数,再做最终汇总。
-执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
+执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。
-如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的策略、策略子表和返回 ID。
+
+如果出现 `错误反馈 {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
+
+如果出现 `JSON解析复核 {...}`,必须查看 `响应片段`,判断是否能识别传播节点策略 JSON;无法确认时重跑 Step6。
-如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别传播节点策略 JSON;无法确认时重跑 Step6。
diff --git a/skills-data/xdfx-step6-pathway-strategy/scripts/xdfx_auth.py b/skills-data/xdfx-step6-pathway-strategy/scripts/xdfx_auth.py
new file mode 100644
index 00000000..4003e55d
--- /dev/null
+++ b/skills-data/xdfx-step6-pathway-strategy/scripts/xdfx_auth.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+"""Authentication helper for xdfx skill scripts.
+
+The caller should not pass tokens through command-line arguments. This module
+owns the fixed admin login flow and returns a Bearer token before business
+interfaces are called.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import ssl
+import urllib.error
+import urllib.request
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass
+class AuthResult:
+ token: str
+ url: str
+ status: int | None
+ raw: str
+
+
+class AuthError(RuntimeError):
+ def __init__(
+ self,
+ message: str,
+ *,
+ url: str,
+ status: int | None = None,
+ response: str = "",
+ exception: BaseException | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.url = url
+ self.status = status
+ self.response = response
+ self.exception = exception
+
+
+def auth_header_value(token: str) -> str:
+ token = token.strip()
+ return token if token.lower().startswith("bearer ") else f"Bearer {token}"
+
+
+def redact_url(url: str) -> str:
+ return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
+
+
+def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
+ if verify_ssl:
+ return None
+ return ssl._create_unverified_context()
+
+
+def build_login_url(api_base: str, path: str) -> str:
+ return path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
+
+
+def extract_access_token(data: Any) -> str | None:
+ if isinstance(data, dict):
+ for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
+ value = data.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ for value in data.values():
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ elif isinstance(data, list):
+ for value in data:
+ nested = extract_access_token(value)
+ if nested:
+ return nested
+ return None
+
+
+def parse_json_loose(text: str) -> Any:
+ try:
+ return json.loads(text)
+ except Exception:
+ pass
+ match = re.search(r"[\[{]", text or "")
+ if not match:
+ return {}
+ start = match.start()
+ for end in range(len(text), start, -1):
+ try:
+ return json.loads(text[start:end])
+ except Exception:
+ continue
+ return {}
+
+
+def fetch_access_token(
+ *,
+ api_base: str,
+ username: str = "admin",
+ password: str = "ch@user",
+ login_path: str = "/api/auth/login",
+ timeout: float = 90,
+ verify_ssl: bool = False,
+) -> AuthResult:
+ """Login once and return the extracted access token."""
+ url = build_login_url(api_base, login_path)
+ payload = {"username": username, "password": password}
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ parsed = parse_json_loose(raw)
+ token = extract_access_token(parsed)
+ if not token:
+ raise AuthError(
+ "登录接口已响应但未找到 access_token",
+ url=redact_url(url),
+ status=getattr(resp, "status", None),
+ response=raw,
+ )
+ return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
+ raise AuthError(
+ "登录接口返回错误",
+ url=redact_url(url),
+ status=exc.code,
+ response=body,
+ exception=exc,
+ ) from exc
+ except AuthError:
+ raise
+ except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
+ raise AuthError(
+ "登录接口调用异常",
+ url=redact_url(url),
+ response=str(exc),
+ exception=exc,
+ ) from exc
diff --git a/skills-data/xdfx-step6-pathway-strategy/scripts/xdfx_flow.py b/skills-data/xdfx-step6-pathway-strategy/scripts/xdfx_flow.py
index 52880e82..d9580e2f 100644
--- a/skills-data/xdfx-step6-pathway-strategy/scripts/xdfx_flow.py
+++ b/skills-data/xdfx-step6-pathway-strategy/scripts/xdfx_flow.py
@@ -26,7 +26,10 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
+import xdfx_auth
+
+# Keep every backend path in one table so endpoint drift is easy to audit.
ENDPOINTS = {
"aiuser": "/gpt/chat/chat_aiuser",
"group": "/gpt/chat/chat_qianqian_group_chat",
@@ -44,10 +47,12 @@ ENDPOINTS = {
"save_narrate": "/api/base/base-narrate",
"save_message": "/api/task/task-action-message",
"save_strategy_relation": "/api/task/task-action-design-strategy-relation",
+ "dict_data": "/api/sys/sys-dict-data",
}
+# Human-readable names are used only in the Chinese feedback logs.
ENDPOINT_LABELS = {
- "login": "登录获取访问 token",
+ "login": "登录获取访问令牌",
"aiuser": "调用候选账号/受众分析模型接口",
"group": "调用受众目标结构化抽取接口",
"spread": "调用关键传播节点结构化抽取接口",
@@ -64,8 +69,90 @@ ENDPOINT_LABELS = {
"save_narrate": "保存策略信息主题",
"save_message": "保存策略消息配置",
"save_strategy_relation": "保存策略与受众/传播节点关系",
+ "dict_data": "读取前端字典数据",
}
+LOG_PREFIXES = {
+ "operation": "操作反馈",
+ "error": "错误反馈",
+ "json_review": "JSON解析复核",
+ "save": "入库反馈",
+ "step": "步骤反馈",
+ "done": "执行完成",
+}
+
+STATUS_LABELS = {
+ "ok": "成功",
+ "partial": "部分完成",
+ "skipped": "已跳过",
+}
+
+STEP_NUMBER_LABELS = {
+ "step1": "第一步",
+ "step2": "第二步",
+ "step3": "第三步",
+ "step4": "第四步",
+ "step5": "第五步",
+ "step6": "第六步",
+}
+
+HTTP_METHOD_LABELS = {
+ "GET": "读取(GET)",
+ "POST": "提交(POST)",
+ "PUT": "更新(PUT)",
+ "PATCH": "局部更新(PATCH)",
+ "DELETE": "删除(DELETE)",
+}
+
+STAGE_SEGMENT_LABELS = {
+ "analyze": "模型分析",
+ "extract": "结构化抽取",
+ "save": "保存数据",
+ "read": "读取数据",
+ "status": "同步状态",
+}
+
+REQUEST_FIELD_LABELS = {
+ "history": "历史消息",
+ "max_tokens": "最大生成令牌数",
+ "model_name": "模型名称",
+ "otherinfo": "其他信息",
+ "prompt_name": "提示词名称",
+ "stream": "是否流式",
+ "temperature": "温度",
+ "usermessage": "用户消息",
+ "user_message": "用户消息",
+ "username": "登录用户名",
+ "password": "登录密码",
+ "mubiao": "行动目标",
+ "zhanghao": "账号列表",
+ "taskId": "任务ID",
+ "actionId": "行动ID",
+ "taskActionId": "行动ID",
+ "designId": "受众ID",
+ "pathwayId": "传播节点ID",
+ "strategyId": "策略ID",
+ "accountId": "账号ID",
+ "accountName": "账号名称",
+ "accountType": "账号类型",
+ "targetAudience": "受众目标",
+ "vulnerabilities": "脆弱点",
+ "topic": "信息标题",
+ "narrate": "叙事主题",
+ "platform": "平台",
+ "persona": "发布人设",
+ "modelType": "信息形式",
+ "quantity": "数量",
+ "publishTime": "发布时间段",
+ "reason": "理由",
+ "ids": "ID列表",
+ "id": "ID",
+ "step": "步骤状态",
+}
+
+SENSITIVE_KEY_PARTS = ("password", "token", "authorization", "access_token", "accesstoken", "authtoken")
+
+# Platform values mirror the frontend dictionaries used by xdfx strategy pages.
PLATFORM_MAP = {
"facebook": 1,
"fb": 1,
@@ -78,6 +165,8 @@ PLATFORM_MAP = {
"yt": 4,
}
+# The model service sometimes returns a successful HTTP response with these
+# strings instead of useful content. Treat them like transient failures.
MODEL_RETRY_MARKERS = (
"y模型没有响应",
"模型没有响应",
@@ -118,6 +207,12 @@ class FlowError(RuntimeError):
@dataclass
class FlowContext:
+ """Runtime options shared by every step.
+
+ Keep this object boring and serializable-ish: it is passed through every
+ helper, while durable progress belongs in the state JSON file.
+ """
+
api_base: str
headers: dict[str, str]
timeout: float
@@ -129,13 +224,15 @@ class FlowContext:
max_pathways: int = 3
max_vulns: int = 3
max_strategies: int = 3
+ downstream_audiences: int = 2
+ downstream_pathways: int = 2
sync_step_status: bool = True
model_retries: int = 1
verify_ssl: bool = False
auto_login: bool = True
login_username: str = "admin"
login_password: str = "ch@user"
- login_path: str = "/api/v1/auth/login/username"
+ login_path: str = "/api/auth/login"
login_attempt_count: int = 0
@@ -153,7 +250,38 @@ def compact(value: Any, limit: int = 1400) -> str:
text = repr(value)
if len(text) <= limit:
return text
- return text[:limit] + f"... "
+ return text[:limit] + f"... <已截断 {len(text) - limit} 个字符>"
+
+
+def is_sensitive_key(key: Any) -> bool:
+ normalized = re.sub(r"[^a-z0-9_]", "", str(key or "").lower())
+ return any(part in normalized for part in SENSITIVE_KEY_PARTS)
+
+
+def redact_sensitive(value: Any) -> Any:
+ if isinstance(value, dict):
+ return {key: "***" if is_sensitive_key(key) else redact_sensitive(item) for key, item in value.items()}
+ if isinstance(value, list):
+ return [redact_sensitive(item) for item in value]
+ if isinstance(value, tuple):
+ return tuple(redact_sensitive(item) for item in value)
+ if isinstance(value, str):
+ text = re.sub(r"(?i)(Bearer\s+)[^\s\",}]+", r"\1***", value)
+ text = re.sub(
+ r"(?i)(\"(?:password|token|authToken|access_token|accessToken|authorization)\"\s*:\s*)\"[^\"]*\"",
+ r'\1"***"',
+ text,
+ )
+ return re.sub(
+ r"(?i)((?:password|token|authToken|access_token|accessToken|authorization)=)[^&\s]+",
+ r"\1***",
+ text,
+ )
+ return value
+
+
+def safe_compact(value: Any, limit: int = 1400) -> str:
+ return compact(redact_sensitive(value), limit)
def stable_json(value: Any) -> str:
@@ -223,6 +351,11 @@ def save_json_file(path: str | Path, value: Any) -> None:
def default_state() -> dict[str, Any]:
+ """Create a resumable state container.
+
+ The script can be run as six independent commands. IDs returned by one step
+ are persisted here so later steps can resume without redoing earlier saves.
+ """
return {
"api_base": "",
"taskId": "",
@@ -247,6 +380,7 @@ def default_state() -> dict[str, Any]:
def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> None:
+ # Accept both frontend-style camelCase and CLI-friendly snake_case inputs.
aliases = {
"task_id": "taskId",
"action_id": "actionId",
@@ -260,16 +394,12 @@ def merge_config_into_state(state: dict[str, Any], config: dict[str, Any]) -> No
continue
if dest in {"taskId", "actionId"}:
state[dest] = str(value)
- elif dest in state or dest in {
- "api_base",
- "headers",
- "auth_token",
- "authorization",
- }:
+ elif dest in state or dest in {"api_base", "headers"}:
state[dest] = value
def record_event(state: dict[str, Any], stage: str, message: str, data: Any | None = None) -> None:
+ """Append a compact diagnostic event to the state file."""
state.setdefault("events", []).append(
{
"time": utc_now(),
@@ -286,9 +416,13 @@ def feedback_step(stage: str) -> str:
return match.group(1) if match else ""
+def feedback_step_label(stage: str) -> str:
+ return STEP_NUMBER_LABELS.get(feedback_step(stage), "")
+
+
def request_keys(payload: Any) -> list[str]:
if isinstance(payload, dict):
- return sorted(str(key) for key in payload.keys())
+ return sorted(REQUEST_FIELD_LABELS.get(str(key), str(key)) for key in payload.keys())
return []
@@ -306,6 +440,77 @@ def endpoint_label(endpoint_key: str | None, method: str, url: str) -> str:
return f"调用接口 {method}"
+def method_label(method: str) -> str:
+ return HTTP_METHOD_LABELS.get(str(method).upper(), str(method))
+
+
+def yes_no(value: bool) -> str:
+ return "是" if value else "否"
+
+
+def stage_label(stage: Any) -> str:
+ text = str(stage or "")
+ if not text:
+ return ""
+ if text in STEP_LABELS:
+ return STEP_LABELS[text]
+ if text == "runner":
+ return "流程执行器"
+ parts = text.split("/")
+ base = parts[0]
+ match = re.search(r"(step[1-6])", base)
+ labels: list[str] = []
+ if match:
+ labels.append(STEP_LABELS.get(match.group(1), match.group(1)))
+ elif base == "runner":
+ labels.append("流程执行器")
+ else:
+ labels.append(base)
+ for segment in parts[1:]:
+ normalized = segment.replace("-", "_")
+ attempt = re.fullmatch(r"model-call-attempt-(\d+)", segment)
+ json_attempt = re.fullmatch(r"model-json-attempt-(\d+)", segment)
+ if attempt:
+ labels.append(f"模型接口第 {attempt.group(1)} 次尝试")
+ elif json_attempt:
+ labels.append(f"JSON解析第 {json_attempt.group(1)} 次尝试")
+ elif segment == "auto-login":
+ labels.append("自动登录")
+ elif segment in STAGE_SEGMENT_LABELS:
+ labels.append(STAGE_SEGMENT_LABELS[segment])
+ elif normalized in ENDPOINT_LABELS:
+ labels.append(ENDPOINT_LABELS[normalized])
+ else:
+ labels.append(segment)
+ return " / ".join(labels)
+
+
+def status_label(status: Any) -> str:
+ if status is None:
+ return ""
+ return STATUS_LABELS.get(str(status), str(status))
+
+
+def mode_label(mode: str) -> str:
+ if mode == "full":
+ return "全流程"
+ return STEP_LABELS.get(mode, mode)
+
+
+def exception_type_label(exception_type: Any) -> str:
+ name = str(exception_type or "")
+ labels = {
+ "HTTPError": "HTTP 调用异常",
+ "URLError": "网络地址异常",
+ "TimeoutError": "请求超时异常",
+ "FlowError": "流程异常",
+ "ValueError": "数据格式异常",
+ "KeyError": "字段缺失异常",
+ "RuntimeError": "运行异常",
+ }
+ return labels.get(name, "程序异常" if name else "")
+
+
def emit_operation_feedback(
state: dict[str, Any],
stage: str,
@@ -317,39 +522,41 @@ def emit_operation_feedback(
attempt: int = 1,
dry_run: bool = False,
) -> None:
+ # Feedback lines are a mini protocol consumed by the calling agent. They
+ # must stay single-line JSON so weak models/tools can relay them reliably.
payload_obj = {
- "type": "OP_FEEDBACK",
- "step": feedback_step(stage),
- "stage": stage,
- "operation": endpoint_label(endpoint_key, method, url),
- "method": method,
- "url": url,
- "endpoint": endpoint_key or "",
- "attempt": attempt,
- "dryRun": dry_run,
- "requestKeys": request_keys(payload),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "操作反馈",
+ "步骤": feedback_step_label(stage),
+ "阶段": stage_label(stage),
+ "操作": endpoint_label(endpoint_key, method, url),
+ "请求方式": method_label(method),
+ "接口地址": url,
+ "接口名称": endpoint_label(endpoint_key, method, url),
+ "第几次尝试": attempt,
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("OP_FEEDBACK " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["operation"] + " " + json.dumps(payload_obj, ensure_ascii=False), flush=True)
def emit_error_feedback(error: dict[str, Any]) -> None:
payload = {
- "type": "ERROR_FEEDBACK",
- "stage": error.get("stage"),
- "step": feedback_step(str(error.get("stage") or "")),
- "message": error.get("message"),
- "url": error.get("url"),
- "status": error.get("status"),
- "request": error.get("request"),
- "response": error.get("response"),
- "exceptionType": error.get("exceptionType"),
- "exception": error.get("exception"),
- "time": error.get("time"),
+ "日志类型": "错误反馈",
+ "阶段": stage_label(error.get("stage")),
+ "步骤": feedback_step_label(str(error.get("stage") or "")),
+ "错误信息": error.get("message"),
+ "接口地址": error.get("url"),
+ "状态码": error.get("status"),
+ "请求摘要": error.get("request"),
+ "响应摘要": error.get("response"),
+ "异常类型": exception_type_label(error.get("exceptionType")),
+ "异常信息": error.get("exception"),
+ "时间": error.get("time"),
}
- print("ERROR_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["error"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def emit_json_parse_review(
@@ -360,18 +567,89 @@ def emit_json_parse_review(
candidate_count: int,
) -> None:
payload = {
- "type": "JSON_PARSE_REVIEW",
- "stage": stage,
- "step": feedback_step(stage),
- "endpoint": endpoint_key,
- "message": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
- "candidateCount": candidate_count,
- "responseExcerpt": compact(text, 3000),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "time": utc_now(),
+ "日志类型": "JSON解析复核",
+ "阶段": stage_label(stage),
+ "步骤": feedback_step_label(stage),
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, endpoint_key),
+ "提示": "模型输出经过多候选提取和修复后仍无法解析为可用 JSON,请执行智能体复核是否需要重新调用本步骤接口。",
+ "候选数量": candidate_count,
+ "响应片段": compact(text, 3000),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
}
- print("JSON_PARSE_REVIEW " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["json_review"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
+
+
+SAVE_ENTITY_LABELS = {
+ "save_design": "受众目标",
+ "save_design_account": "受众目标账号",
+ "save_pathway": "关键传播节点",
+ "save_relation": "受众-传播节点关系",
+ "save_design_vuln": "受众脆弱点",
+ "save_pathway_vuln": "传播节点脆弱点",
+ "save_strategy": "策略",
+ "save_channel": "策略平台与人设",
+ "save_narrate": "策略信息主题",
+ "save_message": "策略消息配置",
+ "save_strategy_relation": "策略关联关系",
+}
+
+
+def saved_entity_name(endpoint_key: str, payload: dict[str, Any]) -> str:
+ """Return the most user-readable name for an inserted row."""
+ candidates = [
+ payload.get("targetAudience"),
+ payload.get("accountName"),
+ payload.get("vulnerabilities"),
+ payload.get("topic"),
+ payload.get("narrate"),
+ payload.get("platform"),
+ payload.get("strategyId"),
+ payload.get("designId"),
+ payload.get("pathwayId"),
+ ]
+ for value in candidates:
+ if value is not None and str(value).strip():
+ return str(value).strip()
+ return endpoint_key
+
+
+def emit_save_feedback(
+ state: dict[str, Any],
+ stage: str,
+ endpoint_key: str,
+ payload: dict[str, Any],
+ row_id: int,
+ *,
+ dry_run: bool,
+) -> None:
+ # Emit only after an id is known. Downstream steps use these ids and should
+ # fail loudly rather than inventing relations against missing rows.
+ label = SAVE_ENTITY_LABELS.get(endpoint_key, endpoint_key)
+ name = saved_entity_name(endpoint_key, payload)
+ feedback = {
+ "日志类型": "入库反馈",
+ "步骤": feedback_step(stage),
+ "阶段": stage,
+ "入库对象类型": label,
+ "入库对象名称": name,
+ "返回ID": row_id,
+ "接口名称": ENDPOINT_LABELS.get(endpoint_key, label),
+ "接口路径": ENDPOINTS.get(endpoint_key, ""),
+ "是否演练": yes_no(dry_run),
+ "请求字段": request_keys(payload),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "时间": utc_now(),
+ }
+ print(LOG_PREFIXES["save"] + " " + json.dumps(feedback, ensure_ascii=False), flush=True)
+ record_event(
+ state,
+ stage,
+ f"入库{label}成功:{name}",
+ {"id": row_id, "endpoint": endpoint_key, "dryRun": dry_run},
+ )
def record_error(
@@ -385,16 +663,18 @@ def record_error(
response: Any | None = None,
exception: BaseException | None = None,
) -> None:
+ # Persist full-enough error context for post-run diagnosis, then immediately
+ # emit a short line the agent can show to the user.
item = {
"time": utc_now(),
"stage": stage,
"message": message,
"url": url,
"status": status,
- "request": compact(request, 1200) if request is not None else None,
- "response": compact(response, 2000) if response is not None else None,
+ "request": safe_compact(request, 1200) if request is not None else None,
+ "response": safe_compact(response, 2000) if response is not None else None,
"exceptionType": type(exception).__name__ if exception else None,
- "exception": str(exception) if exception else None,
+ "exception": safe_compact(str(exception), 1000) if exception else None,
}
state.setdefault("errors", []).append(item)
state["updatedAt"] = utc_now()
@@ -430,69 +710,29 @@ def url_for_path(ctx: FlowContext, path: str) -> str:
return ctx.api_base.rstrip("/") + "/" + path.lstrip("/")
-def parse_headers(header_values: list[str], auth_token: str | None, authorization: str | None) -> dict[str, str]:
+def parse_headers(header_values: list[str]) -> dict[str, str]:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
- if auth_token:
- headers["Authorization"] = auth_token if auth_token.lower().startswith("bearer ") else f"Bearer {auth_token}"
- if authorization:
- headers["Authorization"] = authorization
for raw in header_values:
if ":" not in raw:
raise FlowError(f"header 格式错误,应为 Key: Value,实际:{raw}")
key, value = raw.split(":", 1)
- headers[key.strip()] = value.strip()
+ normalized = key.strip()
+ if normalized.lower() == "authorization":
+ continue
+ headers[normalized] = value.strip()
return headers
-def has_authorization(headers: dict[str, str]) -> bool:
- return any(key.lower() == "authorization" and str(value).strip() for key, value in headers.items())
-
-
def ssl_context(ctx: FlowContext) -> ssl.SSLContext | None:
if ctx.verify_ssl:
return None
return ssl._create_unverified_context()
-def auth_header_value(token: str) -> str:
- token = token.strip()
- return token if token.lower().startswith("bearer ") else f"Bearer {token}"
-
-
-def extract_access_token(data: Any) -> str | None:
- if isinstance(data, dict):
- for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
- value = data.get(key)
- if isinstance(value, str) and value.strip():
- return value.strip()
- for value in data.values():
- nested = extract_access_token(value)
- if nested:
- return nested
- elif isinstance(data, list):
- for value in data:
- nested = extract_access_token(value)
- if nested:
- return nested
- return None
-
-
-def redact_url(url: str) -> str:
- return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
-
-
-def build_login_url(ctx: FlowContext, path: str) -> str:
- base = path if re.match(r"^https?://", path, flags=re.IGNORECASE) else url_for_path(ctx, path)
- if ctx.login_password:
- separator = "&" if "?" in base else "?"
- base = f"{base}{separator}password={urllib.parse.quote(ctx.login_password, safe='')}"
- return base
-
-
def login_candidate_paths(ctx: FlowContext) -> list[str]:
paths = [
- ctx.login_path or "/api/v1/auth/login/username",
- "/api/v1/auth/login/username",
+ ctx.login_path or "/api/auth/login",
+ "/api/auth/login",
]
seen: set[str] = set()
unique: list[str] = []
@@ -506,6 +746,7 @@ def login_candidate_paths(ctx: FlowContext) -> list[str]:
def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force: bool = False) -> bool:
+ """Fetch a fresh token through the local auth package before business calls."""
if not ctx.auto_login:
return False
if ctx.dry_run:
@@ -515,18 +756,17 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
record_error(state, stage, "自动登录失败:缺少 login_username")
return False
if not force and ctx.login_attempt_count > 0:
- return has_authorization(ctx.headers)
+ return any(key.lower() == "authorization" and str(value).strip() for key, value in ctx.headers.items())
if force and ctx.login_attempt_count >= 2:
return False
ctx.login_attempt_count += 1
- payload = {"username": ctx.login_username}
+ payload = {"username": ctx.login_username, "password": ctx.login_password}
last_status: int | None = None
last_response = ""
last_exc: BaseException | None = None
for attempt, path in enumerate(login_candidate_paths(ctx), start=1):
- url = build_login_url(ctx, path)
- display_url = redact_url(url)
+ display_url = xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, path))
emit_operation_feedback(
state,
stage,
@@ -537,63 +777,39 @@ def try_auto_login(ctx: FlowContext, state: dict[str, Any], stage: str, *, force
attempt=attempt,
)
try:
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- headers = {
- "Content-Type": "application/json",
- "Accept": "application/json",
- }
- req = urllib.request.Request(url, data=data, headers=headers, method="POST")
- with urllib.request.urlopen(req, timeout=ctx.timeout, context=ssl_context(ctx)) as resp:
- raw = resp.read().decode("utf-8", errors="replace")
- parsed = parse_json_lenient(raw)
- token = extract_access_token(parsed)
- if not token:
- last_status = getattr(resp, "status", None)
- last_response = raw
- record_event(
- state,
- stage,
- "登录接口已响应但未找到 access_token",
- {"url": display_url, "status": last_status, "response": compact(raw, 1000)},
- )
- continue
- ctx.headers["Authorization"] = auth_header_value(token)
- record_event(
- state,
- stage,
- "自动登录成功,已写入 Authorization Bearer token",
- {
- "url": display_url,
- "status": getattr(resp, "status", None),
- "username": ctx.login_username,
- },
- )
- return True
- except urllib.error.HTTPError as exc:
- last_status = exc.code
- last_response = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
- last_exc = exc
- record_event(
- state,
- stage,
- "登录接口返回错误,准备尝试下一个候选登录路径",
- {"url": display_url, "status": exc.code, "response": compact(last_response, 1000)},
+ result = xdfx_auth.fetch_access_token(
+ api_base=ctx.api_base,
+ username=ctx.login_username,
+ password=ctx.login_password,
+ login_path=path,
+ timeout=ctx.timeout,
+ verify_ssl=ctx.verify_ssl,
)
- except Exception as exc: # noqa: BLE001 - auto-login must report every failure.
- last_exc = exc
- last_response = traceback.format_exc()
+ ctx.headers["Authorization"] = xdfx_auth.auth_header_value(result.token)
+ state["_tokenSource"] = "xdfx_auth"
record_event(
state,
stage,
- "登录接口调用异常,准备尝试下一个候选登录路径",
- {"url": display_url, "exceptionType": type(exc).__name__, "exception": str(exc)},
+ "启动阶段已通过 xdfx_auth 包获取 token,并写入 Authorization",
+ {"url": result.url, "status": result.status, "username": ctx.login_username},
+ )
+ return True
+ except xdfx_auth.AuthError as exc:
+ last_status = exc.status
+ last_response = exc.response
+ last_exc = exc
+ record_event(
+ state,
+ stage,
+ "xdfx_auth 获取 token 失败,准备尝试下一个候选登录路径",
+ {"url": exc.url, "status": exc.status, "response": safe_compact(exc.response, 1000), "exception": str(exc)},
)
record_error(
state,
stage,
- "自动登录失败,未能获取 access_token",
- url=redact_url(build_login_url(ctx, ctx.login_path or "/api/v1/auth/login/username")),
+ "自动登录失败,未能通过 xdfx_auth 获取 access_token",
+ url=xdfx_auth.redact_url(xdfx_auth.build_login_url(ctx.api_base, ctx.login_path or "/api/auth/login")),
status=last_status,
request=payload,
response=last_response,
@@ -612,6 +828,7 @@ def http_request_json_url(
endpoint_key: str | None = None,
allow_auto_login: bool = True,
) -> tuple[Any | None, str]:
+ """Call an HTTP JSON endpoint with logging, retries, and 401 recovery."""
method = method.upper()
if ctx.dry_run:
emit_operation_feedback(state, stage, method, url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
@@ -640,6 +857,7 @@ def http_request_json_url(
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
if exc.code == 401 and allow_auto_login and ctx.auto_login:
+ # Refresh once on 401, then replay the original request exactly.
refreshed = try_auto_login(ctx, state, f"{stage}/auto-login", force=True)
if refreshed:
record_event(
@@ -709,6 +927,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
emit_operation_feedback(state, stage, "POST", url, endpoint_key=endpoint_key, payload=payload, dry_run=True)
record_event(state, stage, f"DRY RUN POST {url}", {"payload": payload})
if endpoint_key.startswith("save_"):
+ # Synthetic ids let downstream dry-run code exercise relation wiring.
synthetic_id = abs(hash(stage + stable_json(payload))) % 100000000
return {"id": synthetic_id, "dryRun": True}, json.dumps({"id": synthetic_id, "dryRun": True})
return {"dryRun": True}, json.dumps({"dryRun": True})
@@ -716,6 +935,7 @@ def http_post_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, p
def parse_json_lenient(text: str) -> Any | None:
+ """Parse normal JSON plus adjacent/SSE-like JSON objects."""
stripped = text.strip()
if not stripped:
return None
@@ -730,6 +950,7 @@ def parse_json_lenient(text: str) -> Any | None:
def parse_concatenated_json_objects(text: str) -> list[Any]:
+ """Read streams such as {"answer": "..."}{"answer": "..."} or data: lines."""
decoder = json.JSONDecoder()
objects: list[Any] = []
i = 0
@@ -766,6 +987,7 @@ def parse_concatenated_json_objects(text: str) -> list[Any]:
def model_text_from_response(parsed: Any, raw: str) -> tuple[str, list[Any]]:
+ """Flatten streamed model chunks into one text buffer."""
objects: list[Any]
if isinstance(parsed, list):
objects = parsed
@@ -824,6 +1046,7 @@ def extract_json_from_text(text: str) -> Any | None:
def extract_json_candidates_from_text(text: str) -> list[Any]:
+ """Collect every plausible JSON object/array from messy model text."""
cleaned = strip_think(text)
candidates: list[Any] = []
seen: set[str] = set()
@@ -860,6 +1083,7 @@ def extract_json_candidates_from_text(text: str) -> list[Any]:
def select_json_candidate(candidates: list[Any], endpoint_key: str, stage: str) -> Any | None:
+ """Pick the candidate whose keys best match the current step."""
if not candidates:
return None
scored = [(json_candidate_score(candidate, endpoint_key, stage), idx, candidate) for idx, candidate in enumerate(candidates)]
@@ -1010,6 +1234,7 @@ def extract_first_balanced(text: str, open_ch: str, close_ch: str) -> str | None
def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[str, list[Any], Any | None]:
+ """Call a model endpoint and retry only known empty/timeout/no-response cases."""
max_attempts = max(ctx.model_retries, 0) + 1
last_text = ""
last_objects: list[Any] = []
@@ -1048,6 +1273,11 @@ def call_model_text(ctx: FlowContext, state: dict[str, Any], endpoint_key: str,
def call_model_json(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[dict[str, Any], str, list[Any]]:
+ """Return the best structured JSON for a model step.
+
+ If the first parse fails, the second attempt includes an explicit repair
+ instruction so weak models can re-emit only the required JSON.
+ """
last_text = ""
last_objects: list[Any] = []
last_candidate_count = 0
@@ -1202,23 +1432,85 @@ def profile_info(account: dict[str, Any]) -> str:
def parse_index_list(value: Any) -> list[str]:
- if value is None:
- return []
- if isinstance(value, list):
- return [str(v).strip() for v in value if str(v).strip()]
- text = str(value)
- return [part.strip().strip("<>").strip() for part in re.split(r"[,,;;、\s]+", text) if part.strip()]
+ refs: list[str] = []
+
+ def add(raw: Any) -> None:
+ if raw is None:
+ return
+ if isinstance(raw, dict):
+ keys = (
+ "编号id",
+ "编号ID",
+ "用户编号id",
+ "用户编号ID",
+ "用户编号",
+ "账号编号",
+ "引用序号",
+ "索引",
+ "index",
+ "id",
+ "ids",
+ "accountIndex",
+ "accountIndexes",
+ "userIndex",
+ "userIndexes",
+ )
+ for key in keys:
+ if key in raw:
+ add(raw.get(key))
+ return
+ if isinstance(raw, (list, tuple, set)):
+ for item in raw:
+ add(item)
+ return
+ text = str(raw).strip()
+ if not text:
+ return
+ angle_refs = re.findall(r"<<\s*([^<>]+?)\s*>>", text)
+ if angle_refs:
+ for token in angle_refs:
+ add(token)
+ return
+ for part in re.split(r"[,,;;、\s]+", text):
+ token = part.strip().strip("<>").strip()
+ if token:
+ refs.append(token)
+
+ add(value)
+ deduped: list[str] = []
+ seen: set[str] = set()
+ for ref in refs:
+ if ref in seen:
+ continue
+ seen.add(ref)
+ deduped.append(ref)
+ return deduped
+
+
+def account_ref_numbers(account: dict[str, Any]) -> set[int]:
+ numbers: set[int] = set()
+ meta = account.get("metadata") if isinstance(account.get("metadata"), dict) else {}
+ for key in ("编号id", "编号ID", "用户编号id", "用户编号ID", "用户编号", "账号编号", "引用序号", "索引", "index", "accountIndex"):
+ for container in (account, meta):
+ number = as_int(container.get(key)) if isinstance(container, dict) else None
+ if number is not None:
+ numbers.add(number)
+ return numbers
def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, Any] | None:
+ """Resolve model references like <<3>>, index numbers, names, or account ids."""
if not accounts:
return None
text = str(ref).strip().strip("<>").strip()
number = as_int(text)
if number is not None:
for account in accounts:
- if any(as_int(account.get(key)) == number for key in ("引用序号", "索引", "index")):
+ if number in account_ref_numbers(account):
return account
+ # Step1 group extraction returns 编号/id values that refer to the
+ # previous zhanghao array index. Prefer exact index fields above, then
+ # fall back to zero-based and one-based array positions.
if 0 <= number < len(accounts):
return accounts[number]
if 1 <= number <= len(accounts):
@@ -1232,11 +1524,25 @@ def resolve_account_ref(ref: Any, accounts: list[dict[str, Any]]) -> dict[str, A
def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Normalize audience JSON whether the model used numbered fields or items[]."""
items: list[dict[str, Any]] = []
for i in range(1, 21):
group = first_present(data.get(f"群体{i}"), data.get(f"受众{i}"), data.get(f"targetAudience{i}"))
reason = first_present(data.get(f"理由{i}"), data.get(f"推荐理由{i}"), data.get(f"recReason{i}"))
- users = first_present(data.get(f"用户编号列表{i}"), data.get(f"用户列表{i}"), data.get(f"账号编号列表{i}"))
+ users = first_present(
+ data.get(f"用户编号列表{i}"),
+ data.get(f"编号id列表{i}"),
+ data.get(f"编号ID列表{i}"),
+ data.get(f"用户编号id列表{i}"),
+ data.get(f"用户编号ID列表{i}"),
+ data.get(f"用户列表{i}"),
+ data.get(f"账号编号列表{i}"),
+ data.get(f"索引列表{i}"),
+ data.get(f"编号{i}"),
+ data.get(f"id{i}"),
+ data.get(f"ids{i}"),
+ data.get(f"索引{i}"),
+ )
if group:
items.append(
{
@@ -1255,7 +1561,27 @@ def extract_audiences(data: dict[str, Any]) -> list[dict[str, Any]]:
{
"targetAudience": str(group).strip(),
"recReason": str(first_present(item.get("理由"), item.get("recReason"), item.get("reason")) or "").strip(),
- "userIndexes": parse_index_list(first_present(item.get("用户编号列表"), item.get("users"), item.get("accounts"))),
+ "userIndexes": parse_index_list(
+ first_present(
+ item.get("用户编号列表"),
+ item.get("编号id列表"),
+ item.get("编号ID列表"),
+ item.get("用户编号id列表"),
+ item.get("用户编号ID列表"),
+ item.get("编号id"),
+ item.get("编号ID"),
+ item.get("用户编号id"),
+ item.get("用户编号ID"),
+ item.get("编号"),
+ item.get("索引"),
+ item.get("id"),
+ item.get("ids"),
+ item.get("users"),
+ item.get("accounts"),
+ item.get("userIndexes"),
+ item.get("accountIndexes"),
+ )
+ ),
"rank": i,
}
)
@@ -1272,6 +1598,7 @@ def audience_score(item: dict[str, Any]) -> tuple[int, int]:
def select_top_audiences(items: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
+ """Deduplicate audience names and keep the strongest candidates."""
unique: dict[str, dict[str, Any]] = {}
for item in items:
key = item.get("targetAudience", "")
@@ -1301,6 +1628,7 @@ def pathway_score(item: dict[str, Any]) -> tuple[int, int, int]:
def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Merge the same account found from different audiences before saving top 3."""
merged: dict[str, dict[str, Any]] = {}
for item in candidates:
account = item.get("account") or {}
@@ -1325,6 +1653,7 @@ def merge_pathway_candidates(candidates: list[dict[str, Any]]) -> list[dict[str,
def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
+ """Normalize weak-point model output into a capped list."""
vulns: list[dict[str, str]] = []
for i in range(1, 21):
vuln = first_present(data.get(f"脆弱点{i}"), data.get(f"vulnerability{i}"), data.get(f"vuln{i}"))
@@ -1339,6 +1668,7 @@ def extract_vulns(data: dict[str, Any], limit: int) -> list[dict[str, str]]:
def extract_strategies(data: dict[str, Any], limit: int) -> list[dict[str, Any]]:
+ """Normalize strategy output and preserve the modal text for later mapping."""
strategies: list[dict[str, Any]] = []
for i in range(1, 21):
title = first_present(data.get(f"信息标题{i}"), data.get(f"主题{i}"), data.get(f"topic{i}"))
@@ -1395,11 +1725,29 @@ def response_id(response: Any) -> int | None:
return None
+def response_rows(response: Any) -> list[Any]:
+ """Extract list rows from common CRUD response shapes."""
+ if isinstance(response, list):
+ return response
+ if not isinstance(response, dict):
+ return []
+ for key in ("data", "rows", "items", "records", "list"):
+ value = response.get(key)
+ if isinstance(value, list):
+ return value
+ rows = response_rows(value)
+ if rows:
+ return rows
+ return []
+
+
def save_with_id(ctx: FlowContext, state: dict[str, Any], endpoint_key: str, payload: dict[str, Any], stage: str) -> tuple[int | None, Any | None]:
response, _ = http_post_json(ctx, state, endpoint_key, payload, stage)
rid = response_id(response)
if rid is None:
- record_error(state, stage, "保存接口未返回 id,视为失败", request=payload, response=response)
+ record_error(state, stage, "保存接口未返回 ID,视为失败", request=payload, response=response)
+ else:
+ emit_save_feedback(state, stage, endpoint_key, payload, rid, dry_run=ctx.dry_run)
return rid, response
@@ -1429,6 +1777,7 @@ def parse_step_flags(raw: Any) -> dict[str, Any]:
def mark_step_status(ctx: FlowContext, state: dict[str, Any], links: list[str], stage: str) -> None:
+ """Update the frontend step bar without overwriting unrelated step flags."""
if not ctx.sync_step_status:
record_event(state, stage, "跳过 task-action.step 状态同步")
return
@@ -1477,12 +1826,126 @@ def strategy_platform(value: Any) -> Any:
return platform_to_number(value) or 1
-def strategy_model_type(value: Any) -> str:
- # Frontend defaults to text model type "1" when no modal type is selected.
- raw = first_present(value, "1")
- if isinstance(raw, list):
- return ",".join(str(v) for v in raw if str(v).strip()) or "1"
- return str(raw or "1")
+def choice_tokens(value: Any) -> list[Any]:
+ """Split frontend choice values from arrays, JSON strings, and mixed delimiters."""
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return choice_tokens(
+ first_present(value.get("value"), value.get("dictValue"), value.get("label"), value.get("dictLabel"))
+ )
+ if isinstance(value, (list, tuple, set)):
+ tokens: list[Any] = []
+ for item in value:
+ tokens.extend(choice_tokens(item))
+ return tokens
+ text = str(value).strip()
+ if not text:
+ return []
+ if text[0] in "[{":
+ try:
+ parsed = json.loads(text)
+ if parsed != value:
+ return choice_tokens(parsed)
+ except Exception:
+ pass
+ return [part.strip() for part in re.split(r"[,,、;/|]+", text) if part.strip()]
+
+
+def dict_choices(
+ ctx: FlowContext,
+ state: dict[str, Any],
+ dict_type: str,
+ stage: str,
+) -> list[dict[str, Any]]:
+ """Fetch and cache frontend dictionary choices such as modelType."""
+ cache = state.setdefault("_dictChoiceCache", {})
+ if dict_type in cache:
+ cached = cache[dict_type]
+ return cached if isinstance(cached, list) else []
+ if ctx.dry_run:
+ cache[dict_type] = []
+ return []
+
+ query = urllib.parse.urlencode({"filter": f"dictType||$eq||{dict_type}", "limit": 999, "page": 1})
+ url = f"{url_for(ctx, 'dict_data')}?{query}"
+ response, _ = http_request_json_url(ctx, state, "GET", url, None, stage, endpoint_key="dict_data")
+ choices: list[dict[str, Any]] = []
+ for row in response_rows(response):
+ if not isinstance(row, dict):
+ continue
+ label = str(first_present(row.get("dictLabel"), row.get("label"), "") or "").strip()
+ raw_value = first_present(row.get("dictValue"), row.get("value"))
+ if not label or raw_value is None:
+ continue
+ number = as_int(raw_value)
+ choices.append({"label": label, "value": number if number is not None else str(raw_value).strip()})
+ for alias in choice_tokens(row.get("alias")):
+ alias_label = str(alias).strip()
+ if alias_label:
+ choices.append({"label": alias_label, "value": number if number is not None else str(raw_value).strip()})
+ cache[dict_type] = choices
+ return choices
+
+
+def choice_value_by_label(choices: list[dict[str, Any]] | None, label: str) -> Any | None:
+ needle = re.sub(r"\s+", "", str(label or "").lower())
+ if not needle:
+ return None
+ for item in choices or []:
+ item_label = re.sub(r"\s+", "", str(item.get("label") or "").lower())
+ if item_label and (needle == item_label or needle in item_label or item_label in needle):
+ return item.get("value")
+ return None
+
+
+def model_type_token_value(token: Any, choices: list[dict[str, Any]] | None = None) -> Any:
+ """Convert modal labels like 图片/视频 into frontend dictionary values."""
+ number = as_int(token)
+ if number is not None:
+ return number
+
+ text = str(token or "").strip()
+ lower = text.lower()
+ exact = choice_value_by_label(choices, text)
+ if exact is not None:
+ return exact
+
+ label = ""
+ fallback_value = 2
+ if "视" in text or "video" in lower:
+ label, fallback_value = "视频", 3
+ elif "图" in text or "image" in lower or "picture" in lower or "photo" in lower:
+ label, fallback_value = "图片", 2
+ elif "音" in text or "audio" in lower or "sound" in lower:
+ label, fallback_value = "音频", 4
+ elif "发文" in text or "文本" in text or "文字" in text or "text" in lower:
+ label, fallback_value = "文本", 1
+
+ if label:
+ matched = choice_value_by_label(choices, label)
+ if matched is not None:
+ return matched
+ return fallback_value
+
+ # Mirrors the frontend helper's unknown-token default.
+ return 2
+
+
+def strategy_model_type(value: Any, choices: list[dict[str, Any]] | None = None) -> str:
+ """Return the comma-separated value string expected by task-action-message."""
+ tokens = choice_tokens(value)
+ if not tokens:
+ # Frontend save paths fall back to "1" when no modal type is selected.
+ return "1"
+ values: list[str] = []
+ seen: set[str] = set()
+ for token in tokens:
+ normalized = str(model_type_token_value(token, choices)).strip()
+ if normalized and normalized not in seen:
+ values.append(normalized)
+ seen.add(normalized)
+ return ",".join(values) or "1"
def strategy_language(value: Any) -> int:
@@ -1501,11 +1964,28 @@ def save_strategy_details(
*,
audience: bool,
) -> None:
- platform = strategy_platform(first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})))
+ """Save all frontend-readable strategy child rows.
+
+ The strategy table and arrangement page do not read only the main strategy
+ row; they also expect channel, narrate, message, and relation rows.
+ """
+ platform = strategy_platform(
+ first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
+ )
+ account_type = str(
+ first_present(
+ strategy.get("accountType"),
+ item.get("accountType"),
+ item.get("type"),
+ "目标受众账号" if audience else "关键传播节点账号",
+ )
+ or ""
+ ).strip()
persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
title = str(strategy.get("title") or "").strip()
+ model_type_choices = dict_choices(ctx, state, "modelType", stage + "/dict-modelType")
channel_id, _ = save_with_id(
ctx,
@@ -1516,6 +1996,7 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"platform": platform,
+ "accountType": account_type,
"personaJson": json.dumps([{"persona": persona, "size": persona_size}], ensure_ascii=False),
},
stage + "/save-channel",
@@ -1531,7 +2012,10 @@ def save_strategy_details(
"actionId": as_int(state["actionId"]) or state["actionId"],
"strategyId": strategy_id,
"channelId": channel_id,
- "modelType": strategy_model_type(first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType"))),
+ "modelType": strategy_model_type(
+ first_present(strategy.get("format"), strategy.get("信息形式"), strategy.get("modelType")),
+ model_type_choices,
+ ),
"language": strategy_language(first_present(strategy.get("language"), strategy.get("语种"))),
"respondingCount": responding_count,
"messageCount": responding_count,
@@ -1564,7 +2048,46 @@ def object_for_audience(audience: dict[str, Any]) -> dict[str, Any]:
}
+def selected_accounts_for_item(item: dict[str, Any], *, audience: bool) -> list[Any]:
+ if audience:
+ accounts = item.get("accounts") or []
+ return accounts or [object_for_audience(item)]
+ return [item.get("account") or item]
+
+
+def downstream_limit(ctx: FlowContext, *, audience: bool) -> int:
+ """Limit expensive downstream analysis while keeping 3 upstream rows stored."""
+ raw = ctx.downstream_audiences if audience else ctx.downstream_pathways
+ return max(1, raw)
+
+
+def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, audience: bool) -> list[dict[str, Any]]:
+ """Select the 1-2 best stored rows for vulnerability/strategy generation."""
+ source_key = "topAudiences" if audience else "topPathways"
+ id_key = "designId" if audience else "pathwayAccountId"
+ selected_key = "selectedDownstreamAudiences" if audience else "selectedDownstreamPathways"
+ items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)]
+ limit = downstream_limit(ctx, audience=audience)
+ selected = items[:limit]
+ state[selected_key] = [
+ {
+ "id": item.get(id_key),
+ "name": item.get("targetAudience") if audience else item.get("accountName"),
+ }
+ for item in selected
+ ]
+ if items:
+ record_event(
+ state,
+ stage,
+ f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}",
+ {"limit": limit, "selected": state[selected_key]},
+ )
+ return selected
+
+
def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze audiences, save top 3, and attach any referenced accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step1-audience"
if state.get("topAudiences") and not ctx.dry_run:
@@ -1582,12 +2105,15 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
state["audienceRawText"] = text
+ # The first model call may return raw text plus embedded account objects.
accounts = accounts_from_response(parsed, objects, text)
state["audienceAccounts"] = accounts
+ # The second model call converts the long audience analysis into numbered
+ # groups that can be saved as task-action-design rows.
group_payload = {
"mubiao": state["mubiao"],
- "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
"zhanghao": accounts,
"history": [],
"stream": True,
@@ -1602,6 +2128,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
top = select_top_audiences(candidates, ctx.max_audiences)
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the audience first; account rows and downstream analyses depend
+ # on the returned designId.
design_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
@@ -1614,6 +2142,8 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
continue
item = {**item, "designId": design_id, "saveResponse": resp, "accounts": [], "savedAccountIds": []}
for ref in item.get("userIndexes") or []:
+ # Model references are intentionally fuzzy, so resolve them against
+ # both explicit indexes and account names/ids.
account = resolve_account_ref(ref, accounts)
if account is None:
record_error(state, stage + "/save-design-account", f"用户编号 {ref} 未匹配到候选账号")
@@ -1645,6 +2175,7 @@ def run_step1(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Analyze key pathway accounts for saved audiences and store top 3."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step2-pathway"
audiences = [a for a in state.get("topAudiences") or [] if a.get("designId")]
@@ -1656,6 +2187,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
return
all_candidates: list[dict[str, Any]] = []
for audience in audiences:
+ # Analyze pathway candidates in the context of each saved audience.
payload = {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请分析哪些社交账号是你本次任务的核心传播节点,通过撬动该账号可实现扩大传播影响力的目的。可以从账号粉丝量、发文内容互动量、所在领域的影响力、账号画像与你传播议题的贴合度等角度分析。",
"otherinfo": {
@@ -1673,6 +2205,8 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
}
text, objects, parsed = call_model_text(ctx, state, "aiuser", payload, stage + "/analyze")
accounts = accounts_from_response(parsed, objects, text)
+ # Ask the model to point back to candidate accounts, then resolve those
+ # references to concrete account objects.
spread_payload = {
"mubiao": state["mubiao"],
"user_message": "分析其中关键信息。请从候选账号中抽取核心传播节点,字段必须是:账号1、理由1、账号2、理由2 ...,最多输出5个。账号字段优先返回候选数组的引用编号或索引,只返回JSON。",
@@ -1707,6 +2241,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None:
top = sorted(merged, key=pathway_score, reverse=True)[: ctx.max_pathways]
saved: list[dict[str, Any]] = []
for item in top:
+ # Save the account row before writing the design-pathway relation.
account = item["account"]
aid = account_id(account)
name = account_name(account)
@@ -1757,18 +2292,20 @@ def run_step4(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate and save weak points for selected audiences or pathway accounts."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step3-audience-vulns" if audience else "step4-pathway-vulns"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
output_key = "audienceVulns" if audience else "pathwayVulns"
state.setdefault(output_key, {})
for item in items:
+ # owner_id is designId for audiences and pathwayAccountId for accounts.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
if not owner_id:
continue
if str(owner_id) in state.get(output_key, {}) and state[output_key][str(owner_id)] and not ctx.dry_run:
continue
- zhanghao = item.get("accounts") or [object_for_audience(item)] if audience else [item.get("account") or item]
+ zhanghao = selected_accounts_for_item(item, audience=audience)
prompt = (
"你是中国对台湾方向的认知战专家,你要传播行动要求的叙事主题。请基于该受众目标的画像、关注议题、社会分工定位、利益诉求与上一轮账号佐证,分析该受众目标有哪些脆弱点可以成为后续策略设计的关键要素。请输出理由、脆弱点1、理由1、脆弱点2、理由2、脆弱点3、理由3,只返回JSON。"
if audience
@@ -1789,6 +2326,8 @@ def run_vuln_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) ->
vulns = extract_vulns(data, ctx.max_vulns)
saved: list[dict[str, Any]] = []
for vuln in vulns:
+ # The two weak-point tables have nearly identical payloads but
+ # different foreign-key names.
if audience:
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
@@ -1835,14 +2374,16 @@ def run_step6(ctx: FlowContext, state: dict[str, Any]) -> None:
def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool) -> None:
+ """Generate strategies from saved weak points and persist frontend child rows."""
require_context(state, "taskId", "actionId", "mubiao")
stage = "step5-audience-strategy" if audience else "step6-pathway-strategy"
- items = state.get("topAudiences" if audience else "topPathways") or []
+ items = downstream_items(ctx, state, stage, audience=audience)
vulns_by_owner = state.get("audienceVulns" if audience else "pathwayVulns") or {}
output_key = "audienceStrategies" if audience else "pathwayStrategies"
state.setdefault(output_key, [])
existing_topics = {s.get("topic") for s in state[output_key] if isinstance(s, dict)}
for item in items:
+ # Strategies are only meaningful after the matching weak points exist.
owner_id = item.get("designId") if audience else item.get("pathwayAccountId")
vulns = vulns_by_owner.get(str(owner_id)) or []
vuln_texts = [v.get("vulnerabilities") for v in vulns if v.get("vulnerabilities")]
@@ -1850,7 +2391,9 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
record_error(state, stage, "没有可用脆弱点,跳过策略生成", request=item)
continue
target_names = [item.get("targetAudience")] if audience else (item.get("relatedAudiences") or [item.get("accountName")])
- pingtai = state.get("platform") or (platform_label(item.get("account") or {}) if not audience else "")
+ zhanghao = selected_accounts_for_item(item, audience=audience)
+ first_account = zhanghao[0] if zhanghao and isinstance(zhanghao[0], dict) else {}
+ pingtai = state.get("platform") or platform_label(first_account)
payload = {
"user_message": DEFAULT_STRATEGY_PROMPT_AUDIENCE if audience else DEFAULT_STRATEGY_PROMPT_PATHWAY,
"cuiruodian": vuln_texts,
@@ -1858,6 +2401,7 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
"shouzhong": [name for name in target_names if name],
"mudi": extract_goal(state["mubiao"]),
"heiliao": [],
+ "zhanghao": zhanghao,
"history": [],
"stream": True,
"model_name": "OpenAI3",
@@ -1876,16 +2420,20 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
continue
if topic in existing_topics:
continue
+ # Save the main row first; channel/narrate/message/relation rows all
+ # require the returned strategy id.
save_payload = {
"taskId": as_int(state["taskId"]) or state["taskId"],
"actionId": as_int(state["actionId"]) or state["actionId"],
"targetAudience": target,
"accountType": account_type,
"topic": topic,
- "strategyTitle": "",
+ "strategyTitle": str(strategy.get("title") or "").strip(),
}
strategy_id, resp = save_with_id(ctx, state, "save_strategy", save_payload, stage + "/save")
if strategy_id is not None:
+ # Frontend strategy pages read these child tables, so a strategy
+ # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience)
existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp})
@@ -1914,13 +2462,14 @@ STEP_LABELS = {
def state_counts(state: dict[str, Any]) -> dict[str, int]:
+ """Summarize stored objects for step feedback."""
return {
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceVulns": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
- "pathwayVulns": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "受众脆弱点数量": sum(len(v) for v in (state.get("audienceVulns") or {}).values()),
+ "传播节点脆弱点数量": sum(len(v) for v in (state.get("pathwayVulns") or {}).values()),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
}
@@ -1932,23 +2481,26 @@ def emit_step_feedback(
status: str | None = None,
note: str = "",
) -> None:
+ # One feedback line per step keeps the single-skill and split-skill versions
+ # equally transparent to the user.
total_errors = len(state.get("errors") or [])
new_errors = max(0, total_errors - before_error_count)
final_status = status or ("partial" if new_errors else "ok")
payload = {
- "type": "STEP_FEEDBACK",
- "step": step,
- "label": STEP_LABELS.get(step, step),
- "status": final_status,
- "note": note,
- "newErrors": new_errors,
- "totalErrors": total_errors,
- "counts": state_counts(state),
- "state": state.get("_statePath", ""),
- "summary": state.get("_summaryPath", ""),
- "updatedAt": state.get("updatedAt"),
+ "日志类型": "步骤反馈",
+ "步骤": STEP_NUMBER_LABELS.get(step, step),
+ "步骤名称": STEP_LABELS.get(step, step),
+ "状态": STATUS_LABELS.get(final_status, final_status),
+ "执行状态": status_label(final_status),
+ "备注": note,
+ "本步骤新增错误数": new_errors,
+ "累计错误数": total_errors,
+ "累计入库数量": state_counts(state),
+ "状态文件": state.get("_statePath", ""),
+ "摘要文件": state.get("_summaryPath", ""),
+ "更新时间": state.get("updatedAt"),
}
- print("STEP_FEEDBACK " + json.dumps(payload, ensure_ascii=False), flush=True)
+ print(LOG_PREFIXES["step"] + " " + json.dumps(payload, ensure_ascii=False), flush=True)
def finish_step(
@@ -1960,6 +2512,8 @@ def finish_step(
status: str | None = None,
note: str = "",
) -> None:
+ # Always persist before emitting step feedback so the user can inspect files
+ # immediately after a reported step completes.
state["updatedAt"] = utc_now()
save_json_file(ctx.state_path, state)
if ctx.summary_path:
@@ -1980,6 +2534,11 @@ def skip_step_with_feedback(ctx: FlowContext, state: dict[str, Any], step: str,
def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
+ """Legacy full runner.
+
+ The wrapper script is preferred for real-time logs, but this remains useful
+ for dry-runs and unattended local troubleshooting.
+ """
run_step_with_feedback(ctx, state, "step1")
run_step_with_feedback(ctx, state, "step2")
run_step_with_feedback(ctx, state, "step3")
@@ -1995,6 +2554,7 @@ def run_full(ctx: FlowContext, state: dict[str, Any]) -> None:
def write_summary(path: Path, state: dict[str, Any]) -> None:
+ """Write a human-readable checkpoint beside the machine state JSON."""
audience_vuln_count = sum(len(v) for v in (state.get("audienceVulns") or {}).values())
pathway_vuln_count = sum(len(v) for v in (state.get("pathwayVulns") or {}).values())
lines = [
@@ -2005,6 +2565,8 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
f"- actionId:{state.get('actionId')}",
f"- 受众入库:{len(state.get('topAudiences') or [])}",
f"- 传播节点入库:{len(state.get('topPathways') or [])}",
+ f"- 下游受众选择:{len(state.get('selectedDownstreamAudiences') or [])}",
+ f"- 下游传播节点选择:{len(state.get('selectedDownstreamPathways') or [])}",
f"- 受众脆弱点入库:{audience_vuln_count}",
f"- 传播节点脆弱点入库:{pathway_vuln_count}",
f"- 受众策略入库:{len(state.get('audienceStrategies') or [])}",
@@ -2021,9 +2583,9 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
if state.get("errors"):
lines += ["", "## 失败明细"]
for err in state["errors"]:
- lines.append(f"- [{err.get('stage')}] {err.get('message')} status={err.get('status')} url={err.get('url')}")
+ lines.append(f"- [{stage_label(err.get('stage'))}] {err.get('message')} 状态码={err.get('status')} 接口={err.get('url')}")
if err.get("response"):
- lines.append(f" - response: {err.get('response')}")
+ lines.append(f" - 响应摘要:{err.get('response')}")
text = "\n".join(lines) + "\n"
try:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -2034,34 +2596,34 @@ def write_summary(path: Path, state: dict[str, Any]) -> None:
def build_arg_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(description="Run xdfx analysis/storage flow.")
+ parser = argparse.ArgumentParser(description="运行 xdfx 分析与入库流程。")
parser.add_argument("--mode", choices=["full", *STEP_FUNCS.keys()], default="full")
- parser.add_argument("--input", help="JSON config file. Keys: api_base, taskId, actionId, mubiao, direction, platform, headers.")
- parser.add_argument("--state", default="xdfx-flow-state.json", help="State JSON path for resume and step handoff.")
- parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown summary output path.")
+ parser.add_argument("--input", help="JSON 配置文件。常用字段:api_base、taskId、actionId、mubiao、direction、platform、headers。")
+ parser.add_argument("--state", default="xdfx-flow-state.json", help="用于断点续跑和步骤交接的状态 JSON 文件路径。")
+ parser.add_argument("--summary", default="xdfx-flow-summary.md", help="Markdown 执行摘要输出路径。")
parser.add_argument("--api-base")
parser.add_argument("--task-id")
parser.add_argument("--action-id")
parser.add_argument("--mubiao")
parser.add_argument("--direction", default=None)
parser.add_argument("--platform", default=None)
- parser.add_argument("--auth-token", default=os.getenv("XDFX_AUTH_TOKEN"))
- parser.add_argument("--authorization", default=os.getenv("AUTHORIZATION"))
- parser.add_argument("--header", action="append", default=[], help="Extra HTTP header, format 'Key: Value'.")
- parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="Username for automatic login when Authorization is missing or stale.")
- parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="Password query parameter for automatic username login.")
- parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/v1/auth/login/username"), help="Login endpoint path or absolute URL.")
- parser.add_argument("--no-auto-login", action="store_true", help="Disable automatic username login and 401 token refresh.")
- parser.add_argument("--verify-ssl", action="store_true", help="Verify HTTPS certificates. Default is disabled for internal/UAT certificates.")
+ parser.add_argument("--header", action="append", default=[], help="附加非鉴权 HTTP 请求头,格式为 'Key: Value';Authorization 会被忽略。")
+ parser.add_argument("--login-username", default=os.getenv("XDFX_LOGIN_USERNAME", "admin"), help="xdfx_auth 启动阶段自动登录使用的用户名。")
+ parser.add_argument("--login-password", default=os.getenv("XDFX_LOGIN_PASSWORD", "ch@user"), help="登录接口 JSON 请求体中的 password。")
+ parser.add_argument("--login-path", default=os.getenv("XDFX_LOGIN_PATH", "/api/auth/login"), help="登录接口路径或完整 URL。")
+ parser.add_argument("--no-auto-login", action="store_true", help="关闭 xdfx_auth 自动登录和 401 刷新。")
+ parser.add_argument("--verify-ssl", action="store_true", help="校验 HTTPS 证书。默认不校验,以兼容内网/UAT 自签证书。")
parser.add_argument("--timeout", type=float, default=90)
parser.add_argument("--retries", type=int, default=2)
parser.add_argument("--dry-run", action="store_true")
- parser.add_argument("--no-step-status", action="store_true", help="Do not update task/task-action.step after completed steps.")
- parser.add_argument("--model-retries", type=int, default=1, help="Retry model/stream endpoints when the first response is empty, timeout-like, or says the model did not respond.")
+ parser.add_argument("--no-step-status", action="store_true", help="步骤完成后不更新 task/task-action.step。")
+ parser.add_argument("--model-retries", type=int, default=1, help="模型/流式接口首次空响应、超时或提示模型无响应时的重试次数。")
parser.add_argument("--max-audiences", type=int, default=3)
parser.add_argument("--max-pathways", type=int, default=3)
parser.add_argument("--max-vulns", type=int, default=3)
parser.add_argument("--max-strategies", type=int, default=3)
+ parser.add_argument("--downstream-audiences", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库受众数量,默认 2。")
+ parser.add_argument("--downstream-pathways", type=int, default=2, help="继续进入脆弱点/策略步骤的已入库传播节点数量,默认 2。")
return parser
@@ -2070,6 +2632,8 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
state_path = Path(args.state)
state = default_state()
+ # Load previous state first, then overlay input JSON and finally CLI flags.
+ # That ordering makes resume safe while still allowing explicit overrides.
if state_path.exists():
loaded = load_json_file(state_path)
if isinstance(loaded, dict):
@@ -2081,9 +2645,7 @@ def main(argv: list[str] | None = None) -> int:
merge_config_into_state(state, config)
input_headers = config.get("headers")
if isinstance(input_headers, dict):
- args.header.extend(f"{k}: {v}" for k, v in input_headers.items())
- args.auth_token = args.auth_token or config.get("auth_token")
- args.authorization = args.authorization or config.get("authorization")
+ args.header.extend(f"{k}: {v}" for k, v in input_headers.items() if str(k).lower() != "authorization")
if config.get("login_username") is not None:
args.login_username = str(config.get("login_username") or "")
if config.get("login_password") is not None:
@@ -2094,6 +2656,10 @@ def main(argv: list[str] | None = None) -> int:
args.no_auto_login = True
if config.get("verify_ssl") is not None:
args.verify_ssl = bool(config.get("verify_ssl"))
+ if config.get("downstream_audiences") is not None:
+ args.downstream_audiences = as_int(config.get("downstream_audiences")) or args.downstream_audiences
+ if config.get("downstream_pathways") is not None:
+ args.downstream_pathways = as_int(config.get("downstream_pathways")) or args.downstream_pathways
cli_config = {
"api_base": args.api_base,
@@ -2105,7 +2671,8 @@ def main(argv: list[str] | None = None) -> int:
}
merge_config_into_state(state, {k: v for k, v in cli_config.items() if v is not None})
api_base = state.get("api_base") or "https://ch1.b.uat.4.cn"
- headers = parse_headers(args.header, args.auth_token, args.authorization)
+ headers = parse_headers(args.header)
+ # ctx owns runtime knobs; state owns business data and resumable ids.
ctx = FlowContext(
api_base=api_base,
headers=headers,
@@ -2118,6 +2685,8 @@ def main(argv: list[str] | None = None) -> int:
max_pathways=max(args.max_pathways, 1),
max_vulns=max(args.max_vulns, 1),
max_strategies=max(args.max_strategies, 1),
+ downstream_audiences=max(args.downstream_audiences, 1),
+ downstream_pathways=max(args.downstream_pathways, 1),
sync_step_status=not args.no_step_status,
model_retries=max(args.model_retries, 0),
verify_ssl=bool(args.verify_ssl),
@@ -2131,11 +2700,17 @@ def main(argv: list[str] | None = None) -> int:
state["_summaryPath"] = str(ctx.summary_path) if ctx.summary_path else ""
state["_autoLogin"] = ctx.auto_login
state["_verifySsl"] = ctx.verify_ssl
+ state["_downstreamLimits"] = {
+ "audiences": ctx.downstream_audiences,
+ "pathways": ctx.downstream_pathways,
+ }
record_event(state, "runner", f"start mode={args.mode} dry_run={ctx.dry_run}")
try:
require_context(state, "taskId", "actionId", "mubiao")
- if ctx.auto_login and not has_authorization(ctx.headers):
- try_auto_login(ctx, state, "runner/auto-login")
+ if ctx.auto_login and not ctx.dry_run:
+ # Always acquire a token through the imported auth package before the first business call.
+ if not try_auto_login(ctx, state, "runner/initial-login", force=True):
+ raise FlowError("启动阶段获取 token 失败,已停止执行,避免无 token 调用业务接口")
if args.mode == "full":
run_full(ctx, state)
else:
@@ -2145,13 +2720,14 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(f"ERROR: {exc}", file=sys.stderr)
+ print(f"错误:{exc}", file=sys.stderr)
return 2
except Exception as exc: # noqa: BLE001
record_error(state, "runner", "未捕获异常", response=traceback.format_exc(), exception=exc)
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
+ print("未捕获异常堆栈:", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
return 1
@@ -2159,20 +2735,23 @@ def main(argv: list[str] | None = None) -> int:
save_json_file(state_path, state)
if ctx.summary_path:
write_summary(ctx.summary_path, state)
- print(json.dumps(
- {
- "ok": True,
- "mode": args.mode,
- "state": str(state_path),
- "summary": str(ctx.summary_path) if ctx.summary_path else "",
- "audiences": len(state.get("topAudiences") or []),
- "pathways": len(state.get("topPathways") or []),
- "audienceStrategies": len(state.get("audienceStrategies") or []),
- "pathwayStrategies": len(state.get("pathwayStrategies") or []),
- "errors": len(state.get("errors") or []),
- },
- ensure_ascii=False,
- ))
+ # Final JSON is intentionally compact; detailed per-step status has already
+ # been emitted as Chinese step feedback.
+ final_payload = {
+ "日志类型": "执行完成",
+ "是否成功": "是",
+ "执行模式": mode_label(args.mode),
+ "状态文件": str(state_path),
+ "摘要文件": str(ctx.summary_path) if ctx.summary_path else "",
+ "受众数量": len(state.get("topAudiences") or []),
+ "传播节点数量": len(state.get("topPathways") or []),
+ "已选择受众数": len(state.get("selectedDownstreamAudiences") or []),
+ "已选择传播节点数": len(state.get("selectedDownstreamPathways") or []),
+ "受众策略数量": len(state.get("audienceStrategies") or []),
+ "传播节点策略数量": len(state.get("pathwayStrategies") or []),
+ "错误数": len(state.get("errors") or []),
+ }
+ print(LOG_PREFIXES["done"] + " " + json.dumps(final_payload, ensure_ascii=False))
return 0
diff --git a/xdfx-auth-index-check-state.json b/xdfx-auth-index-check-state.json
new file mode 100644
index 00000000..98090243
--- /dev/null
+++ b/xdfx-auth-index-check-state.json
@@ -0,0 +1,80 @@
+{
+ "api_base": "https://ch1.b.uat.4.cn",
+ "taskId": "1",
+ "actionId": "2",
+ "mubiao": "索引映射验证",
+ "direction": "U方向",
+ "platform": "",
+ "audienceRawText": "{\"dryRun\": true}",
+ "audienceCandidates": [],
+ "topAudiences": [],
+ "pathwayCandidates": [],
+ "topPathways": [],
+ "audienceVulns": {},
+ "pathwayVulns": {},
+ "audienceStrategies": [],
+ "pathwayStrategies": [],
+ "errors": [],
+ "events": [
+ {
+ "time": "2026-06-29T08:49:10.660975+00:00",
+ "stage": "runner",
+ "message": "start mode=step1 dry_run=True",
+ "data": null
+ },
+ {
+ "time": "2026-06-29T08:49:10.662968+00:00",
+ "stage": "step1-audience/analyze/model-call-attempt-1",
+ "message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_aiuser",
+ "data": {
+ "payload": {
+ "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。",
+ "otherinfo": {
+ "方向": "U方向"
+ },
+ "history": [],
+ "stream": true,
+ "model_name": "OpenAI3",
+ "temperature": 0.7,
+ "max_tokens": 20000,
+ "prompt_name": "任务背景和目的:索引映射验证"
+ }
+ }
+ },
+ {
+ "time": "2026-06-29T08:49:10.668998+00:00",
+ "stage": "step1-audience/extract/model-json-attempt-1/model-call-attempt-1",
+ "message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_qianqian_group_chat",
+ "data": {
+ "payload": {
+ "mubiao": "索引映射验证",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
+ "zhanghao": [],
+ "history": [],
+ "stream": true,
+ "model_name": "OpenAI3",
+ "temperature": 0.7,
+ "max_tokens": 20000,
+ "prompt_name": "索引映射验证"
+ }
+ }
+ },
+ {
+ "time": "2026-06-29T08:49:10.668998+00:00",
+ "stage": "step1-audience",
+ "message": "完成,保存受众 0 个",
+ "data": null
+ }
+ ],
+ "startedAt": "2026-06-29T08:49:10.659972+00:00",
+ "updatedAt": "2026-06-29T08:49:10.674982+00:00",
+ "_statePath": "C:\\tmp\\xdfx-auth-index-check-state.json",
+ "_summaryPath": "C:\\tmp\\xdfx-auth-index-check-summary.md",
+ "_autoLogin": true,
+ "_verifySsl": false,
+ "_downstreamLimits": {
+ "audiences": 2,
+ "pathways": 2
+ },
+ "audienceAccounts": []
+}
\ No newline at end of file
diff --git a/xdfx-auth-index-check-summary.md b/xdfx-auth-index-check-summary.md
new file mode 100644
index 00000000..85b459cb
--- /dev/null
+++ b/xdfx-auth-index-check-summary.md
@@ -0,0 +1,18 @@
+# xdfx 分析入库执行摘要
+
+- 更新时间:2026-06-29T08:49:10.674982+00:00
+- taskId:1
+- actionId:2
+- 受众入库:0
+- 传播节点入库:0
+- 下游受众选择:0
+- 下游传播节点选择:0
+- 受众脆弱点入库:0
+- 传播节点脆弱点入库:0
+- 受众策略入库:0
+- 传播节点策略入库:0
+- 错误数:0
+
+## 受众
+
+## 传播节点
diff --git a/xdfx-log-check-state.json b/xdfx-log-check-state.json
new file mode 100644
index 00000000..1ad0e726
--- /dev/null
+++ b/xdfx-log-check-state.json
@@ -0,0 +1,88 @@
+{
+ "api_base": "https://ch1.b.uat.4.cn",
+ "taskId": "1",
+ "actionId": "2",
+ "mubiao": "日志中文化验证",
+ "direction": "U方向",
+ "platform": "",
+ "audienceRawText": "{\"dryRun\": true}",
+ "audienceCandidates": [],
+ "topAudiences": [],
+ "pathwayCandidates": [],
+ "topPathways": [],
+ "audienceVulns": {},
+ "pathwayVulns": {},
+ "audienceStrategies": [],
+ "pathwayStrategies": [],
+ "errors": [],
+ "events": [
+ {
+ "time": "2026-06-29T07:16:23.252171+00:00",
+ "stage": "runner",
+ "message": "start mode=step1 dry_run=True",
+ "data": null
+ },
+ {
+ "time": "2026-06-29T07:16:23.252171+00:00",
+ "stage": "runner/auto-login",
+ "message": "dry-run 跳过自动登录",
+ "data": {
+ "username": "admin"
+ }
+ },
+ {
+ "time": "2026-06-29T07:16:23.253180+00:00",
+ "stage": "step1-audience/analyze/model-call-attempt-1",
+ "message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_aiuser",
+ "data": {
+ "payload": {
+ "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。",
+ "otherinfo": {
+ "方向": "U方向"
+ },
+ "history": [],
+ "stream": true,
+ "model_name": "OpenAI3",
+ "temperature": 0.7,
+ "max_tokens": 20000,
+ "prompt_name": "任务背景和目的:日志中文化验证"
+ }
+ }
+ },
+ {
+ "time": "2026-06-29T07:16:23.261131+00:00",
+ "stage": "step1-audience/extract/model-json-attempt-1/model-call-attempt-1",
+ "message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_qianqian_group_chat",
+ "data": {
+ "payload": {
+ "mubiao": "日志中文化验证",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "zhanghao": [],
+ "history": [],
+ "stream": true,
+ "model_name": "OpenAI3",
+ "temperature": 0.7,
+ "max_tokens": 20000,
+ "prompt_name": "日志中文化验证"
+ }
+ }
+ },
+ {
+ "time": "2026-06-29T07:16:23.261131+00:00",
+ "stage": "step1-audience",
+ "message": "完成,保存受众 0 个",
+ "data": null
+ }
+ ],
+ "startedAt": "2026-06-29T07:16:23.252171+00:00",
+ "updatedAt": "2026-06-29T07:16:23.266115+00:00",
+ "_statePath": "C:\\tmp\\xdfx-log-check-state.json",
+ "_summaryPath": "C:\\tmp\\xdfx-log-check-summary.md",
+ "_autoLogin": true,
+ "_verifySsl": false,
+ "_downstreamLimits": {
+ "audiences": 2,
+ "pathways": 2
+ },
+ "audienceAccounts": []
+}
\ No newline at end of file
diff --git a/xdfx-log-check-summary.md b/xdfx-log-check-summary.md
new file mode 100644
index 00000000..add40f51
--- /dev/null
+++ b/xdfx-log-check-summary.md
@@ -0,0 +1,18 @@
+# xdfx 分析入库执行摘要
+
+- 更新时间:2026-06-29T07:16:23.266115+00:00
+- taskId:1
+- actionId:2
+- 受众入库:0
+- 传播节点入库:0
+- 下游受众选择:0
+- 下游传播节点选择:0
+- 受众脆弱点入库:0
+- 传播节点脆弱点入库:0
+- 受众策略入库:0
+- 传播节点策略入库:0
+- 错误数:0
+
+## 受众
+
+## 传播节点
diff --git a/xdfx-login-fix-state.json b/xdfx-login-fix-state.json
new file mode 100644
index 00000000..9ac35b51
--- /dev/null
+++ b/xdfx-login-fix-state.json
@@ -0,0 +1,80 @@
+{
+ "api_base": "https://ch1.b.uat.4.cn",
+ "taskId": "dry-task",
+ "actionId": "dry-action",
+ "mubiao": "登录修正演练",
+ "direction": "U方向",
+ "platform": "",
+ "audienceRawText": "{\"dryRun\": true}",
+ "audienceCandidates": [],
+ "topAudiences": [],
+ "pathwayCandidates": [],
+ "topPathways": [],
+ "audienceVulns": {},
+ "pathwayVulns": {},
+ "audienceStrategies": [],
+ "pathwayStrategies": [],
+ "errors": [],
+ "events": [
+ {
+ "time": "2026-06-29T09:58:41.695868+00:00",
+ "stage": "runner",
+ "message": "start mode=step1 dry_run=True",
+ "data": null
+ },
+ {
+ "time": "2026-06-29T09:58:41.697959+00:00",
+ "stage": "step1-audience/analyze/model-call-attempt-1",
+ "message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_aiuser",
+ "data": {
+ "payload": {
+ "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。",
+ "otherinfo": {
+ "方向": "U方向"
+ },
+ "history": [],
+ "stream": true,
+ "model_name": "OpenAI3",
+ "temperature": 0.7,
+ "max_tokens": 20000,
+ "prompt_name": "任务背景和目的:登录修正演练"
+ }
+ }
+ },
+ {
+ "time": "2026-06-29T09:58:41.703207+00:00",
+ "stage": "step1-audience/extract/model-json-attempt-1/model-call-attempt-1",
+ "message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_qianqian_group_chat",
+ "data": {
+ "payload": {
+ "mubiao": "登录修正演练",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
+ "zhanghao": [],
+ "history": [],
+ "stream": true,
+ "model_name": "OpenAI3",
+ "temperature": 0.7,
+ "max_tokens": 20000,
+ "prompt_name": "登录修正演练"
+ }
+ }
+ },
+ {
+ "time": "2026-06-29T09:58:41.703207+00:00",
+ "stage": "step1-audience",
+ "message": "完成,保存受众 0 个",
+ "data": null
+ }
+ ],
+ "startedAt": "2026-06-29T09:58:41.694826+00:00",
+ "updatedAt": "2026-06-29T09:58:41.709191+00:00",
+ "_statePath": "C:\\tmp\\xdfx-login-fix-state.json",
+ "_summaryPath": "C:\\tmp\\xdfx-login-fix-summary.md",
+ "_autoLogin": true,
+ "_verifySsl": false,
+ "_downstreamLimits": {
+ "audiences": 2,
+ "pathways": 2
+ },
+ "audienceAccounts": []
+}
\ No newline at end of file
diff --git a/xdfx-login-fix-summary.md b/xdfx-login-fix-summary.md
new file mode 100644
index 00000000..d2eca652
--- /dev/null
+++ b/xdfx-login-fix-summary.md
@@ -0,0 +1,18 @@
+# xdfx 分析入库执行摘要
+
+- 更新时间:2026-06-29T09:58:41.709191+00:00
+- taskId:dry-task
+- actionId:dry-action
+- 受众入库:0
+- 传播节点入库:0
+- 下游受众选择:0
+- 下游传播节点选择:0
+- 受众脆弱点入库:0
+- 传播节点脆弱点入库:0
+- 受众策略入库:0
+- 传播节点策略入库:0
+- 错误数:0
+
+## 受众
+
+## 传播节点
diff --git a/xdfx-runner-dry-state.json b/xdfx-runner-dry-state.json
new file mode 100644
index 00000000..127027a9
--- /dev/null
+++ b/xdfx-runner-dry-state.json
@@ -0,0 +1,88 @@
+{
+ "api_base": "https://ch1.b.uat.4.cn",
+ "taskId": "1",
+ "actionId": "2",
+ "mubiao": "dry-run-test",
+ "direction": "U方向",
+ "platform": "fb",
+ "audienceRawText": "{\"dryRun\": true}",
+ "audienceCandidates": [],
+ "topAudiences": [],
+ "pathwayCandidates": [],
+ "topPathways": [],
+ "audienceVulns": {},
+ "pathwayVulns": {},
+ "audienceStrategies": [],
+ "pathwayStrategies": [],
+ "errors": [],
+ "events": [
+ {
+ "time": "2026-06-29T03:43:58.007253+00:00",
+ "stage": "runner",
+ "message": "start mode=step1 dry_run=True",
+ "data": null
+ },
+ {
+ "time": "2026-06-29T03:43:58.007253+00:00",
+ "stage": "runner/auto-login",
+ "message": "dry-run 跳过自动登录",
+ "data": {
+ "username": "admin"
+ }
+ },
+ {
+ "time": "2026-06-29T03:43:58.007253+00:00",
+ "stage": "step1-audience/analyze/model-call-attempt-1",
+ "message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_aiuser",
+ "data": {
+ "payload": {
+ "usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。",
+ "otherinfo": {
+ "方向": "U方向"
+ },
+ "history": [],
+ "stream": true,
+ "model_name": "OpenAI3",
+ "temperature": 0.7,
+ "max_tokens": 20000,
+ "prompt_name": "任务背景和目的:dry-run-test"
+ }
+ }
+ },
+ {
+ "time": "2026-06-29T03:43:58.017225+00:00",
+ "stage": "step1-audience/extract/model-json-attempt-1/model-call-attempt-1",
+ "message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_qianqian_group_chat",
+ "data": {
+ "payload": {
+ "mubiao": "dry-run-test",
+ "user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
+ "zhanghao": [],
+ "history": [],
+ "stream": true,
+ "model_name": "OpenAI3",
+ "temperature": 0.7,
+ "max_tokens": 20000,
+ "prompt_name": "dry-run-test"
+ }
+ }
+ },
+ {
+ "time": "2026-06-29T03:43:58.018223+00:00",
+ "stage": "step1-audience",
+ "message": "完成,保存受众 0 个",
+ "data": null
+ }
+ ],
+ "startedAt": "2026-06-29T03:43:58.006257+00:00",
+ "updatedAt": "2026-06-29T03:43:58.021216+00:00",
+ "_statePath": "C:\\tmp\\xdfx-runner-dry-state.json",
+ "_summaryPath": "C:\\tmp\\xdfx-runner-dry-summary.md",
+ "_autoLogin": true,
+ "_verifySsl": false,
+ "_downstreamLimits": {
+ "audiences": 2,
+ "pathways": 2
+ },
+ "audienceAccounts": []
+}
\ No newline at end of file
diff --git a/xdfx-runner-dry-summary.md b/xdfx-runner-dry-summary.md
new file mode 100644
index 00000000..2ac9168f
--- /dev/null
+++ b/xdfx-runner-dry-summary.md
@@ -0,0 +1,18 @@
+# xdfx 分析入库执行摘要
+
+- 更新时间:2026-06-29T03:43:58.021216+00:00
+- taskId:1
+- actionId:2
+- 受众入库:0
+- 传播节点入库:0
+- 下游受众选择:0
+- 下游传播节点选择:0
+- 受众脆弱点入库:0
+- 传播节点脆弱点入库:0
+- 受众策略入库:0
+- 传播节点策略入库:0
+- 错误数:0
+
+## 受众
+
+## 传播节点