diff --git a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx index 4c56e940..2cd88cd4 100644 --- a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx +++ b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx @@ -414,6 +414,8 @@ function AIWritingBody({ const routeBase = useAIWritingRouteBase(); const agentRoute = useAIWritingAgentRouteState(routeBase); const isAdmin = getStoredAuth()?.system_role === 'admin'; + const externalNotebookMode = + studioToken != null || initialNotebookId != null || initialDocumentIds != null; // 需求 0616 #5:左栏点「笔记本 / 所有空间」→ 在右侧内嵌打开对应页,替换右侧内容 // (写作布局不卸载,仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程)。 @@ -432,12 +434,12 @@ function AIWritingBody({ } }, [agentRoute.active]); - const agentsActive = sidebarTab === 'agents'; + const agentsActive = !externalNotebookMode && sidebarTab === 'agents'; // 研究模板侧栏在「写作配置(idle)」与「历史回看」两态都展示(点历史记录后保留侧栏、 // 历史内容显示到右侧区域);写作进行中收起;嵌入态(studioToken)始终不展示。 const showSidebar = - !studioToken && (state.status === 'idle' || isHistoryMode || agentsActive); + !externalNotebookMode && (state.status === 'idle' || isHistoryMode || agentsActive); const compactAppearanceActions = compactChrome ? ( ) : null; diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 1cb9bcd3..b8eba50b 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -273,7 +273,7 @@ The AI-writing graph (`packages/harness/deerflow/agents/ai_writing/`) has four s Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship in `app/gateway/routers/_ai_writing_seed_assets//`; `_ai_writing_seed.py::ensure_ai_writing_functional_agents()` copies them to `.deer-flow/agents/` when missing (never overwrites local edits), called from the startup lifespan (before `_sync_legacy_agents`, so the agents appear in `/api/agents` on first boot) and self-heals from `GET /api/ai-writing/sessions` + `GET /api/ai-writing/article-types`. Tests: `tests/test_ai_writing_seed.py`, `tests/test_ai_writing_agent_runner.py`. -**Skill-driven retrieval (检索类型).** `material_source` has three values: `general` (通用检索, default), `knowledge_base` (知识库检索), and `notebook` (我的空间); legacy `skill` is normalized to `general` by the researcher node. **`knowledge_base` 直连内网 ES 接口(= 旧版「内网直连检索」)** — it bypasses skill orchestration entirely and calls `IntranetSearchProvider` over `ai_writing.intranet_search_url` (concurrent over the generated keywords; `intranet_knowledge_base`/`intranet_search_timeout` apply). **An empty/unconfigured `intranet_search_url` does NOT disable it** — the empty string is passed straight to `IntranetSearchProvider`, which falls back to its built-in default endpoint (`intranet_stub._DEFAULT_ENDPOINT`), exactly like the legacy `get_search_provider(search_provider=intranet)` path. So 内网直连检索 works out of the box without configuring an address (configure `intranet_search_url` only to point at a different endpoint). This is the "弱模型识别不了技能" escape hatch — the old no-skill retrieval path restored as an explicit option (weak models like deepseek-chat often fail to invoke configured skills). Tests: `tests/test_ai_writing_knowledge_base.py`. General retrieval is orchestrated from the **skills configured on the `ai-writing-researcher` agent** (`config.yaml → skills`, editable from the agent management page): the built-in `knowledge-base-search` skill (知识库检索) runs the native keyword fan-out web/intranet search (the old default path), while any other configured skill searches with a `description`-targeted query; skills run in configured order with early-stop at `max_materials`, and an empty/unresolvable skills config falls back to the built-in knowledge search so retrieval always works. The skill itself is seeded from `_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` into `skills/public/` (never overwrites), and a pre-existing researcher `config.yaml` lacking the `skills` key is patched in place with the default (explicit `skills: []` is respected). **PAUSE-1 input append flow**: when the user types natural language into the material-confirm input (`re_search`/legacy `enable_skill_search` + `user_query`), the node matches the most relevant configured skill via `SkillPicker(candidates=…)` (skipped when only one skill is configured), searches with the user's words, and **appends** the new materials to the existing package (URL-dedup, original order preserved, no LLM re-rank/truncation, 100-item hard cap). **Skill-failure fault tolerance**: a configured skill that times out / raises / has all its keyword searches fail (vs. legitimately returning 0 results) no longer just silently moves on — the researcher node emits a `skill_search_step` of type `skill_error` (carrying the error `reason`), and if the configured skills collected **no** new materials at all it runs a `fallback` pass over other available skills (`_fallback_candidate_skills`: the built-in `knowledge-base-search` native path first, then other enabled catalog skills not yet tried) instead of erroring out. The frontend `SkillStepsCard` renders `skill_error` (rose) and `fallback` (amber) so the timeline shows "技能报错 → 改用其他技能搜集". Tests: `tests/test_ai_writing_material_source.py`. +**Skill-driven retrieval (检索类型).** `material_source` has three values: `general` (通用检索, default), `knowledge_base` (知识库检索), and `notebook` (我的空间); legacy `skill` is normalized to `general` by the researcher node. **`knowledge_base` 直连内网 ES 接口(= 旧版「内网直连检索」)** — it bypasses skill orchestration entirely and calls `IntranetSearchProvider` over `ai_writing.intranet_search_url` (concurrent over the generated keywords; `intranet_knowledge_base`/`intranet_search_timeout` apply). The client uses `trust_env=False` and defaults `intranet_verify_ssl=false`, so internal HTTPS endpoints with self-signed/internal-CA certificates do not fail on certificate verification; set `ai_writing.intranet_verify_ssl: true` only for environments that require strict certificate checks. **An empty/unconfigured `intranet_search_url` does NOT disable it** — the empty string is passed straight to `IntranetSearchProvider`, which falls back to its built-in default endpoint (`intranet_stub._DEFAULT_ENDPOINT`), exactly like the legacy `get_search_provider(search_provider=intranet)` path. So 内网直连检索 works out of the box without configuring an address (configure `intranet_search_url` only to point at a different endpoint). This is the "弱模型识别不了技能" escape hatch — the old no-skill retrieval path restored as an explicit option (weak models like deepseek-chat often fail to invoke configured skills). Tests: `tests/test_ai_writing_knowledge_base.py`. General retrieval is orchestrated from the **skills configured on the `ai-writing-researcher` agent** (`config.yaml → skills`, editable from the agent management page): the built-in `knowledge-base-search` skill (知识库检索) runs the native keyword fan-out web/intranet search (the old default path), while any other configured skill searches with a `description`-targeted query; skills run in configured order with early-stop at `max_materials`, and an empty/unresolvable skills config falls back to the built-in knowledge search so retrieval always works. The skill itself is seeded from `_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` into `skills/public/` (never overwrites), and a pre-existing researcher `config.yaml` lacking the `skills` key is patched in place with the default (explicit `skills: []` is respected). **PAUSE-1 input append flow**: when the user types natural language into the material-confirm input (`re_search`/legacy `enable_skill_search` + `user_query`), the node matches the most relevant configured skill via `SkillPicker(candidates=…)` (skipped when only one skill is configured), searches with the user's words, and **appends** the new materials to the existing package (URL-dedup, original order preserved, no LLM re-rank/truncation, 100-item hard cap). **Skill-failure fault tolerance**: a configured skill that times out / raises / has all its keyword searches fail (vs. legitimately returning 0 results) no longer just silently moves on — the researcher node emits a `skill_search_step` of type `skill_error` (carrying the error `reason`), and if the configured skills collected **no** new materials at all it runs a `fallback` pass over other available skills (`_fallback_candidate_skills`: the built-in `knowledge-base-search` native path first, then other enabled catalog skills not yet tried) instead of erroring out. The frontend `SkillStepsCard` renders `skill_error` (rose) and `fallback` (amber) so the timeline shows "技能报错 → 改用其他技能搜集". Tests: `tests/test_ai_writing_material_source.py`. **Skill-call timeout** is hardcoded **300s (5 min)** — `researcher.py` `_SKILL_SEARCH_TIMEOUT_SECONDS` (per-skill wrapper) and `_skill_agent.py` `_REAL_AGENT_TIMEOUT_SECONDS` (whole real-agent skill run). The intranet ES fallback (`IntranetSearchProvider`) gets a longer **600s (10 min)** default (`intranet_stub.py`; overridable via `ai_writing.intranet_search_timeout`) — that interface is the slow one, so our own fallback timeout is generous to avoid being blamed for "检不出". diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index 40e7e03b..229d1cf0 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -135,6 +135,10 @@ FastAPI application providing REST endpoints for frontend integration: Custom agents are indexed in the `agents` database table. The `id` column is the stable runtime identifier and filesystem directory name (`.deer-flow/agents/{id}/SOUL.md`); `name` is display-only, may be Chinese, and may be duplicated. Rows with `user_id = NULL` are built-in agents, user rows are private unless `published = true`, and users can only update or delete agents they created. +### AI Writing Intranet Retrieval + +AI 写作的「知识库检索」会直连 `ai_writing.intranet_search_url` 内网 ES/知识库接口,并默认跳过代理环境变量和 HTTPS 证书校验,适配自签名或内部 CA 证书的内网服务。需要强校验时可在 `config.yaml` 里设置 `ai_writing.intranet_verify_ssl: true`。 + ### IM Channels The IM bridge supports Feishu, Slack, and Telegram. Slack and Telegram still use the final `runs.wait()` response path, while Feishu now streams through `runs.stream(["messages-tuple", "values"])` and updates a single in-thread card in place. 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 b53b5955..a91d8e0e 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 @@ -27,13 +27,15 @@ from types import SimpleNamespace from langchain_core.runnables import RunnableConfig -from ..search import get_search_provider, SearchResult +from ..prompts.researcher_prompts import ( + EXTRACT_KEYWORDS_SYSTEM, + EXTRACT_KEYWORDS_USER, + RANK_MATERIALS_SYSTEM, + RANK_MATERIALS_USER, +) +from ..search import SearchResult, get_search_provider from ..search.skill_search import SkillPicker from ..state import AIWritingState, Material, MaterialPackage -from ..prompts.researcher_prompts import ( - EXTRACT_KEYWORDS_SYSTEM, EXTRACT_KEYWORDS_USER, - RANK_MATERIALS_SYSTEM, RANK_MATERIALS_USER, -) from ._agent_runner import ( KEYWORDS_READY_MARKER, MATERIALS_RANKED_MARKER, @@ -620,6 +622,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict # 内网慢检索超时可在 config.yaml 的 ai_writing.intranet_search_timeout 调大, # 默认 600s,且最低 300s。 timeout=_intranet_timeout_seconds(ai_writing_config), + verify_ssl=ai_writing_config.get("intranet_verify_ssl", False), ) endpoint_label = intranet_url or getattr(provider, "endpoint", "内置默认内网端点") intranet_debug = { @@ -628,6 +631,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict "endpoint": endpoint_label, "knowledge_base": ai_writing_config.get("intranet_knowledge_base", "samples"), "timeout": _intranet_timeout_seconds(ai_writing_config), + "verify_ssl": getattr(provider, "verify_ssl", False), "status": "running", "queries": [], "calls": [], @@ -780,7 +784,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict timeout=_SKILL_SEARCH_TIMEOUT_SECONDS, ) return (results or []), "" - except asyncio.TimeoutError: + except TimeoutError: msg = f"检索超时(>{_SKILL_SEARCH_TIMEOUT_SECONDS}s)" logger.warning("skill[%s] search timed out after %ss", skill_name, _SKILL_SEARCH_TIMEOUT_SECONDS) search_errors.append(f"skill[{skill_name}]: {msg}") @@ -923,13 +927,14 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict intranet_url = (ai_writing_config.get("intranet_search_url") or "").strip() # 诊断开关:开启后即使技能成功也照样跑完内网并记录完整结果(用于验证内网接口是否真能通)。 intranet_debug_force = bool(ai_writing_config.get("intranet_search_debug", False)) - intranet_task: "asyncio.Task | None" = None + intranet_task: asyncio.Task | None = None intranet_holder: dict = {} intranet_debug = { "configured": bool(intranet_url), "endpoint": intranet_url or "(未配置 ai_writing.intranet_search_url)", "knowledge_base": ai_writing_config.get("intranet_knowledge_base", "samples"), "timeout": _intranet_timeout_seconds(ai_writing_config), + "verify_ssl": bool(ai_writing_config.get("intranet_verify_ssl", False)), "debug_force": intranet_debug_force, "status": "pending" if intranet_url else "not_configured", "queries": [], # 每个检索词:{query, count, error?} @@ -952,6 +957,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict # 兜底接口超时可在 config.yaml 的 ai_writing.intranet_search_timeout 调大, # 默认 600s(10 分钟),且最低 300s。 timeout=_intranet_timeout_seconds(ai_writing_config), + verify_ssl=ai_writing_config.get("intranet_verify_ssl", False), ) intranet_holder["provider"] = intranet fb_keywords = keywords[:keyword_count] or [intent] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/__init__.py index 68a0f36f..f58de660 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/__init__.py @@ -1,8 +1,8 @@ from .base import BaseSearchProvider, SearchResult -from .internet_search import InternetSearchProvider -from .intranet_stub import IntranetSearchProvider, normalize_intranet_timeout from .demo_search import DemoSearchProvider from .duckduckgo_search import DuckDuckGoSearchProvider +from .internet_search import InternetSearchProvider +from .intranet_stub import IntranetSearchProvider, normalize_intranet_timeout, normalize_intranet_verify_ssl from .skill_search import SkillSearchProvider @@ -14,6 +14,7 @@ def get_search_provider(ai_writing_config: dict) -> BaseSearchProvider: intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es intranet_knowledge_base: samples intranet_search_timeout: 300 # 内网检索超时秒数,最少 300 秒 + intranet_verify_ssl: false # 内网 HTTPS 自签名证书场景默认关闭证书校验 ddg_region: "cn-zh" # DuckDuckGo 区域,默认 cn-zh internet 模式:DuckDuckGo 为主搜索(免 API Key),失败时 fallback 到 demo 演示素材。 @@ -26,6 +27,7 @@ def get_search_provider(ai_writing_config: dict) -> BaseSearchProvider: base_url=cfg.get("intranet_search_url", ""), knowledge_base_name=cfg.get("intranet_knowledge_base", "samples"), timeout=normalize_intranet_timeout(cfg.get("intranet_search_timeout")), + verify_ssl=normalize_intranet_verify_ssl(cfg.get("intranet_verify_ssl")), ) if provider == "tavily": return _FallbackSearchProvider(InternetSearchProvider(), DemoSearchProvider()) 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 3f5fcce1..4398933d 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 @@ -3,7 +3,6 @@ from __future__ import annotations import logging import time -from typing import List from .base import BaseSearchProvider, SearchResult, simplify_query @@ -15,6 +14,8 @@ _DEFAULT_KB = "samples" # 内网检索最少等待 5 分钟;该接口经常慢返回,低于这个值会造成素材收集误判为空。 _MIN_TIMEOUT_SECONDS = 300 _DEFAULT_TIMEOUT_SECONDS = 600 +# 内网 HTTPS 检索服务常使用自签名/内部 CA 证书,默认跳过证书校验避免 SSL 证书链错误。 +_DEFAULT_VERIFY_SSL = False # 单条原始响应体最大保留长度,避免把超大响应整段灌进前端时间线 _RAW_SNIPPET_LIMIT = 2000 @@ -28,6 +29,23 @@ def normalize_intranet_timeout(timeout: object, default: int = _DEFAULT_TIMEOUT_ return max(value, _MIN_TIMEOUT_SECONDS) +def normalize_intranet_verify_ssl(value: object, default: bool = _DEFAULT_VERIFY_SSL) -> bool: + """返回内网检索是否校验 SSL 证书,兼容 config.yaml 里的 bool / 字符串写法。""" + if isinstance(value, bool): + return value + if value is None: + return default + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + return default + + def _pick(d: dict, keys: tuple) -> str: """从结果项里按候选字段名取第一个非空值(两套接口字段名不同,做并集兼容)。""" for k in keys: @@ -62,12 +80,14 @@ class IntranetSearchProvider(BaseSearchProvider): base_url: str = "", knowledge_base_name: str = _DEFAULT_KB, timeout: int = _DEFAULT_TIMEOUT_SECONDS, + verify_ssl: bool = _DEFAULT_VERIFY_SSL, **_ignored, ): self.endpoint = base_url or _DEFAULT_ENDPOINT self.knowledge_base_name = knowledge_base_name or _DEFAULT_KB # 内网 ES 接口很慢,兜底检索超时默认 10 分钟,且无论配置如何至少 5 分钟。 self.timeout = normalize_intranet_timeout(timeout) + self.verify_ssl = normalize_intranet_verify_ssl(verify_ssl) # 收集本次运行的检索异常,供 researcher 节点取出并提示用户(ES 波动等) self.errors: list[str] = [] # 解析失败时收集的原始响应体,供前端「错误返回结果」折叠展示 @@ -82,7 +102,7 @@ class IntranetSearchProvider(BaseSearchProvider): if snippet: self.raw_responses.append(snippet) - async def search(self, query: str, max_results: int = 5, **kwargs) -> List[SearchResult]: + async def search(self, query: str, max_results: int = 5, **kwargs) -> list[SearchResult]: results = await self._search_once(query, max_results) # 0 结果兜底:复合检索词常检不出(如「特朗普竞选」),换成更核心的检索词重试一次 # (「特朗普竞选」→「特朗普」)。仅在首检为空、且能简化出不同的词时才重试。 @@ -93,7 +113,7 @@ class IntranetSearchProvider(BaseSearchProvider): results = await self._search_once(simpler, max_results) return results - async def _search_once(self, query: str, max_results: int = 5) -> List[SearchResult]: + async def _search_once(self, query: str, max_results: int = 5) -> list[SearchResult]: import httpx payload = { @@ -112,12 +132,13 @@ class IntranetSearchProvider(BaseSearchProvider): "status": "pending", "ok": False, "count": 0, + "verify_ssl": self.verify_ssl, } # 排查用:每次真实调用都打一行请求明细(端点 / 知识库 / 检索词 / 超时), # 内网复现时直接看后端日志即可知道"到底请求了哪个地址、用什么参数"。 logger.info( - "[intranet-search] POST endpoint=%s kb=%s timeout=%ss query=%r", - self.endpoint, self.knowledge_base_name, self.timeout, query, + "[intranet-search] POST endpoint=%s kb=%s timeout=%ss verify_ssl=%s query=%r", + self.endpoint, self.knowledge_base_name, self.timeout, self.verify_ssl, query, ) try: # trust_env=False:**关键**。内网 ES 是内网地址,但本部署的模型走公网, @@ -125,7 +146,8 @@ class IntranetSearchProvider(BaseSearchProvider): # 会把这个内网请求也套用公网代理 → 代理到不了内网 IP → 连接被重置/空回复, # 表现为「接口异常」。强制忽略代理 env、直连内网(与 multi_agent.py 的回环 # 调用同款修法)。 - async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client: + # verify=False:内网 HTTPS 常见自签名/内部 CA 证书;这里仅跳过本内网检索端点的证书校验。 + async with httpx.AsyncClient(timeout=self.timeout, trust_env=False, verify=self.verify_ssl) as client: resp = await client.post(self.endpoint, json=payload) call["http_status"] = getattr(resp, "status_code", None) resp.raise_for_status() @@ -210,7 +232,7 @@ class IntranetSearchProvider(BaseSearchProvider): "[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] = [] + results: list[SearchResult] = [] for i, item in enumerate(items[:max_results]): if not isinstance(item, dict): continue diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py index 4eeddb1f..c1e47b44 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py @@ -6,20 +6,21 @@ from datetime import UTC, datetime from typing import Any from uuid import uuid4 -from sqlalchemy import and_, case, delete, func, or_, select +from sqlalchemy import and_, case, delete, func, literal, not_, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from deerflow.config.agent_name_desensitize import source_terms_for_needle from deerflow.persistence.agents.base import AgentStore -from deerflow.persistence.agents.model import ( - AgentExtrasRow, - AgentFavoriteRow, - AgentPinRow, - AgentRow, - AgentSkillRow, -) -from deerflow.persistence.tags.model import TagAssignmentRow +from deerflow.persistence.agents.model import ( + AgentExtrasRow, + AgentFavoriteRow, + AgentPinRow, + AgentRow, + AgentSkillRow, +) +from deerflow.persistence.roundtable_chains.model import RoundtableChainRow +from deerflow.persistence.tags.model import TagAssignmentRow def _row_to_dict(row: AgentRow) -> dict[str, Any]: @@ -32,9 +33,33 @@ def _row_to_dict(row: AgentRow) -> dict[str, Any]: return data -class AgentRepository(AgentStore): - def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: - self._sf = session_factory +class AgentRepository(AgentStore): + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._sf = session_factory + + @staticmethod + def _business_chain_agent_exists(agent_id_col): + return ( + select(RoundtableChainRow.id) + .where( + or_( + RoundtableChainRow.seats.like(literal('%"agent_id": "') + agent_id_col + literal('"%')), + RoundtableChainRow.seats.like(literal('%"agent_id":"') + agent_id_col + literal('"%')), + ) + ) + .exists() + ) + + @classmethod + def _valid_favorite_filter(cls): + return or_( + AgentFavoriteRow.origin == "user", + and_( + AgentFavoriteRow.origin == "position", + AgentRow.user_id.is_not(None), + not_(cls._business_chain_agent_exists(AgentFavoriteRow.agent_id)), + ), + ) @staticmethod def _visible_filter(user_id: str): @@ -52,15 +77,23 @@ class AgentRepository(AgentStore): ``published=false``, so this clause cannot lean on ``published``; - favorited — the "我的" listing keeps favorited agents even after the owner unpublishes them, so reads keep honoring the favorite. - """ - fav_subq = select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id) - return or_( - AgentRow.user_id.is_(None), - AgentRow.user_id == user_id, - AgentRow.published.is_(True), - AgentRow.is_private.is_(True), - AgentRow.id.in_(fav_subq), - ) + """ + valid_favorite_exists = ( + select(AgentFavoriteRow.id) + .where( + AgentFavoriteRow.user_id == user_id, + AgentFavoriteRow.agent_id == AgentRow.id, + AgentRepository._valid_favorite_filter(), + ) + .exists() + ) + return or_( + AgentRow.user_id.is_(None), + AgentRow.user_id == user_id, + AgentRow.published.is_(True), + AgentRow.is_private.is_(True), + valid_favorite_exists, + ) @staticmethod def _listing_filter(user_id: str): @@ -268,11 +301,20 @@ class AgentRepository(AgentStore): or_(AgentRow.user_id.is_(None), AgentRow.published.is_(True)), AgentRow.is_private.is_(False), ) - if scope == "mine": - # owned OR favorited. A correlated IN-subquery (not a JOIN) keeps a - # single row per agent even when it is both owned and favorited. - fav_subq = select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id) - return or_(AgentRow.user_id == user_id, AgentRow.id.in_(fav_subq)) + if scope == "mine": + # owned OR valid favorites. Built-in agents and business-chain + # seats only enter "mine" when explicitly favorited by the user; + # the original position-granted behavior stays for ordinary agents. + valid_favorite_exists = ( + select(AgentFavoriteRow.id) + .where( + AgentFavoriteRow.user_id == user_id, + AgentFavoriteRow.agent_id == AgentRow.id, + self._valid_favorite_filter(), + ) + .exists() + ) + return or_(AgentRow.user_id == user_id, valid_favorite_exists) if scope == "private": return AgentRow.is_private.is_(True) if scope == "builtin": @@ -437,18 +479,22 @@ class AgentRepository(AgentStore): # --- Favorites ("收藏") --- - async def add_favorite(self, user_id: str, agent_id: str) -> None: - async with self._sf() as session: - existing = ( - await session.execute( - select(AgentFavoriteRow.id) - .where(AgentFavoriteRow.user_id == user_id, AgentFavoriteRow.agent_id == agent_id) - .limit(1) - ) - ).scalar_one_or_none() - if existing is not None: - return - session.add(AgentFavoriteRow(id=uuid4().hex, user_id=user_id, agent_id=agent_id)) + async def add_favorite(self, user_id: str, agent_id: str) -> None: + async with self._sf() as session: + existing = ( + await session.execute( + select(AgentFavoriteRow) + .where(AgentFavoriteRow.user_id == user_id, AgentFavoriteRow.agent_id == agent_id) + .limit(1) + ) + ).scalar_one_or_none() + if existing is not None: + if existing.origin != "user" or existing.position_id is not None: + existing.origin = "user" + existing.position_id = None + await session.commit() + return + session.add(AgentFavoriteRow(id=uuid4().hex, user_id=user_id, agent_id=agent_id)) try: await session.commit() except IntegrityError: @@ -467,12 +513,17 @@ class AgentRepository(AgentStore): await session.commit() return (result.rowcount or 0) > 0 - async def list_favorite_ids(self, user_id: str) -> set[str]: - async with self._sf() as session: - result = await session.execute( - select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id) - ) - return {row[0] for row in result.all()} + async def list_favorite_ids(self, user_id: str) -> set[str]: + async with self._sf() as session: + result = await session.execute( + select(AgentFavoriteRow.agent_id) + .join(AgentRow, AgentRow.id == AgentFavoriteRow.agent_id) + .where( + AgentFavoriteRow.user_id == user_id, + self._valid_favorite_filter(), + ) + ) + return {row[0] for row in result.all()} async def delete_favorites_for_agent(self, agent_id: str) -> None: async with self._sf() as session: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260701_01_agent_position_favorites.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260701_01_agent_position_favorites.py new file mode 100644 index 00000000..264a12bf --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260701_01_agent_position_favorites.py @@ -0,0 +1,90 @@ +"""Stop position-granted favorites from collecting built-ins/chain seats. + +Built-in agents and agents used as business-chain seats should only enter +"mine" when a user explicitly clicks the favorite action. This migration +removes historical automatic ``origin='position'`` rows for those agents while +preserving user-created favorites (``origin='user'``) and ordinary +position-granted agent favorites. + +Revision ID: 20260701_01 +Revises: 20260625_04 +Create Date: 2026-07-01 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "20260701_01" +down_revision = "20260625_04" +branch_labels = None +depends_on = None + + +def _has_table(table_name: str) -> bool: + inspector = inspect(op.get_bind()) + return table_name in inspector.get_table_names() + + +def _has_column(table_name: str, column_name: str) -> bool: + if not _has_table(table_name): + return False + inspector = inspect(op.get_bind()) + return any(col["name"] == column_name for col in inspector.get_columns(table_name)) + + +def upgrade() -> None: + if not ( + _has_table("agent_favorites") + and _has_column("agent_favorites", "origin") + and _has_column("agent_favorites", "agent_id") + ): + return + + favorites = sa.table( + "agent_favorites", + sa.column("agent_id", sa.String(64)), + sa.column("origin", sa.String(16)), + ) + cleanup_conditions = [] + if _has_table("agents") and _has_column("agents", "user_id"): + agents = sa.table( + "agents", + sa.column("id", sa.String(64)), + sa.column("user_id", sa.String(64)), + ) + cleanup_conditions.append( + favorites.c.agent_id.in_(sa.select(agents.c.id).where(agents.c.user_id.is_(None))) + ) + if _has_table("roundtable_chains") and _has_column("roundtable_chains", "seats"): + chains = sa.table( + "roundtable_chains", + sa.column("seats", sa.Text()), + ) + cleanup_conditions.append( + sa.select(sa.literal(1)) + .select_from(chains) + .where( + sa.or_( + chains.c.seats.like(sa.literal('%"agent_id": "') + favorites.c.agent_id + sa.literal('"%')), + chains.c.seats.like(sa.literal('%"agent_id":"') + favorites.c.agent_id + sa.literal('"%')), + ) + ) + .exists() + ) + if not cleanup_conditions: + return + + op.get_bind().execute( + favorites.delete().where( + favorites.c.origin == "position", + sa.or_(*cleanup_conditions), + ) + ) + + +def downgrade() -> None: + # Data cleanup only; removed auto-grants are not reconstructable. + pass diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py b/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py index 2a6e86e8..23b4a8d9 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py @@ -12,7 +12,8 @@ Resolution rule (per resource type): * For a resource type **with** bound tags → the union of resources carrying any of those tags. * For a resource type **without** bound tags → the *default set*: - - ``agent`` → all built-in agents (``user_id IS NULL``) + - ``agent`` -> built-ins first, then filtered out of + position-granted favorites - ``scheduled_task`` → empty (no built-in tasks) - ``skill`` → empty (the 广场 still shows every visible skill; a position only *adds* its tagged skills to the member's "我的技能") @@ -24,9 +25,12 @@ Resolution rule (per resource type): Materialization (``sync_user``) only ever touches rows it created itself (``origin='position'``), so a user's own favorites / subscriptions are never -clobbered. ``skill`` is materialized into ``skill_favorites`` exactly like -``agent`` → ``agent_favorites``: the tagged skills are *added* to the user's -"我的技能" while the browse-all 广场 stays complete (it is **not** restricted). +clobbered. ``agent`` keeps the original position-granted behavior for ordinary +tagged agents, but filters out built-ins and business-chain seats; those only +enter "mine" after an explicit user favorite. ``skill`` is still materialized +into ``skill_favorites`` so tagged skills are added to the user's "mine" +collection while the browse-all square stays complete (it is **not** +restricted). Only recommended-question visibility is computed live by :meth:`resolve_visible` and is *not* materialized. (The admin-configured prompts shown to users ARE the recommended questions — there is no separate prompt-template store.) @@ -34,6 +38,7 @@ recommended questions — there is no separate prompt-template store.) from __future__ import annotations +import json import logging import uuid from datetime import UTC, datetime @@ -43,13 +48,15 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow from deerflow.persistence.positions.base import PositionStore +from deerflow.persistence.roundtable_chains.model import RoundtableChainRow from deerflow.persistence.scheduled_tasks.model import ScheduledTaskSubscriptionRow from deerflow.persistence.skills.model import SkillFavoriteRow from deerflow.persistence.tags.base import TagStore logger = logging.getLogger(__name__) -# Materialized into "我的" tables by sync_user. +# Reconciled by sync_user. ``agent`` preserves ordinary position-granted rows, +# but filters built-ins and business-chain seats. _MATERIALIZED_TYPES = ("agent", "skill", "scheduled_task") # Resolved live at query time (visibility filters), never materialized. _VISIBILITY_TYPES = ("recommended_question",) @@ -93,8 +100,12 @@ class PositionSyncService: bindings = await self._positions.get_tag_bindings(pid) if pid else {} tag_ids = bindings.get(resource_type) or [] if tag_ids: - return set(await self._tags.list_targets_for_tags(resource_type, tag_ids)) - return await self._default_set(resource_type, session) + target = set(await self._tags.list_targets_for_tags(resource_type, tag_ids)) + else: + target = await self._default_set(resource_type, session) + if resource_type == "agent": + return await self._filter_materializable_agents(target, session) + return target async def _default_set(self, resource_type: str, session: AsyncSession) -> set[str]: if resource_type == "agent": @@ -103,6 +114,37 @@ class PositionSyncService: # scheduled_task (and any other materialized type) defaults to empty. return set() + async def _filter_materializable_agents(self, agent_ids: set[str], session: AsyncSession) -> set[str]: + if not agent_ids: + return set() + rows = ( + await session.execute( + select(AgentRow.id).where( + AgentRow.id.in_(agent_ids), + AgentRow.user_id.is_not(None), + ) + ) + ).all() + candidate_ids = {r[0] for r in rows} + return candidate_ids - await self._business_chain_agent_ids(session) + + async def _business_chain_agent_ids(self, session: AsyncSession) -> set[str]: + rows = (await session.execute(select(RoundtableChainRow.seats))).scalars() + agent_ids: set[str] = set() + for raw in rows: + if not raw: + continue + try: + seats = json.loads(raw) if isinstance(raw, str) else raw + except Exception: + continue + if not isinstance(seats, list): + continue + for seat in seats: + if isinstance(seat, dict) and isinstance(seat.get("agent_id"), str): + agent_ids.add(seat["agent_id"]) + return agent_ids + # --------------------------------------------------------- materialize async def sync_user(self, user_id: str) -> None: diff --git a/offline-backend-20260512/backend/tests/test_agents_backend.py b/offline-backend-20260512/backend/tests/test_agents_backend.py index 57345641..f87df609 100644 --- a/offline-backend-20260512/backend/tests/test_agents_backend.py +++ b/offline-backend-20260512/backend/tests/test_agents_backend.py @@ -1,33 +1,131 @@ -from __future__ import annotations - -import pytest -import yaml - -from app.gateway.routers.agents import ( - _backfill_listing_cache, - _load_config_extras, - _write_agent_config, -) -from app.gateway.services import build_run_config -from deerflow.config import paths as paths_module -from deerflow.config.agents_config import AgentConfig -from deerflow.persistence.agents import MemoryAgentStore - - +from __future__ import annotations + +import json +from uuid import uuid4 + +import pytest +import pytest_asyncio +import yaml +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.gateway.routers.agents import ( + _backfill_listing_cache, + _load_config_extras, + _write_agent_config, +) +from app.gateway.services import build_run_config +from deerflow.config import paths as paths_module +from deerflow.config.agents_config import AgentConfig +from deerflow.persistence.agents import AgentRepository, MemoryAgentStore +from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow +from deerflow.persistence.base import Base +from deerflow.persistence.positions.memory import MemoryPositionStore +from deerflow.persistence.roundtable_chains.model import RoundtableChainRow +from deerflow.persistence.tags.memory import MemoryTagStore +from deerflow.services.position_sync import PositionSyncService + + @pytest.fixture -def isolated_paths(tmp_path, monkeypatch): - """Redirect get_paths() at a fresh temp directory for the duration of one test.""" - monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path)) - monkeypatch.setattr(paths_module, "_paths", None) - yield tmp_path - monkeypatch.setattr(paths_module, "_paths", None) +def isolated_paths(tmp_path, monkeypatch): + """Redirect get_paths() at a fresh temp directory for the duration of one test.""" + monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path)) + monkeypatch.setattr(paths_module, "_paths", None) + yield tmp_path + monkeypatch.setattr(paths_module, "_paths", None) + + +@pytest_asyncio.fixture +async def sql_session_factory(tmp_path): + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'agent-tests.db'}") + async with engine.begin() as conn: + # Importing PositionSyncService above registers the agent / skill / + # scheduled-task rows it materializes into. + await conn.run_sync(Base.metadata.create_all) + sf = async_sessionmaker(engine, expire_on_commit=False) + try: + yield sf + finally: + await engine.dispose() + + +async def _add_sql_agent(sf, *, agent_id: str, user_id: str | None) -> None: + async with sf() as session: + session.add( + AgentRow( + id=agent_id, + name=agent_id, + description="", + user_id=user_id, + published=True, + is_private=False, + square_id="", + ) + ) + await session.commit() + + +async def _add_sql_favorite(sf, *, user_id: str, agent_id: str, origin: str) -> None: + async with sf() as session: + session.add( + AgentFavoriteRow( + id=uuid4().hex, + user_id=user_id, + agent_id=agent_id, + origin=origin, + position_id="default" if origin == "position" else None, + ) + ) + await session.commit() + + +async def _add_sql_chain(sf, *, chain_id: str, agent_ids: list[str]) -> None: + async with sf() as session: + session.add( + RoundtableChainRow( + id=chain_id, + user_id="chain-owner", + title=chain_id, + description="", + seats=json.dumps( + [{"agent_id": agent_id, "name": agent_id, "description": ""} for agent_id in agent_ids], + ensure_ascii=False, + ), + ) + ) + await session.commit() + + +async def _sql_favorite_rows(sf, user_id: str) -> set[tuple[str, str]]: + async with sf() as session: + rows = ( + await session.execute( + select(AgentFavoriteRow.agent_id, AgentFavoriteRow.origin).where( + AgentFavoriteRow.user_id == user_id + ) + ) + ).all() + return {(agent_id, origin) for agent_id, origin in rows} + + +async def _default_positions() -> MemoryPositionStore: + positions = MemoryPositionStore() + await positions.create_position( + { + "id": "default", + "name": "Default", + "description": "", + "is_default": True, + } + ) + return positions @pytest.mark.asyncio -async def test_agent_store_allows_duplicate_chinese_names_and_filters_visibility() -> None: - store = MemoryAgentStore() - chinese_name = "\u4e2d\u6587\u52a9\u624b" - +async def test_agent_store_allows_duplicate_chinese_names_and_filters_visibility() -> None: + store = MemoryAgentStore() + chinese_name = "\u4e2d\u6587\u52a9\u624b" + await store.ensure_builtin({"id": "builtin-agent", "name": chinese_name, "description": "built in"}) await store.create({"id": "private-a", "name": chinese_name, "description": "private", "user_id": "user-a"}) await store.create({"id": "published-b", "name": chinese_name, "description": "published", "user_id": "user-b", "published": True}) @@ -35,14 +133,144 @@ async def test_agent_store_allows_duplicate_chinese_names_and_filters_visibility user_a_ids = {agent["id"] for agent in await store.list_visible("user-a")} assert user_a_ids == {"builtin-agent", "private-a", "published-b"} - user_c_ids = {agent["id"] for agent in await store.list_visible("user-c")} - assert user_c_ids == {"builtin-agent", "published-b"} - - -@pytest.mark.asyncio -async def test_agent_store_only_owner_can_update_or_delete() -> None: - store = MemoryAgentStore() - + user_c_ids = {agent["id"] for agent in await store.list_visible("user-c")} + assert user_c_ids == {"builtin-agent", "published-b"} + + +@pytest.mark.asyncio +async def test_position_sync_default_does_not_collect_builtin_agents(sql_session_factory) -> None: + await _add_sql_agent(sql_session_factory, agent_id="builtin-auto", user_id=None) + await _add_sql_agent(sql_session_factory, agent_id="builtin-manual", user_id=None) + await _add_sql_favorite( + sql_session_factory, + user_id="user-a", + agent_id="builtin-auto", + origin="position", + ) + await _add_sql_favorite( + sql_session_factory, + user_id="user-a", + agent_id="builtin-manual", + origin="user", + ) + + sync = PositionSyncService( + sql_session_factory, + await _default_positions(), + MemoryTagStore(), + ) + await sync.sync_user("user-a") + + assert await _sql_favorite_rows(sql_session_factory, "user-a") == { + ("builtin-manual", "user") + } + + +@pytest.mark.asyncio +async def test_position_sync_tagged_agents_are_not_auto_favorited(sql_session_factory) -> None: + await _add_sql_agent(sql_session_factory, agent_id="builtin-tagged", user_id=None) + await _add_sql_agent(sql_session_factory, agent_id="custom-tagged", user_id="owner") + await _add_sql_agent(sql_session_factory, agent_id="chain-tagged", user_id="owner") + await _add_sql_chain(sql_session_factory, chain_id="chain-1", agent_ids=["chain-tagged"]) + + positions = await _default_positions() + tags = MemoryTagStore() + await tags.create_tag({"id": "agent-tag", "name": "Agent tag", "tag_type": "agent"}) + await tags.set_target_tags("agent", "builtin-tagged", ["agent-tag"]) + await tags.set_target_tags("agent", "custom-tagged", ["agent-tag"]) + await tags.set_target_tags("agent", "chain-tagged", ["agent-tag"]) + await positions.set_tag_bindings("default", {"agent": ["agent-tag"]}) + + sync = PositionSyncService(sql_session_factory, positions, tags) + await sync.sync_user("user-a") + + assert await _sql_favorite_rows(sql_session_factory, "user-a") == { + ("custom-tagged", "position") + } + + +@pytest.mark.asyncio +async def test_mine_scope_ignores_auto_agent_favorites_but_keeps_manual_builtin(sql_session_factory) -> None: + await _add_sql_agent(sql_session_factory, agent_id="builtin-auto", user_id=None) + await _add_sql_agent(sql_session_factory, agent_id="builtin-manual", user_id=None) + await _add_sql_agent(sql_session_factory, agent_id="custom-auto", user_id="owner") + await _add_sql_agent(sql_session_factory, agent_id="chain-auto", user_id="owner") + await _add_sql_chain(sql_session_factory, chain_id="chain-1", agent_ids=["chain-auto"]) + await _add_sql_favorite( + sql_session_factory, + user_id="user-a", + agent_id="builtin-auto", + origin="position", + ) + await _add_sql_favorite( + sql_session_factory, + user_id="user-a", + agent_id="builtin-manual", + origin="user", + ) + await _add_sql_favorite( + sql_session_factory, + user_id="user-a", + agent_id="custom-auto", + origin="position", + ) + await _add_sql_favorite( + sql_session_factory, + user_id="user-a", + agent_id="chain-auto", + origin="position", + ) + + store = AgentRepository(sql_session_factory) + rows, total = await store.list_paginated( + user_id="user-a", + scope="mine", + search=None, + tag_ids=None, + page=1, + page_size=20, + ) + + assert total == 2 + assert {row["id"] for row in rows} == {"builtin-manual", "custom-auto"} + assert await store.list_favorite_ids("user-a") == {"builtin-manual", "custom-auto"} + + +@pytest.mark.asyncio +async def test_manual_favorite_upgrades_existing_auto_builtin_favorite(sql_session_factory) -> None: + await _add_sql_agent(sql_session_factory, agent_id="builtin-agent", user_id=None) + await _add_sql_favorite( + sql_session_factory, + user_id="user-a", + agent_id="builtin-agent", + origin="position", + ) + + store = AgentRepository(sql_session_factory) + assert await store.list_favorite_ids("user-a") == set() + + await store.add_favorite("user-a", "builtin-agent") + + assert await _sql_favorite_rows(sql_session_factory, "user-a") == { + ("builtin-agent", "user") + } + assert await store.list_favorite_ids("user-a") == {"builtin-agent"} + rows, total = await store.list_paginated( + user_id="user-a", + scope="mine", + search=None, + tag_ids=None, + page=1, + page_size=20, + ) + assert total == 1 + assert [row["id"] for row in rows] == ["builtin-agent"] + + +@pytest.mark.asyncio +async def test_agent_store_only_owner_can_update_or_delete() -> None: + store = MemoryAgentStore() + await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"}) await store.create({"id": "private-a", "name": "Owned", "user_id": "user-a"}) diff --git a/offline-backend-20260512/backend/tests/test_ai_writing_knowledge_base.py b/offline-backend-20260512/backend/tests/test_ai_writing_knowledge_base.py index 01ed878f..2dadb6f3 100644 --- a/offline-backend-20260512/backend/tests/test_ai_writing_knowledge_base.py +++ b/offline-backend-20260512/backend/tests/test_ai_writing_knowledge_base.py @@ -10,7 +10,9 @@ from types import SimpleNamespace import pytest from deerflow.agents.ai_writing.nodes import researcher as R +from deerflow.agents.ai_writing.search import get_search_provider from deerflow.agents.ai_writing.search.base import SearchResult +from deerflow.agents.ai_writing.search.intranet_stub import IntranetSearchProvider class _FakeIntranet: @@ -21,6 +23,7 @@ class _FakeIntranet: self.raw_responses = [] self.calls = [] self.kw = kw + self.verify_ssl = kw.get("verify_ssl", False) _FakeIntranet.instances.append(self) async def search(self, query, max_results=5, **_): @@ -55,12 +58,24 @@ class _SkillProviderShouldNotRun: raise AssertionError("knowledge_base 模式不应调用通用 provider / 技能检索") -def _patch(monkeypatch, intranet_url="http://es/api", intranet_cls=_FakeIntranet): +class _FakeResponse: + status_code = 200 + text = '[{"title":"内网标题","content":"内网正文","url":"https://intranet/doc"}]' + + def raise_for_status(self) -> None: + return None + + def json(self): + return [{"title": "内网标题", "content": "内网正文", "url": "https://intranet/doc"}] + + +def _patch(monkeypatch, intranet_url="http://es/api", intranet_cls=_FakeIntranet, intranet_verify_ssl=False): _FakeIntranet.instances = [] cfg = { "max_materials": 20, "intranet_search_url": intranet_url, "intranet_knowledge_base": "samples", + "intranet_verify_ssl": intranet_verify_ssl, } monkeypatch.setattr(R, "get_ai_writing_config", lambda: cfg) monkeypatch.setattr(R, "use_builtin_agent", lambda *_: False) @@ -125,6 +140,15 @@ async def test_knowledge_base_no_url_falls_back_to_default_endpoint(monkeypatch) assert _FakeIntranet.instances[0].kw.get("base_url") == "", "空地址应原样下传,由 provider 回退默认端点" +@pytest.mark.asyncio +async def test_knowledge_base_passes_verify_ssl_config(monkeypatch): + _patch(monkeypatch, intranet_verify_ssl=True) + out = await R.researcher_node(_state(), {"configurable": {}}) + + assert _FakeIntranet.instances[0].kw.get("verify_ssl") is True + assert out["material_package"]["intranet_debug"]["verify_ssl"] is True + + @pytest.mark.asyncio async def test_knowledge_base_does_not_orchestrate_skills(monkeypatch): """knowledge_base 不编排技能:通用 provider 一旦被调用就会 raise。""" @@ -163,3 +187,44 @@ async def test_knowledge_base_failure_is_visible(monkeypatch): steps = pkg["skill_search_steps"] assert any(step["type"] == "skill_error" for step in steps) assert any(event["type"] == "notice" and event.get("raw") for event in out["progress_events"]) + + +@pytest.mark.asyncio +async def test_intranet_search_disables_ssl_verification_for_internal_https(monkeypatch): + import httpx + + seen: dict = {} + + class _FakeAsyncClient: + def __init__(self, **kwargs): + seen.update(kwargs) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, endpoint, json): + seen["endpoint"] = endpoint + seen["payload"] = json + return _FakeResponse() + + monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient) + + provider = IntranetSearchProvider(base_url="https://intranet.example/search", knowledge_base_name="kb", timeout=300) + results = await provider.search("测试", max_results=1) + + assert results + assert seen["verify"] is False + assert seen["trust_env"] is False + assert seen["endpoint"] == "https://intranet.example/search" + assert seen["payload"]["knowledge_base_name"] == "kb" + assert provider.calls[0]["verify_ssl"] is False + + +def test_intranet_verify_ssl_can_be_reenabled_from_config(): + provider = get_search_provider({"search_provider": "intranet", "intranet_verify_ssl": "true"}) + + assert isinstance(provider, IntranetSearchProvider) + assert provider.verify_ssl is True diff --git a/skills-data/xdfx-full-flow-skill-guide.md b/skills-data/xdfx-full-flow-skill-guide.md index 014a6300..ec7ed043 100644 --- a/skills-data/xdfx-full-flow-skill-guide.md +++ b/skills-data/xdfx-full-flow-skill-guide.md @@ -194,7 +194,8 @@ Step1 受众目标分析完成:当前已入库受众 X 个,本步骤新增 - 单个受众、账号、传播节点、脆弱点或策略失败,不中断整个流程。 - 保存接口必须返回 `id`,否则不会进入下游步骤。 - 受众目标默认入库 3 个,关键传播节点默认入库 3 个。 -- 下游脆弱点/策略默认只选择前 2 个受众和前 2 个传播节点继续,可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。 +- 下游受众脆弱点/策略只能从“已在 Step2 分析出并关联关键传播节点”的受众中选择,默认取前 2 个;没有产生传播节点的受众不得继续分析脆弱点和策略。 +- 下游传播节点脆弱点/策略默认只选择前 2 个已入库传播节点继续,可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。 - 每个被选中的对象默认最多生成 3 个脆弱点、3 个策略。 - 模型流式 `answer` 会自动拼接。 - 流式模型接口第一次返回空响应、`y模型没有响应`、`模型没有响应` 或超时类字符串时,会自动再调用一次;仍失败才写入 `errors`。 diff --git a/skills-data/xdfx-single-agent-full-flow/SKILL.md b/skills-data/xdfx-single-agent-full-flow/SKILL.md index 2cdce48e..b0a45773 100644 --- a/skills-data/xdfx-single-agent-full-flow/SKILL.md +++ b/skills-data/xdfx-single-agent-full-flow/SKILL.md @@ -86,7 +86,8 @@ python scripts/xdfx_flow.py --mode full --input xdfx-input.json --dry-run - 受众目标默认入库最优 3 个,可用 `--max-audiences` 调整。 - 关键传播节点全局去重后默认入库最优 3 个,可用 `--max-pathways` 调整。 -- 下游脆弱点/策略默认只从已入库结果中选择前 2 个受众、前 2 个传播节点继续,可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。 +- 下游受众脆弱点/策略只能从“已在 Step2 分析出并关联关键传播节点”的受众中选择,默认取前 2 个;没有产生传播节点的受众不得继续分析脆弱点和策略。 +- 下游传播节点脆弱点/策略默认从已入库传播节点中选择前 2 个继续;可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。 - 每个被选中的受众/传播节点默认生成最多 3 个脆弱点,可用 `--max-vulns` 调整。 - 每个被选中的受众/传播节点默认生成最多 3 个策略,可用 `--max-strategies` 调整。 - 每个保存接口必须拿到返回 `id` 才进入下游。 diff --git a/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_flow.py b/skills-data/xdfx-single-agent-full-flow/scripts/xdfx_flow.py index d9580e2f..8e62e51e 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 @@ -368,6 +368,7 @@ def default_state() -> dict[str, Any]: "topAudiences": [], "pathwayCandidates": [], "topPathways": [], + "audiencesWithPathways": [], "audienceVulns": {}, "pathwayVulns": {}, "audienceStrategies": [], @@ -2055,6 +2056,88 @@ def selected_accounts_for_item(item: dict[str, Any], *, audience: bool) -> list[ return [item.get("account") or item] +def id_ref_key(value: Any) -> str: + number = as_int(value) + if number is not None: + return str(number) + return str(value or "").strip() + + +def audience_name_key(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")).lower() + + +def compact_pathway_ref(pathway: dict[str, Any]) -> dict[str, Any]: + return { + "pathwayAccountId": pathway.get("pathwayAccountId") or pathway.get("pathwayId"), + "accountId": pathway.get("accountId"), + "accountName": pathway.get("accountName"), + } + + +def add_unique_pathway(target: list[dict[str, Any]], pathway: dict[str, Any]) -> None: + key = id_ref_key(pathway.get("pathwayAccountId") or pathway.get("pathwayId") or pathway.get("accountId") or pathway.get("accountName")) + if not key: + return + seen = { + id_ref_key(item.get("pathwayAccountId") or item.get("pathwayId") or item.get("accountId") or item.get("accountName")) + for item in target + } + if key not in seen: + target.append(pathway) + + +def audience_pathway_pairs(state: dict[str, Any]) -> list[tuple[dict[str, Any], list[dict[str, Any]]]]: + """Return only audiences that produced at least one saved key pathway.""" + id_links: dict[str, list[dict[str, Any]]] = {} + name_links: dict[str, list[dict[str, Any]]] = {} + for pathway in state.get("topPathways") or []: + if not isinstance(pathway, dict): + continue + for design_id in pathway.get("designIds") or []: + key = id_ref_key(design_id) + if key: + id_links.setdefault(key, []).append(pathway) + for name in pathway.get("relatedAudiences") or []: + key = audience_name_key(name) + if key: + name_links.setdefault(key, []).append(pathway) + + pairs: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [] + for audience_item in state.get("topAudiences") or []: + if not isinstance(audience_item, dict): + continue + linked: list[dict[str, Any]] = [] + design_key = id_ref_key(audience_item.get("designId")) + for pathway in id_links.get(design_key, []): + add_unique_pathway(linked, pathway) + name_key = audience_name_key(audience_item.get("targetAudience")) + for pathway in name_links.get(name_key, []): + add_unique_pathway(linked, pathway) + if linked: + item = {**audience_item, "downstreamPathways": [compact_pathway_ref(pathway) for pathway in linked]} + pairs.append((item, linked)) + return pairs + + +def audience_pathway_summary(state: dict[str, Any]) -> list[dict[str, Any]]: + summary: list[dict[str, Any]] = [] + for audience_item, pathways in audience_pathway_pairs(state): + summary.append( + { + "designId": audience_item.get("designId"), + "targetAudience": audience_item.get("targetAudience"), + "pathwayCount": len(pathways), + "pathways": [compact_pathway_ref(pathway) for pathway in pathways], + } + ) + return summary + + +def has_audiences_with_pathways(state: dict[str, Any]) -> bool: + return bool(audience_pathway_pairs(state)) + + 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 @@ -2066,22 +2149,35 @@ def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, aud 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)] + all_items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)] + if audience: + state["audiencesWithPathways"] = audience_pathway_summary(state) + items = [item for item, _ in audience_pathway_pairs(state)] + if not items: + state[selected_key] = [] + record_error(state, stage, "没有已分析出关键传播节点的受众,不能继续分析受众脆弱点或策略;请先重试关键传播节点分析") + return [] + else: + items = all_items limit = downstream_limit(ctx, audience=audience) selected = items[:limit] - state[selected_key] = [ - { + selected_rows: list[dict[str, Any]] = [] + for item in selected: + row = { "id": item.get(id_key), "name": item.get("targetAudience") if audience else item.get("accountName"), } - for item in selected - ] - if items: + if audience: + row["pathwayCount"] = len(item.get("downstreamPathways") or []) + row["pathways"] = item.get("downstreamPathways") or [] + selected_rows.append(row) + state[selected_key] = selected_rows + if selected: record_event( state, stage, - f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}", - {"limit": limit, "selected": state[selected_key]}, + f"下游分析选择 {len(selected)}/{len(items)} 个{'已产生传播节点的受众' if audience else '传播节点'}", + {"limit": limit, "selected": state[selected_key], "candidateCount": len(items), "totalStoredCount": len(all_items)}, ) return selected @@ -2183,6 +2279,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: record_error(state, stage, "没有已入库受众,无法分析传播节点") return if state.get("topPathways") and not ctx.dry_run: + state["audiencesWithPathways"] = audience_pathway_summary(state) record_event(state, stage, "topPathways 已存在,跳过。如需重跑请清空 state 中该字段。") return all_candidates: list[dict[str, Any]] = [] @@ -2278,9 +2375,10 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: http_post_json(ctx, state, "save_relation", relation_payload, stage + "/save-relation") saved.append(item) state["topPathways"] = saved + state["audiencesWithPathways"] = audience_pathway_summary(state) if saved: mark_step_status(ctx, state, ["step-analysis-keypoint", "step-analysis-weakpoint"], stage + "/step-status") - record_event(state, stage, f"完成,保存传播节点 {len(saved)} 个") + record_event(state, stage, f"完成,保存传播节点 {len(saved)} 个,可继续下游分析的受众 {len(state.get('audiencesWithPathways') or [])} 个") def run_step3(ctx: FlowContext, state: dict[str, Any]) -> None: diff --git a/skills-data/xdfx-split-controller/SKILL.md b/skills-data/xdfx-split-controller/SKILL.md index 1ee180ab..71ad1d51 100644 --- a/skills-data/xdfx-split-controller/SKILL.md +++ b/skills-data/xdfx-split-controller/SKILL.md @@ -77,8 +77,9 @@ python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow- - Step1 的受众目标抽取结果里,`用户编号列表` / `编号id` / `id` 必须对应上一轮 `zhanghao` 账号数组的索引值;脚本会按这个索引依次找到账号并保存受众账号关系。 - Step2 成功后会写入 `topPathways`。 - Step1 默认入库最优 3 个受众;Step2 默认入库最优 3 个传播节点。 -- Step3/Step4 默认只选择前 2 个已入库受众/传播节点继续,每个生成最多 3 个脆弱点;可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。 -- Step5/Step6 默认对上述选中的每个受众/传播节点生成最多 3 个策略。 +- Step3/Step5 的受众下游分析只能从“已在 Step2 分析出并关联关键传播节点”的受众中选择,默认取前 2 个;没有产生传播节点的受众不得继续分析脆弱点和策略。 +- Step4/Step6 的传播节点下游分析默认从已入库传播节点中选择前 2 个继续;可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。 +- Step3-Step6 对上述选中的每个受众/传播节点默认生成最多 3 个脆弱点或策略。 - 各步骤会同步前端 `task/task-action.step`;需要禁用时每条命令追加 `--no-step-status`。 - 策略步骤会同步写入 channel、narrate、message、策略与受众/传播节点关系表,保证原前端策略表格/编排页可继续读取。 - 策略步骤保存 `信息形式/模态` 时,必须使用脚本读取 `sys/sys-dict-data` 的 `modelType` 字典并把文字转成 value 逗号串,不能把“图片,视频”等文字直接写入 `task-action-message.modelType`。 diff --git a/skills-data/xdfx-split-controller/scripts/xdfx_flow.py b/skills-data/xdfx-split-controller/scripts/xdfx_flow.py index d9580e2f..8e62e51e 100644 --- a/skills-data/xdfx-split-controller/scripts/xdfx_flow.py +++ b/skills-data/xdfx-split-controller/scripts/xdfx_flow.py @@ -368,6 +368,7 @@ def default_state() -> dict[str, Any]: "topAudiences": [], "pathwayCandidates": [], "topPathways": [], + "audiencesWithPathways": [], "audienceVulns": {}, "pathwayVulns": {}, "audienceStrategies": [], @@ -2055,6 +2056,88 @@ def selected_accounts_for_item(item: dict[str, Any], *, audience: bool) -> list[ return [item.get("account") or item] +def id_ref_key(value: Any) -> str: + number = as_int(value) + if number is not None: + return str(number) + return str(value or "").strip() + + +def audience_name_key(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")).lower() + + +def compact_pathway_ref(pathway: dict[str, Any]) -> dict[str, Any]: + return { + "pathwayAccountId": pathway.get("pathwayAccountId") or pathway.get("pathwayId"), + "accountId": pathway.get("accountId"), + "accountName": pathway.get("accountName"), + } + + +def add_unique_pathway(target: list[dict[str, Any]], pathway: dict[str, Any]) -> None: + key = id_ref_key(pathway.get("pathwayAccountId") or pathway.get("pathwayId") or pathway.get("accountId") or pathway.get("accountName")) + if not key: + return + seen = { + id_ref_key(item.get("pathwayAccountId") or item.get("pathwayId") or item.get("accountId") or item.get("accountName")) + for item in target + } + if key not in seen: + target.append(pathway) + + +def audience_pathway_pairs(state: dict[str, Any]) -> list[tuple[dict[str, Any], list[dict[str, Any]]]]: + """Return only audiences that produced at least one saved key pathway.""" + id_links: dict[str, list[dict[str, Any]]] = {} + name_links: dict[str, list[dict[str, Any]]] = {} + for pathway in state.get("topPathways") or []: + if not isinstance(pathway, dict): + continue + for design_id in pathway.get("designIds") or []: + key = id_ref_key(design_id) + if key: + id_links.setdefault(key, []).append(pathway) + for name in pathway.get("relatedAudiences") or []: + key = audience_name_key(name) + if key: + name_links.setdefault(key, []).append(pathway) + + pairs: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [] + for audience_item in state.get("topAudiences") or []: + if not isinstance(audience_item, dict): + continue + linked: list[dict[str, Any]] = [] + design_key = id_ref_key(audience_item.get("designId")) + for pathway in id_links.get(design_key, []): + add_unique_pathway(linked, pathway) + name_key = audience_name_key(audience_item.get("targetAudience")) + for pathway in name_links.get(name_key, []): + add_unique_pathway(linked, pathway) + if linked: + item = {**audience_item, "downstreamPathways": [compact_pathway_ref(pathway) for pathway in linked]} + pairs.append((item, linked)) + return pairs + + +def audience_pathway_summary(state: dict[str, Any]) -> list[dict[str, Any]]: + summary: list[dict[str, Any]] = [] + for audience_item, pathways in audience_pathway_pairs(state): + summary.append( + { + "designId": audience_item.get("designId"), + "targetAudience": audience_item.get("targetAudience"), + "pathwayCount": len(pathways), + "pathways": [compact_pathway_ref(pathway) for pathway in pathways], + } + ) + return summary + + +def has_audiences_with_pathways(state: dict[str, Any]) -> bool: + return bool(audience_pathway_pairs(state)) + + 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 @@ -2066,22 +2149,35 @@ def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, aud 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)] + all_items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)] + if audience: + state["audiencesWithPathways"] = audience_pathway_summary(state) + items = [item for item, _ in audience_pathway_pairs(state)] + if not items: + state[selected_key] = [] + record_error(state, stage, "没有已分析出关键传播节点的受众,不能继续分析受众脆弱点或策略;请先重试关键传播节点分析") + return [] + else: + items = all_items limit = downstream_limit(ctx, audience=audience) selected = items[:limit] - state[selected_key] = [ - { + selected_rows: list[dict[str, Any]] = [] + for item in selected: + row = { "id": item.get(id_key), "name": item.get("targetAudience") if audience else item.get("accountName"), } - for item in selected - ] - if items: + if audience: + row["pathwayCount"] = len(item.get("downstreamPathways") or []) + row["pathways"] = item.get("downstreamPathways") or [] + selected_rows.append(row) + state[selected_key] = selected_rows + if selected: record_event( state, stage, - f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}", - {"limit": limit, "selected": state[selected_key]}, + f"下游分析选择 {len(selected)}/{len(items)} 个{'已产生传播节点的受众' if audience else '传播节点'}", + {"limit": limit, "selected": state[selected_key], "candidateCount": len(items), "totalStoredCount": len(all_items)}, ) return selected @@ -2183,6 +2279,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: record_error(state, stage, "没有已入库受众,无法分析传播节点") return if state.get("topPathways") and not ctx.dry_run: + state["audiencesWithPathways"] = audience_pathway_summary(state) record_event(state, stage, "topPathways 已存在,跳过。如需重跑请清空 state 中该字段。") return all_candidates: list[dict[str, Any]] = [] @@ -2278,9 +2375,10 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: http_post_json(ctx, state, "save_relation", relation_payload, stage + "/save-relation") saved.append(item) state["topPathways"] = saved + state["audiencesWithPathways"] = audience_pathway_summary(state) if saved: mark_step_status(ctx, state, ["step-analysis-keypoint", "step-analysis-weakpoint"], stage + "/step-status") - record_event(state, stage, f"完成,保存传播节点 {len(saved)} 个") + record_event(state, stage, f"完成,保存传播节点 {len(saved)} 个,可继续下游分析的受众 {len(state.get('audiencesWithPathways') or [])} 个") def run_step3(ctx: FlowContext, state: dict[str, Any]) -> None: diff --git a/skills-data/xdfx-split-flow-skill-guide.md b/skills-data/xdfx-split-flow-skill-guide.md index 62d52a6e..f09c6203 100644 --- a/skills-data/xdfx-split-flow-skill-guide.md +++ b/skills-data/xdfx-split-flow-skill-guide.md @@ -134,8 +134,9 @@ python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow- - Step1 入库最优 3 个受众。 - Step2 入库最优 3 个传播节点。 -- Step3/Step4 只从已入库结果中默认选择前 2 个受众/传播节点继续,每个生成最多 3 个脆弱点;可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。 -- Step5/Step6 对上述选中的每个受众/传播节点默认生成最多 3 个策略。 +- Step3/Step5 的受众下游分析只能从“已在 Step2 分析出并关联关键传播节点”的受众中选择,默认取前 2 个;没有产生传播节点的受众不得继续分析脆弱点和策略。 +- Step4/Step6 的传播节点下游分析默认从已入库传播节点中选择前 2 个继续;可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。 +- Step3-Step6 对上述选中的每个受众/传播节点默认生成最多 3 个脆弱点或策略。 - Step5/Step6 的策略模态必须按前端 `modelType` 字典保存为 value 逗号串;脚本会自动把“图片,视频/文本,图片”等文字转成 `task-action-message.modelType` 需要的值。 总控反馈用户时建议使用这种格式: diff --git a/skills-data/xdfx-step1-audience/scripts/xdfx_flow.py b/skills-data/xdfx-step1-audience/scripts/xdfx_flow.py index d9580e2f..8e62e51e 100644 --- a/skills-data/xdfx-step1-audience/scripts/xdfx_flow.py +++ b/skills-data/xdfx-step1-audience/scripts/xdfx_flow.py @@ -368,6 +368,7 @@ def default_state() -> dict[str, Any]: "topAudiences": [], "pathwayCandidates": [], "topPathways": [], + "audiencesWithPathways": [], "audienceVulns": {}, "pathwayVulns": {}, "audienceStrategies": [], @@ -2055,6 +2056,88 @@ def selected_accounts_for_item(item: dict[str, Any], *, audience: bool) -> list[ return [item.get("account") or item] +def id_ref_key(value: Any) -> str: + number = as_int(value) + if number is not None: + return str(number) + return str(value or "").strip() + + +def audience_name_key(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")).lower() + + +def compact_pathway_ref(pathway: dict[str, Any]) -> dict[str, Any]: + return { + "pathwayAccountId": pathway.get("pathwayAccountId") or pathway.get("pathwayId"), + "accountId": pathway.get("accountId"), + "accountName": pathway.get("accountName"), + } + + +def add_unique_pathway(target: list[dict[str, Any]], pathway: dict[str, Any]) -> None: + key = id_ref_key(pathway.get("pathwayAccountId") or pathway.get("pathwayId") or pathway.get("accountId") or pathway.get("accountName")) + if not key: + return + seen = { + id_ref_key(item.get("pathwayAccountId") or item.get("pathwayId") or item.get("accountId") or item.get("accountName")) + for item in target + } + if key not in seen: + target.append(pathway) + + +def audience_pathway_pairs(state: dict[str, Any]) -> list[tuple[dict[str, Any], list[dict[str, Any]]]]: + """Return only audiences that produced at least one saved key pathway.""" + id_links: dict[str, list[dict[str, Any]]] = {} + name_links: dict[str, list[dict[str, Any]]] = {} + for pathway in state.get("topPathways") or []: + if not isinstance(pathway, dict): + continue + for design_id in pathway.get("designIds") or []: + key = id_ref_key(design_id) + if key: + id_links.setdefault(key, []).append(pathway) + for name in pathway.get("relatedAudiences") or []: + key = audience_name_key(name) + if key: + name_links.setdefault(key, []).append(pathway) + + pairs: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [] + for audience_item in state.get("topAudiences") or []: + if not isinstance(audience_item, dict): + continue + linked: list[dict[str, Any]] = [] + design_key = id_ref_key(audience_item.get("designId")) + for pathway in id_links.get(design_key, []): + add_unique_pathway(linked, pathway) + name_key = audience_name_key(audience_item.get("targetAudience")) + for pathway in name_links.get(name_key, []): + add_unique_pathway(linked, pathway) + if linked: + item = {**audience_item, "downstreamPathways": [compact_pathway_ref(pathway) for pathway in linked]} + pairs.append((item, linked)) + return pairs + + +def audience_pathway_summary(state: dict[str, Any]) -> list[dict[str, Any]]: + summary: list[dict[str, Any]] = [] + for audience_item, pathways in audience_pathway_pairs(state): + summary.append( + { + "designId": audience_item.get("designId"), + "targetAudience": audience_item.get("targetAudience"), + "pathwayCount": len(pathways), + "pathways": [compact_pathway_ref(pathway) for pathway in pathways], + } + ) + return summary + + +def has_audiences_with_pathways(state: dict[str, Any]) -> bool: + return bool(audience_pathway_pairs(state)) + + 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 @@ -2066,22 +2149,35 @@ def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, aud 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)] + all_items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)] + if audience: + state["audiencesWithPathways"] = audience_pathway_summary(state) + items = [item for item, _ in audience_pathway_pairs(state)] + if not items: + state[selected_key] = [] + record_error(state, stage, "没有已分析出关键传播节点的受众,不能继续分析受众脆弱点或策略;请先重试关键传播节点分析") + return [] + else: + items = all_items limit = downstream_limit(ctx, audience=audience) selected = items[:limit] - state[selected_key] = [ - { + selected_rows: list[dict[str, Any]] = [] + for item in selected: + row = { "id": item.get(id_key), "name": item.get("targetAudience") if audience else item.get("accountName"), } - for item in selected - ] - if items: + if audience: + row["pathwayCount"] = len(item.get("downstreamPathways") or []) + row["pathways"] = item.get("downstreamPathways") or [] + selected_rows.append(row) + state[selected_key] = selected_rows + if selected: record_event( state, stage, - f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}", - {"limit": limit, "selected": state[selected_key]}, + f"下游分析选择 {len(selected)}/{len(items)} 个{'已产生传播节点的受众' if audience else '传播节点'}", + {"limit": limit, "selected": state[selected_key], "candidateCount": len(items), "totalStoredCount": len(all_items)}, ) return selected @@ -2183,6 +2279,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: record_error(state, stage, "没有已入库受众,无法分析传播节点") return if state.get("topPathways") and not ctx.dry_run: + state["audiencesWithPathways"] = audience_pathway_summary(state) record_event(state, stage, "topPathways 已存在,跳过。如需重跑请清空 state 中该字段。") return all_candidates: list[dict[str, Any]] = [] @@ -2278,9 +2375,10 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: http_post_json(ctx, state, "save_relation", relation_payload, stage + "/save-relation") saved.append(item) state["topPathways"] = saved + state["audiencesWithPathways"] = audience_pathway_summary(state) if saved: mark_step_status(ctx, state, ["step-analysis-keypoint", "step-analysis-weakpoint"], stage + "/step-status") - record_event(state, stage, f"完成,保存传播节点 {len(saved)} 个") + record_event(state, stage, f"完成,保存传播节点 {len(saved)} 个,可继续下游分析的受众 {len(state.get('audiencesWithPathways') or [])} 个") def run_step3(ctx: FlowContext, state: dict[str, Any]) -> None: diff --git a/skills-data/xdfx-step2-pathway/scripts/xdfx_flow.py b/skills-data/xdfx-step2-pathway/scripts/xdfx_flow.py index d9580e2f..8e62e51e 100644 --- a/skills-data/xdfx-step2-pathway/scripts/xdfx_flow.py +++ b/skills-data/xdfx-step2-pathway/scripts/xdfx_flow.py @@ -368,6 +368,7 @@ def default_state() -> dict[str, Any]: "topAudiences": [], "pathwayCandidates": [], "topPathways": [], + "audiencesWithPathways": [], "audienceVulns": {}, "pathwayVulns": {}, "audienceStrategies": [], @@ -2055,6 +2056,88 @@ def selected_accounts_for_item(item: dict[str, Any], *, audience: bool) -> list[ return [item.get("account") or item] +def id_ref_key(value: Any) -> str: + number = as_int(value) + if number is not None: + return str(number) + return str(value or "").strip() + + +def audience_name_key(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")).lower() + + +def compact_pathway_ref(pathway: dict[str, Any]) -> dict[str, Any]: + return { + "pathwayAccountId": pathway.get("pathwayAccountId") or pathway.get("pathwayId"), + "accountId": pathway.get("accountId"), + "accountName": pathway.get("accountName"), + } + + +def add_unique_pathway(target: list[dict[str, Any]], pathway: dict[str, Any]) -> None: + key = id_ref_key(pathway.get("pathwayAccountId") or pathway.get("pathwayId") or pathway.get("accountId") or pathway.get("accountName")) + if not key: + return + seen = { + id_ref_key(item.get("pathwayAccountId") or item.get("pathwayId") or item.get("accountId") or item.get("accountName")) + for item in target + } + if key not in seen: + target.append(pathway) + + +def audience_pathway_pairs(state: dict[str, Any]) -> list[tuple[dict[str, Any], list[dict[str, Any]]]]: + """Return only audiences that produced at least one saved key pathway.""" + id_links: dict[str, list[dict[str, Any]]] = {} + name_links: dict[str, list[dict[str, Any]]] = {} + for pathway in state.get("topPathways") or []: + if not isinstance(pathway, dict): + continue + for design_id in pathway.get("designIds") or []: + key = id_ref_key(design_id) + if key: + id_links.setdefault(key, []).append(pathway) + for name in pathway.get("relatedAudiences") or []: + key = audience_name_key(name) + if key: + name_links.setdefault(key, []).append(pathway) + + pairs: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [] + for audience_item in state.get("topAudiences") or []: + if not isinstance(audience_item, dict): + continue + linked: list[dict[str, Any]] = [] + design_key = id_ref_key(audience_item.get("designId")) + for pathway in id_links.get(design_key, []): + add_unique_pathway(linked, pathway) + name_key = audience_name_key(audience_item.get("targetAudience")) + for pathway in name_links.get(name_key, []): + add_unique_pathway(linked, pathway) + if linked: + item = {**audience_item, "downstreamPathways": [compact_pathway_ref(pathway) for pathway in linked]} + pairs.append((item, linked)) + return pairs + + +def audience_pathway_summary(state: dict[str, Any]) -> list[dict[str, Any]]: + summary: list[dict[str, Any]] = [] + for audience_item, pathways in audience_pathway_pairs(state): + summary.append( + { + "designId": audience_item.get("designId"), + "targetAudience": audience_item.get("targetAudience"), + "pathwayCount": len(pathways), + "pathways": [compact_pathway_ref(pathway) for pathway in pathways], + } + ) + return summary + + +def has_audiences_with_pathways(state: dict[str, Any]) -> bool: + return bool(audience_pathway_pairs(state)) + + 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 @@ -2066,22 +2149,35 @@ def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, aud 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)] + all_items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)] + if audience: + state["audiencesWithPathways"] = audience_pathway_summary(state) + items = [item for item, _ in audience_pathway_pairs(state)] + if not items: + state[selected_key] = [] + record_error(state, stage, "没有已分析出关键传播节点的受众,不能继续分析受众脆弱点或策略;请先重试关键传播节点分析") + return [] + else: + items = all_items limit = downstream_limit(ctx, audience=audience) selected = items[:limit] - state[selected_key] = [ - { + selected_rows: list[dict[str, Any]] = [] + for item in selected: + row = { "id": item.get(id_key), "name": item.get("targetAudience") if audience else item.get("accountName"), } - for item in selected - ] - if items: + if audience: + row["pathwayCount"] = len(item.get("downstreamPathways") or []) + row["pathways"] = item.get("downstreamPathways") or [] + selected_rows.append(row) + state[selected_key] = selected_rows + if selected: record_event( state, stage, - f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}", - {"limit": limit, "selected": state[selected_key]}, + f"下游分析选择 {len(selected)}/{len(items)} 个{'已产生传播节点的受众' if audience else '传播节点'}", + {"limit": limit, "selected": state[selected_key], "candidateCount": len(items), "totalStoredCount": len(all_items)}, ) return selected @@ -2183,6 +2279,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: record_error(state, stage, "没有已入库受众,无法分析传播节点") return if state.get("topPathways") and not ctx.dry_run: + state["audiencesWithPathways"] = audience_pathway_summary(state) record_event(state, stage, "topPathways 已存在,跳过。如需重跑请清空 state 中该字段。") return all_candidates: list[dict[str, Any]] = [] @@ -2278,9 +2375,10 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: http_post_json(ctx, state, "save_relation", relation_payload, stage + "/save-relation") saved.append(item) state["topPathways"] = saved + state["audiencesWithPathways"] = audience_pathway_summary(state) if saved: mark_step_status(ctx, state, ["step-analysis-keypoint", "step-analysis-weakpoint"], stage + "/step-status") - record_event(state, stage, f"完成,保存传播节点 {len(saved)} 个") + record_event(state, stage, f"完成,保存传播节点 {len(saved)} 个,可继续下游分析的受众 {len(state.get('audiencesWithPathways') or [])} 个") def run_step3(ctx: FlowContext, state: dict[str, Any]) -> None: 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 d9580e2f..8e62e51e 100644 --- a/skills-data/xdfx-step3-audience-vulns/scripts/xdfx_flow.py +++ b/skills-data/xdfx-step3-audience-vulns/scripts/xdfx_flow.py @@ -368,6 +368,7 @@ def default_state() -> dict[str, Any]: "topAudiences": [], "pathwayCandidates": [], "topPathways": [], + "audiencesWithPathways": [], "audienceVulns": {}, "pathwayVulns": {}, "audienceStrategies": [], @@ -2055,6 +2056,88 @@ def selected_accounts_for_item(item: dict[str, Any], *, audience: bool) -> list[ return [item.get("account") or item] +def id_ref_key(value: Any) -> str: + number = as_int(value) + if number is not None: + return str(number) + return str(value or "").strip() + + +def audience_name_key(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")).lower() + + +def compact_pathway_ref(pathway: dict[str, Any]) -> dict[str, Any]: + return { + "pathwayAccountId": pathway.get("pathwayAccountId") or pathway.get("pathwayId"), + "accountId": pathway.get("accountId"), + "accountName": pathway.get("accountName"), + } + + +def add_unique_pathway(target: list[dict[str, Any]], pathway: dict[str, Any]) -> None: + key = id_ref_key(pathway.get("pathwayAccountId") or pathway.get("pathwayId") or pathway.get("accountId") or pathway.get("accountName")) + if not key: + return + seen = { + id_ref_key(item.get("pathwayAccountId") or item.get("pathwayId") or item.get("accountId") or item.get("accountName")) + for item in target + } + if key not in seen: + target.append(pathway) + + +def audience_pathway_pairs(state: dict[str, Any]) -> list[tuple[dict[str, Any], list[dict[str, Any]]]]: + """Return only audiences that produced at least one saved key pathway.""" + id_links: dict[str, list[dict[str, Any]]] = {} + name_links: dict[str, list[dict[str, Any]]] = {} + for pathway in state.get("topPathways") or []: + if not isinstance(pathway, dict): + continue + for design_id in pathway.get("designIds") or []: + key = id_ref_key(design_id) + if key: + id_links.setdefault(key, []).append(pathway) + for name in pathway.get("relatedAudiences") or []: + key = audience_name_key(name) + if key: + name_links.setdefault(key, []).append(pathway) + + pairs: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [] + for audience_item in state.get("topAudiences") or []: + if not isinstance(audience_item, dict): + continue + linked: list[dict[str, Any]] = [] + design_key = id_ref_key(audience_item.get("designId")) + for pathway in id_links.get(design_key, []): + add_unique_pathway(linked, pathway) + name_key = audience_name_key(audience_item.get("targetAudience")) + for pathway in name_links.get(name_key, []): + add_unique_pathway(linked, pathway) + if linked: + item = {**audience_item, "downstreamPathways": [compact_pathway_ref(pathway) for pathway in linked]} + pairs.append((item, linked)) + return pairs + + +def audience_pathway_summary(state: dict[str, Any]) -> list[dict[str, Any]]: + summary: list[dict[str, Any]] = [] + for audience_item, pathways in audience_pathway_pairs(state): + summary.append( + { + "designId": audience_item.get("designId"), + "targetAudience": audience_item.get("targetAudience"), + "pathwayCount": len(pathways), + "pathways": [compact_pathway_ref(pathway) for pathway in pathways], + } + ) + return summary + + +def has_audiences_with_pathways(state: dict[str, Any]) -> bool: + return bool(audience_pathway_pairs(state)) + + 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 @@ -2066,22 +2149,35 @@ def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, aud 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)] + all_items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)] + if audience: + state["audiencesWithPathways"] = audience_pathway_summary(state) + items = [item for item, _ in audience_pathway_pairs(state)] + if not items: + state[selected_key] = [] + record_error(state, stage, "没有已分析出关键传播节点的受众,不能继续分析受众脆弱点或策略;请先重试关键传播节点分析") + return [] + else: + items = all_items limit = downstream_limit(ctx, audience=audience) selected = items[:limit] - state[selected_key] = [ - { + selected_rows: list[dict[str, Any]] = [] + for item in selected: + row = { "id": item.get(id_key), "name": item.get("targetAudience") if audience else item.get("accountName"), } - for item in selected - ] - if items: + if audience: + row["pathwayCount"] = len(item.get("downstreamPathways") or []) + row["pathways"] = item.get("downstreamPathways") or [] + selected_rows.append(row) + state[selected_key] = selected_rows + if selected: record_event( state, stage, - f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}", - {"limit": limit, "selected": state[selected_key]}, + f"下游分析选择 {len(selected)}/{len(items)} 个{'已产生传播节点的受众' if audience else '传播节点'}", + {"limit": limit, "selected": state[selected_key], "candidateCount": len(items), "totalStoredCount": len(all_items)}, ) return selected @@ -2183,6 +2279,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: record_error(state, stage, "没有已入库受众,无法分析传播节点") return if state.get("topPathways") and not ctx.dry_run: + state["audiencesWithPathways"] = audience_pathway_summary(state) record_event(state, stage, "topPathways 已存在,跳过。如需重跑请清空 state 中该字段。") return all_candidates: list[dict[str, Any]] = [] @@ -2278,9 +2375,10 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: http_post_json(ctx, state, "save_relation", relation_payload, stage + "/save-relation") saved.append(item) state["topPathways"] = saved + state["audiencesWithPathways"] = audience_pathway_summary(state) if saved: mark_step_status(ctx, state, ["step-analysis-keypoint", "step-analysis-weakpoint"], stage + "/step-status") - record_event(state, stage, f"完成,保存传播节点 {len(saved)} 个") + record_event(state, stage, f"完成,保存传播节点 {len(saved)} 个,可继续下游分析的受众 {len(state.get('audiencesWithPathways') or [])} 个") def run_step3(ctx: FlowContext, state: dict[str, Any]) -> None: 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 d9580e2f..8e62e51e 100644 --- a/skills-data/xdfx-step4-pathway-vulns/scripts/xdfx_flow.py +++ b/skills-data/xdfx-step4-pathway-vulns/scripts/xdfx_flow.py @@ -368,6 +368,7 @@ def default_state() -> dict[str, Any]: "topAudiences": [], "pathwayCandidates": [], "topPathways": [], + "audiencesWithPathways": [], "audienceVulns": {}, "pathwayVulns": {}, "audienceStrategies": [], @@ -2055,6 +2056,88 @@ def selected_accounts_for_item(item: dict[str, Any], *, audience: bool) -> list[ return [item.get("account") or item] +def id_ref_key(value: Any) -> str: + number = as_int(value) + if number is not None: + return str(number) + return str(value or "").strip() + + +def audience_name_key(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")).lower() + + +def compact_pathway_ref(pathway: dict[str, Any]) -> dict[str, Any]: + return { + "pathwayAccountId": pathway.get("pathwayAccountId") or pathway.get("pathwayId"), + "accountId": pathway.get("accountId"), + "accountName": pathway.get("accountName"), + } + + +def add_unique_pathway(target: list[dict[str, Any]], pathway: dict[str, Any]) -> None: + key = id_ref_key(pathway.get("pathwayAccountId") or pathway.get("pathwayId") or pathway.get("accountId") or pathway.get("accountName")) + if not key: + return + seen = { + id_ref_key(item.get("pathwayAccountId") or item.get("pathwayId") or item.get("accountId") or item.get("accountName")) + for item in target + } + if key not in seen: + target.append(pathway) + + +def audience_pathway_pairs(state: dict[str, Any]) -> list[tuple[dict[str, Any], list[dict[str, Any]]]]: + """Return only audiences that produced at least one saved key pathway.""" + id_links: dict[str, list[dict[str, Any]]] = {} + name_links: dict[str, list[dict[str, Any]]] = {} + for pathway in state.get("topPathways") or []: + if not isinstance(pathway, dict): + continue + for design_id in pathway.get("designIds") or []: + key = id_ref_key(design_id) + if key: + id_links.setdefault(key, []).append(pathway) + for name in pathway.get("relatedAudiences") or []: + key = audience_name_key(name) + if key: + name_links.setdefault(key, []).append(pathway) + + pairs: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [] + for audience_item in state.get("topAudiences") or []: + if not isinstance(audience_item, dict): + continue + linked: list[dict[str, Any]] = [] + design_key = id_ref_key(audience_item.get("designId")) + for pathway in id_links.get(design_key, []): + add_unique_pathway(linked, pathway) + name_key = audience_name_key(audience_item.get("targetAudience")) + for pathway in name_links.get(name_key, []): + add_unique_pathway(linked, pathway) + if linked: + item = {**audience_item, "downstreamPathways": [compact_pathway_ref(pathway) for pathway in linked]} + pairs.append((item, linked)) + return pairs + + +def audience_pathway_summary(state: dict[str, Any]) -> list[dict[str, Any]]: + summary: list[dict[str, Any]] = [] + for audience_item, pathways in audience_pathway_pairs(state): + summary.append( + { + "designId": audience_item.get("designId"), + "targetAudience": audience_item.get("targetAudience"), + "pathwayCount": len(pathways), + "pathways": [compact_pathway_ref(pathway) for pathway in pathways], + } + ) + return summary + + +def has_audiences_with_pathways(state: dict[str, Any]) -> bool: + return bool(audience_pathway_pairs(state)) + + 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 @@ -2066,22 +2149,35 @@ def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, aud 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)] + all_items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)] + if audience: + state["audiencesWithPathways"] = audience_pathway_summary(state) + items = [item for item, _ in audience_pathway_pairs(state)] + if not items: + state[selected_key] = [] + record_error(state, stage, "没有已分析出关键传播节点的受众,不能继续分析受众脆弱点或策略;请先重试关键传播节点分析") + return [] + else: + items = all_items limit = downstream_limit(ctx, audience=audience) selected = items[:limit] - state[selected_key] = [ - { + selected_rows: list[dict[str, Any]] = [] + for item in selected: + row = { "id": item.get(id_key), "name": item.get("targetAudience") if audience else item.get("accountName"), } - for item in selected - ] - if items: + if audience: + row["pathwayCount"] = len(item.get("downstreamPathways") or []) + row["pathways"] = item.get("downstreamPathways") or [] + selected_rows.append(row) + state[selected_key] = selected_rows + if selected: record_event( state, stage, - f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}", - {"limit": limit, "selected": state[selected_key]}, + f"下游分析选择 {len(selected)}/{len(items)} 个{'已产生传播节点的受众' if audience else '传播节点'}", + {"limit": limit, "selected": state[selected_key], "candidateCount": len(items), "totalStoredCount": len(all_items)}, ) return selected @@ -2183,6 +2279,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: record_error(state, stage, "没有已入库受众,无法分析传播节点") return if state.get("topPathways") and not ctx.dry_run: + state["audiencesWithPathways"] = audience_pathway_summary(state) record_event(state, stage, "topPathways 已存在,跳过。如需重跑请清空 state 中该字段。") return all_candidates: list[dict[str, Any]] = [] @@ -2278,9 +2375,10 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: http_post_json(ctx, state, "save_relation", relation_payload, stage + "/save-relation") saved.append(item) state["topPathways"] = saved + state["audiencesWithPathways"] = audience_pathway_summary(state) if saved: mark_step_status(ctx, state, ["step-analysis-keypoint", "step-analysis-weakpoint"], stage + "/step-status") - record_event(state, stage, f"完成,保存传播节点 {len(saved)} 个") + record_event(state, stage, f"完成,保存传播节点 {len(saved)} 个,可继续下游分析的受众 {len(state.get('audiencesWithPathways') or [])} 个") def run_step3(ctx: FlowContext, state: dict[str, Any]) -> None: 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 d9580e2f..8e62e51e 100644 --- a/skills-data/xdfx-step5-audience-strategy/scripts/xdfx_flow.py +++ b/skills-data/xdfx-step5-audience-strategy/scripts/xdfx_flow.py @@ -368,6 +368,7 @@ def default_state() -> dict[str, Any]: "topAudiences": [], "pathwayCandidates": [], "topPathways": [], + "audiencesWithPathways": [], "audienceVulns": {}, "pathwayVulns": {}, "audienceStrategies": [], @@ -2055,6 +2056,88 @@ def selected_accounts_for_item(item: dict[str, Any], *, audience: bool) -> list[ return [item.get("account") or item] +def id_ref_key(value: Any) -> str: + number = as_int(value) + if number is not None: + return str(number) + return str(value or "").strip() + + +def audience_name_key(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")).lower() + + +def compact_pathway_ref(pathway: dict[str, Any]) -> dict[str, Any]: + return { + "pathwayAccountId": pathway.get("pathwayAccountId") or pathway.get("pathwayId"), + "accountId": pathway.get("accountId"), + "accountName": pathway.get("accountName"), + } + + +def add_unique_pathway(target: list[dict[str, Any]], pathway: dict[str, Any]) -> None: + key = id_ref_key(pathway.get("pathwayAccountId") or pathway.get("pathwayId") or pathway.get("accountId") or pathway.get("accountName")) + if not key: + return + seen = { + id_ref_key(item.get("pathwayAccountId") or item.get("pathwayId") or item.get("accountId") or item.get("accountName")) + for item in target + } + if key not in seen: + target.append(pathway) + + +def audience_pathway_pairs(state: dict[str, Any]) -> list[tuple[dict[str, Any], list[dict[str, Any]]]]: + """Return only audiences that produced at least one saved key pathway.""" + id_links: dict[str, list[dict[str, Any]]] = {} + name_links: dict[str, list[dict[str, Any]]] = {} + for pathway in state.get("topPathways") or []: + if not isinstance(pathway, dict): + continue + for design_id in pathway.get("designIds") or []: + key = id_ref_key(design_id) + if key: + id_links.setdefault(key, []).append(pathway) + for name in pathway.get("relatedAudiences") or []: + key = audience_name_key(name) + if key: + name_links.setdefault(key, []).append(pathway) + + pairs: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [] + for audience_item in state.get("topAudiences") or []: + if not isinstance(audience_item, dict): + continue + linked: list[dict[str, Any]] = [] + design_key = id_ref_key(audience_item.get("designId")) + for pathway in id_links.get(design_key, []): + add_unique_pathway(linked, pathway) + name_key = audience_name_key(audience_item.get("targetAudience")) + for pathway in name_links.get(name_key, []): + add_unique_pathway(linked, pathway) + if linked: + item = {**audience_item, "downstreamPathways": [compact_pathway_ref(pathway) for pathway in linked]} + pairs.append((item, linked)) + return pairs + + +def audience_pathway_summary(state: dict[str, Any]) -> list[dict[str, Any]]: + summary: list[dict[str, Any]] = [] + for audience_item, pathways in audience_pathway_pairs(state): + summary.append( + { + "designId": audience_item.get("designId"), + "targetAudience": audience_item.get("targetAudience"), + "pathwayCount": len(pathways), + "pathways": [compact_pathway_ref(pathway) for pathway in pathways], + } + ) + return summary + + +def has_audiences_with_pathways(state: dict[str, Any]) -> bool: + return bool(audience_pathway_pairs(state)) + + 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 @@ -2066,22 +2149,35 @@ def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, aud 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)] + all_items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)] + if audience: + state["audiencesWithPathways"] = audience_pathway_summary(state) + items = [item for item, _ in audience_pathway_pairs(state)] + if not items: + state[selected_key] = [] + record_error(state, stage, "没有已分析出关键传播节点的受众,不能继续分析受众脆弱点或策略;请先重试关键传播节点分析") + return [] + else: + items = all_items limit = downstream_limit(ctx, audience=audience) selected = items[:limit] - state[selected_key] = [ - { + selected_rows: list[dict[str, Any]] = [] + for item in selected: + row = { "id": item.get(id_key), "name": item.get("targetAudience") if audience else item.get("accountName"), } - for item in selected - ] - if items: + if audience: + row["pathwayCount"] = len(item.get("downstreamPathways") or []) + row["pathways"] = item.get("downstreamPathways") or [] + selected_rows.append(row) + state[selected_key] = selected_rows + if selected: record_event( state, stage, - f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}", - {"limit": limit, "selected": state[selected_key]}, + f"下游分析选择 {len(selected)}/{len(items)} 个{'已产生传播节点的受众' if audience else '传播节点'}", + {"limit": limit, "selected": state[selected_key], "candidateCount": len(items), "totalStoredCount": len(all_items)}, ) return selected @@ -2183,6 +2279,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: record_error(state, stage, "没有已入库受众,无法分析传播节点") return if state.get("topPathways") and not ctx.dry_run: + state["audiencesWithPathways"] = audience_pathway_summary(state) record_event(state, stage, "topPathways 已存在,跳过。如需重跑请清空 state 中该字段。") return all_candidates: list[dict[str, Any]] = [] @@ -2278,9 +2375,10 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: http_post_json(ctx, state, "save_relation", relation_payload, stage + "/save-relation") saved.append(item) state["topPathways"] = saved + state["audiencesWithPathways"] = audience_pathway_summary(state) if saved: mark_step_status(ctx, state, ["step-analysis-keypoint", "step-analysis-weakpoint"], stage + "/step-status") - record_event(state, stage, f"完成,保存传播节点 {len(saved)} 个") + record_event(state, stage, f"完成,保存传播节点 {len(saved)} 个,可继续下游分析的受众 {len(state.get('audiencesWithPathways') or [])} 个") def run_step3(ctx: FlowContext, state: dict[str, Any]) -> None: 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 d9580e2f..8e62e51e 100644 --- a/skills-data/xdfx-step6-pathway-strategy/scripts/xdfx_flow.py +++ b/skills-data/xdfx-step6-pathway-strategy/scripts/xdfx_flow.py @@ -368,6 +368,7 @@ def default_state() -> dict[str, Any]: "topAudiences": [], "pathwayCandidates": [], "topPathways": [], + "audiencesWithPathways": [], "audienceVulns": {}, "pathwayVulns": {}, "audienceStrategies": [], @@ -2055,6 +2056,88 @@ def selected_accounts_for_item(item: dict[str, Any], *, audience: bool) -> list[ return [item.get("account") or item] +def id_ref_key(value: Any) -> str: + number = as_int(value) + if number is not None: + return str(number) + return str(value or "").strip() + + +def audience_name_key(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")).lower() + + +def compact_pathway_ref(pathway: dict[str, Any]) -> dict[str, Any]: + return { + "pathwayAccountId": pathway.get("pathwayAccountId") or pathway.get("pathwayId"), + "accountId": pathway.get("accountId"), + "accountName": pathway.get("accountName"), + } + + +def add_unique_pathway(target: list[dict[str, Any]], pathway: dict[str, Any]) -> None: + key = id_ref_key(pathway.get("pathwayAccountId") or pathway.get("pathwayId") or pathway.get("accountId") or pathway.get("accountName")) + if not key: + return + seen = { + id_ref_key(item.get("pathwayAccountId") or item.get("pathwayId") or item.get("accountId") or item.get("accountName")) + for item in target + } + if key not in seen: + target.append(pathway) + + +def audience_pathway_pairs(state: dict[str, Any]) -> list[tuple[dict[str, Any], list[dict[str, Any]]]]: + """Return only audiences that produced at least one saved key pathway.""" + id_links: dict[str, list[dict[str, Any]]] = {} + name_links: dict[str, list[dict[str, Any]]] = {} + for pathway in state.get("topPathways") or []: + if not isinstance(pathway, dict): + continue + for design_id in pathway.get("designIds") or []: + key = id_ref_key(design_id) + if key: + id_links.setdefault(key, []).append(pathway) + for name in pathway.get("relatedAudiences") or []: + key = audience_name_key(name) + if key: + name_links.setdefault(key, []).append(pathway) + + pairs: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [] + for audience_item in state.get("topAudiences") or []: + if not isinstance(audience_item, dict): + continue + linked: list[dict[str, Any]] = [] + design_key = id_ref_key(audience_item.get("designId")) + for pathway in id_links.get(design_key, []): + add_unique_pathway(linked, pathway) + name_key = audience_name_key(audience_item.get("targetAudience")) + for pathway in name_links.get(name_key, []): + add_unique_pathway(linked, pathway) + if linked: + item = {**audience_item, "downstreamPathways": [compact_pathway_ref(pathway) for pathway in linked]} + pairs.append((item, linked)) + return pairs + + +def audience_pathway_summary(state: dict[str, Any]) -> list[dict[str, Any]]: + summary: list[dict[str, Any]] = [] + for audience_item, pathways in audience_pathway_pairs(state): + summary.append( + { + "designId": audience_item.get("designId"), + "targetAudience": audience_item.get("targetAudience"), + "pathwayCount": len(pathways), + "pathways": [compact_pathway_ref(pathway) for pathway in pathways], + } + ) + return summary + + +def has_audiences_with_pathways(state: dict[str, Any]) -> bool: + return bool(audience_pathway_pairs(state)) + + 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 @@ -2066,22 +2149,35 @@ def downstream_items(ctx: FlowContext, state: dict[str, Any], stage: str, *, aud 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)] + all_items = [item for item in (state.get(source_key) or []) if isinstance(item, dict)] + if audience: + state["audiencesWithPathways"] = audience_pathway_summary(state) + items = [item for item, _ in audience_pathway_pairs(state)] + if not items: + state[selected_key] = [] + record_error(state, stage, "没有已分析出关键传播节点的受众,不能继续分析受众脆弱点或策略;请先重试关键传播节点分析") + return [] + else: + items = all_items limit = downstream_limit(ctx, audience=audience) selected = items[:limit] - state[selected_key] = [ - { + selected_rows: list[dict[str, Any]] = [] + for item in selected: + row = { "id": item.get(id_key), "name": item.get("targetAudience") if audience else item.get("accountName"), } - for item in selected - ] - if items: + if audience: + row["pathwayCount"] = len(item.get("downstreamPathways") or []) + row["pathways"] = item.get("downstreamPathways") or [] + selected_rows.append(row) + state[selected_key] = selected_rows + if selected: record_event( state, stage, - f"下游分析选择 {len(selected)}/{len(items)} 个{'受众' if audience else '传播节点'}", - {"limit": limit, "selected": state[selected_key]}, + f"下游分析选择 {len(selected)}/{len(items)} 个{'已产生传播节点的受众' if audience else '传播节点'}", + {"limit": limit, "selected": state[selected_key], "candidateCount": len(items), "totalStoredCount": len(all_items)}, ) return selected @@ -2183,6 +2279,7 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: record_error(state, stage, "没有已入库受众,无法分析传播节点") return if state.get("topPathways") and not ctx.dry_run: + state["audiencesWithPathways"] = audience_pathway_summary(state) record_event(state, stage, "topPathways 已存在,跳过。如需重跑请清空 state 中该字段。") return all_candidates: list[dict[str, Any]] = [] @@ -2278,9 +2375,10 @@ def run_step2(ctx: FlowContext, state: dict[str, Any]) -> None: http_post_json(ctx, state, "save_relation", relation_payload, stage + "/save-relation") saved.append(item) state["topPathways"] = saved + state["audiencesWithPathways"] = audience_pathway_summary(state) if saved: mark_step_status(ctx, state, ["step-analysis-keypoint", "step-analysis-weakpoint"], stage + "/step-status") - record_event(state, stage, f"完成,保存传播节点 {len(saved)} 个") + record_event(state, stage, f"完成,保存传播节点 {len(saved)} 个,可继续下游分析的受众 {len(state.get('audiencesWithPathways') or [])} 个") def run_step3(ctx: FlowContext, state: dict[str, Any]) -> None: