Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
a4a44e1154
21 files changed
+22521
-110
No files matched your search
@@ -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 ? (
|
||||
<AIWritingCompactAppearanceActions />
|
||||
) : null;
|
||||
|
||||
@@ -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/<id>/`; `_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 "检不出".
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+17
-8
@@ -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,
|
||||
@@ -349,7 +351,10 @@ async def _search_notebook(
|
||||
results: list[Material] = []
|
||||
mat_idx = 0
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
# trust_env=False:笔记后端是内网/本机地址,但模型走公网,部署常设
|
||||
# HTTP_PROXY/HTTPS_PROXY;httpx 默认会把这个内网请求也走代理 → 不可达。
|
||||
# 强制忽略代理 env、直连(与内网 ES 检索同款修法)。
|
||||
async with httpx.AsyncClient(timeout=20, trust_env=False) as client:
|
||||
for kw in keywords[:_NOTEBOOK_MAX_KEYWORDS]:
|
||||
logger.info("[notebook-search] POST kw=%r body=%s", kw, {
|
||||
"query": kw,
|
||||
@@ -617,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 = {
|
||||
@@ -625,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": [],
|
||||
@@ -777,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}")
|
||||
@@ -920,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?}
|
||||
@@ -949,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]
|
||||
|
||||
+4
-2
@@ -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())
|
||||
|
||||
+56
-11
@@ -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,9 +132,22 @@ 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 verify_ssl=%s query=%r",
|
||||
self.endpoint, self.knowledge_base_name, self.timeout, self.verify_ssl, query,
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
# trust_env=False:**关键**。内网 ES 是内网地址,但本部署的模型走公网,
|
||||
# 服务器通常设了 HTTP_PROXY/HTTPS_PROXY 出公网;httpx 默认 trust_env=True
|
||||
# 会把这个内网请求也套用公网代理 → 代理到不了内网 IP → 连接被重置/空回复,
|
||||
# 表现为「接口异常」。强制忽略代理 env、直连内网(与 multi_agent.py 的回环
|
||||
# 调用同款修法)。
|
||||
# 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()
|
||||
@@ -122,23 +155,31 @@ class IntranetSearchProvider(BaseSearchProvider):
|
||||
data = resp.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
# 非 2xx:接口有响应但状态码异常,把响应体一并暴露给前端
|
||||
logger.warning("intranet search failed for query=%r: %s", query, e)
|
||||
body_for_log = e.response.text if e.response is not None else ""
|
||||
logger.warning(
|
||||
"[intranet-search] HTTP error endpoint=%s query=%r status=%s body=%s",
|
||||
self.endpoint, query, getattr(e.response, "status_code", None), _raw_snippet(body_for_log),
|
||||
)
|
||||
message = f"{type(e).__name__}: {e}"
|
||||
self.errors.append(message)
|
||||
body = e.response.text if e.response is not None else ""
|
||||
call.update({
|
||||
"status": "http_error",
|
||||
"error": message,
|
||||
"http_status": getattr(e.response, "status_code", None),
|
||||
})
|
||||
snippet = _raw_snippet(body)
|
||||
snippet = _raw_snippet(body_for_log)
|
||||
if snippet:
|
||||
call["raw_response"] = snippet
|
||||
self._record_raw(body)
|
||||
self._record_raw(body_for_log)
|
||||
return []
|
||||
except Exception as e:
|
||||
# 连接失败 / 超时 / JSON 解析失败等;JSON 解析失败时 raw_text 已是接口响应体
|
||||
logger.warning("intranet search failed for query=%r: %s", query, e)
|
||||
# 连接失败 / 超时 / JSON 解析失败等;JSON 解析失败时 raw_text 已是接口响应体。
|
||||
# 打出异常类型,便于区分 ConnectError / ConnectTimeout / ReadTimeout /
|
||||
# RemoteProtocolError(空回复) / JSONDecodeError(返回非 JSON) 等具体根因。
|
||||
logger.warning(
|
||||
"[intranet-search] request failed endpoint=%s query=%r error=%s: %s",
|
||||
self.endpoint, query, type(e).__name__, e,
|
||||
)
|
||||
message = f"{type(e).__name__}: {e}"
|
||||
self.errors.append(message)
|
||||
call.update({"status": "error", "error": message})
|
||||
@@ -187,7 +228,11 @@ class IntranetSearchProvider(BaseSearchProvider):
|
||||
self._record_raw(raw_text)
|
||||
return []
|
||||
|
||||
results: List[SearchResult] = []
|
||||
logger.info(
|
||||
"[intranet-search] OK endpoint=%s query=%r http_status=%s raw_items=%s",
|
||||
self.endpoint, query, call.get("http_status"), len(items) if isinstance(items, list) else 0,
|
||||
)
|
||||
results: list[SearchResult] = []
|
||||
for i, item in enumerate(items[:max_results]):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
+95
-44
@@ -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:
|
||||
|
||||
+90
@@ -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
|
||||
+63
@@ -267,6 +267,69 @@ TEMPLATES: tuple[TemplateSpec, ...] = (
|
||||
file="no18/no18.html",
|
||||
convertible=False,
|
||||
),
|
||||
TemplateSpec(
|
||||
id="no19",
|
||||
name="no.19",
|
||||
description="",
|
||||
file="no19/no.19.html",
|
||||
convertible=False,
|
||||
),
|
||||
TemplateSpec(
|
||||
id="no20",
|
||||
name="no.20",
|
||||
description="",
|
||||
file="no20/no.20.html",
|
||||
convertible=False,
|
||||
),
|
||||
TemplateSpec(
|
||||
id="no21",
|
||||
name="no.21",
|
||||
description="",
|
||||
file="no21/no.21.html",
|
||||
convertible=False,
|
||||
),
|
||||
TemplateSpec(
|
||||
id="no22",
|
||||
name="no.22",
|
||||
description="",
|
||||
file="no22/no.22.html",
|
||||
convertible=False,
|
||||
),
|
||||
TemplateSpec(
|
||||
id="no23",
|
||||
name="no.23",
|
||||
description="",
|
||||
file="no23/no.23.html",
|
||||
convertible=False,
|
||||
),
|
||||
TemplateSpec(
|
||||
id="no24",
|
||||
name="no.24",
|
||||
description="",
|
||||
file="no24/no.24.html",
|
||||
convertible=False,
|
||||
),
|
||||
TemplateSpec(
|
||||
id="no25",
|
||||
name="no.25",
|
||||
description="",
|
||||
file="no25/no.25.html",
|
||||
convertible=False,
|
||||
),
|
||||
TemplateSpec(
|
||||
id="no26",
|
||||
name="no.26",
|
||||
description="",
|
||||
file="no26/no.26.html",
|
||||
convertible=False,
|
||||
),
|
||||
TemplateSpec(
|
||||
id="no27",
|
||||
name="no.27",
|
||||
description="",
|
||||
file="no27/no.27.html",
|
||||
convertible=False,
|
||||
),
|
||||
)
|
||||
|
||||
_TEMPLATES_BY_ID: dict[str, TemplateSpec] = {t.id: t for t in TEMPLATES}
|
||||
|
||||
+1764
File diff suppressed because it is too large.
Load diff
+4252
File diff suppressed because it is too large.
Load diff
+3833
File diff suppressed because it is too large.
Load diff
+3484
File diff suppressed because it is too large.
Load diff
+2943
File diff suppressed because it is too large.
Load diff
+381
@@ -0,0 +1,381 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>日本涉台介入风险叙事专题研究系统</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #8f1f25;
|
||||
--bg2: #b8323a;
|
||||
--panel: #fff7f3;
|
||||
--card: #ffffff;
|
||||
--ink: #241616;
|
||||
--muted: #76605d;
|
||||
--line: rgba(120, 29, 35, .18);
|
||||
--red: #b7202e;
|
||||
--red-dark: #7b121b;
|
||||
--gold: #f3c969;
|
||||
--soft: #fff2eb;
|
||||
--green: #18724d;
|
||||
--orange: #b35b13;
|
||||
--blue: #275b91;
|
||||
--shadow: 0 18px 50px rgba(58, 9, 15, .18);
|
||||
--radius: 18px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
|
||||
background: radial-gradient(circle at 20% 0%, #d94c4c 0, transparent 24%), linear-gradient(140deg, var(--bg), #641018 70%);
|
||||
color: var(--ink);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.app { max-width: 1480px; margin: 0 auto; padding: 28px; }
|
||||
.hero {
|
||||
color: #fff;
|
||||
padding: 24px 26px 22px;
|
||||
border: 1px solid rgba(255,255,255,.18);
|
||||
background: linear-gradient(135deg, rgba(255,255,255,.14), rgba(255,255,255,.06));
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 26px;
|
||||
box-shadow: var(--shadow);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 420px; height: 420px;
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
border-radius: 50%;
|
||||
right: -160px; top: -220px;
|
||||
}
|
||||
.eyebrow { font-size: 13px; letter-spacing: .18em; opacity: .86; margin-bottom: 9px; }
|
||||
h1 { margin: 0; font-size: clamp(28px, 4vw, 48px); line-height: 1.15; }
|
||||
.subtitle { max-width: 920px; margin-top: 12px; color: rgba(255,255,255,.88); line-height: 1.75; }
|
||||
.hero-meta { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 18px; }
|
||||
.pill { display:inline-flex; align-items:center; gap:6px; padding: 7px 11px; border-radius: 999px; background: rgba(255,255,255,.16); color: #fff; border: 1px solid rgba(255,255,255,.18); font-size: 13px; }
|
||||
|
||||
.toolbar {
|
||||
margin: 20px 0 14px;
|
||||
background: var(--panel);
|
||||
border-radius: 22px;
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr .85fr .75fr .75fr;
|
||||
gap: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid rgba(255,255,255,.42);
|
||||
}
|
||||
input, select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 12px 13px;
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
input:focus, select:focus { border-color: rgba(183,32,46,.56); box-shadow: 0 0 0 3px rgba(183,32,46,.1); }
|
||||
.tabs {
|
||||
display: flex; flex-wrap: wrap; gap: 9px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.tab-btn {
|
||||
border: 0;
|
||||
background: rgba(255,255,255,.18);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255,255,255,.2);
|
||||
padding: 10px 13px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
transition: .18s ease;
|
||||
}
|
||||
.tab-btn.active { background: #fff; color: var(--red-dark); box-shadow: 0 10px 26px rgba(0,0,0,.13); }
|
||||
.tab-btn:hover { transform: translateY(-1px); }
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border-radius: 26px;
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid rgba(255,255,255,.44);
|
||||
min-height: 520px;
|
||||
}
|
||||
.tab-head { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; margin-bottom:16px; }
|
||||
.tab-title { margin: 0; font-size: 25px; color: var(--red-dark); }
|
||||
.tab-desc { margin: 6px 0 0; color: var(--muted); line-height: 1.6; }
|
||||
.count-badge { flex: 0 0 auto; padding: 8px 11px; border-radius: 999px; background:#fff; color: var(--red-dark); border:1px solid var(--line); font-weight:800; }
|
||||
|
||||
.summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
|
||||
.summary-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
min-height: 144px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.summary-card::after { content:""; position:absolute; right:-38px; top:-42px; width: 110px; height:110px; border-radius:50%; background: rgba(183,32,46,.08); }
|
||||
.summary-card h3 { margin:0 0 8px; color:var(--red-dark); font-size:15px; }
|
||||
.summary-card .value { font-size: 25px; font-weight: 900; margin-bottom: 9px; color: #2b1114; }
|
||||
.summary-card p { margin:0; color:var(--muted); line-height: 1.62; font-size: 14px; }
|
||||
|
||||
.overview-grid { display:grid; grid-template-columns: 1.1fr .9fr; gap: 14px; margin-top: 14px; }
|
||||
.box { background:#fff; border:1px solid var(--line); border-radius:18px; padding:16px; }
|
||||
.box h3 { margin:0 0 12px; color: var(--red-dark); }
|
||||
.bar-row { display:grid; grid-template-columns: 105px 1fr 36px; align-items:center; gap:8px; margin: 9px 0; font-size:13px; color: var(--muted); }
|
||||
.bar { height: 10px; border-radius: 999px; background: #f4ded8; overflow: hidden; }
|
||||
.bar > span { display:block; height:100%; border-radius:999px; background: linear-gradient(90deg, var(--red), var(--gold)); }
|
||||
.risk-list { display:grid; gap:10px; }
|
||||
.risk-mini { border:1px solid var(--line); border-radius:14px; padding:11px; background:#fffdfb; cursor:pointer; }
|
||||
.risk-mini strong { color: var(--red-dark); }
|
||||
|
||||
.cards { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
display:flex; flex-direction:column; gap: 10px;
|
||||
min-height: 248px;
|
||||
transition: .18s ease;
|
||||
}
|
||||
.card:hover { transform: translateY(-2px); box-shadow: 0 12px 28px rgba(94, 20, 24, .12); }
|
||||
.card-top { display:flex; justify-content:space-between; align-items:flex-start; gap:12px; }
|
||||
.card-id { font-size:12px; color:#fff; background: var(--red-dark); border-radius:999px; padding:4px 8px; font-weight: 800; }
|
||||
.risk { font-size:12px; border-radius:999px; padding:5px 8px; font-weight: 800; white-space: nowrap; }
|
||||
.risk.xh { background: #ffe1de; color:#9b111c; }
|
||||
.risk.h { background: #fff0d5; color:#9a4e00; }
|
||||
.risk.m { background: #e9f2ff; color:#24527d; }
|
||||
.card h3 { margin:0; color:var(--red-dark); font-size:17px; line-height:1.35; }
|
||||
.meta { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.tag { font-size:12px; color:var(--red-dark); background: var(--soft); border:1px solid rgba(183,32,46,.15); border-radius:999px; padding:4px 8px; }
|
||||
.card p { margin:0; color:var(--muted); line-height: 1.62; font-size: 14px; }
|
||||
.card-footer { margin-top:auto; display:flex; justify-content:space-between; align-items:center; gap:12px; }
|
||||
.evidence { font-size:12px; color:#5e4d49; }
|
||||
.btn {
|
||||
background: var(--red);
|
||||
color:#fff;
|
||||
border:0;
|
||||
border-radius:12px;
|
||||
padding:9px 12px;
|
||||
cursor:pointer;
|
||||
font-weight:800;
|
||||
box-shadow: 0 8px 18px rgba(183,32,46,.22);
|
||||
}
|
||||
.btn.secondary { background:#fff; color:var(--red-dark); border:1px solid var(--line); box-shadow:none; }
|
||||
.empty { padding: 38px; text-align:center; color:var(--muted); background:#fff; border:1px dashed var(--line); border-radius:18px; }
|
||||
|
||||
.keyword-wrap { display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:14px; margin-top:16px; }
|
||||
.keyword-group { background:#fff; border:1px solid var(--line); border-radius:18px; padding:15px; }
|
||||
.keyword-group h3 { margin:0 0 10px; color: var(--red-dark); }
|
||||
.kw { display:inline-block; margin:5px; padding:7px 10px; background:var(--soft); border:1px solid rgba(183,32,46,.18); color:var(--red-dark); border-radius:999px; font-size:13px; }
|
||||
|
||||
.modal-backdrop { display:none; position:fixed; inset:0; background:rgba(45, 7, 10, .62); z-index:1000; padding: 28px; overflow:auto; }
|
||||
.modal-backdrop.open { display:block; }
|
||||
.modal { max-width: 980px; margin: 0 auto; background:#fffaf7; border-radius: 24px; border:1px solid rgba(255,255,255,.45); box-shadow: 0 30px 90px rgba(0,0,0,.34); overflow:hidden; }
|
||||
.modal-head { background: linear-gradient(135deg, var(--red-dark), var(--red)); color:#fff; padding: 20px 22px; display:flex; justify-content:space-between; gap:16px; align-items:flex-start; }
|
||||
.modal-head h2 { margin:0; font-size:24px; }
|
||||
.close { background:rgba(255,255,255,.16); border:1px solid rgba(255,255,255,.25); color:#fff; border-radius:12px; font-size:22px; cursor:pointer; width:42px; height:38px; }
|
||||
.modal-body { padding: 20px 22px 24px; }
|
||||
.detail-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap:10px; margin-bottom:16px; }
|
||||
.detail-cell { background:#fff; border:1px solid var(--line); border-radius:14px; padding:10px; }
|
||||
.detail-cell span { display:block; font-size:12px; color:var(--muted); margin-bottom:4px; }
|
||||
.detail-cell strong { color:var(--red-dark); }
|
||||
.section { background:#fff; border:1px solid var(--line); border-radius:16px; padding:15px; margin:12px 0; }
|
||||
.section h3 { margin:0 0 8px; color:var(--red-dark); }
|
||||
.section p { margin:0; line-height:1.8; color:#3b2929; }
|
||||
|
||||
.footer-note { color:rgba(255,255,255,.78); text-align:center; padding: 16px 0 4px; font-size:13px; }
|
||||
@media (max-width: 1100px) {
|
||||
.toolbar { grid-template-columns: 1fr 1fr; }
|
||||
.summary-grid, .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.overview-grid { grid-template-columns: 1fr; }
|
||||
.detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.app { padding: 14px; }
|
||||
.toolbar { grid-template-columns: 1fr; }
|
||||
.summary-grid, .cards, .keyword-wrap { grid-template-columns: 1fr; }
|
||||
.tab-head { flex-direction:column; }
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
.modal-backdrop { padding: 12px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="hero">
|
||||
<div class="eyebrow">NARRATIVE RISK MONITORING DATABASE</div>
|
||||
<h1>日本涉台介入风险叙事专题研究系统</h1>
|
||||
<div class="subtitle">围绕“介入战争日本祸及自身”叙事的事件、人物、组织、制度、地方、媒体、经济与民意数据库</div>
|
||||
<div class="hero-meta">
|
||||
<span class="pill">版本:1.0</span>
|
||||
<span class="pill">更新:2026-06-30</span>
|
||||
<span class="pill">离线可用 · 内嵌数据</span>
|
||||
<span class="pill">Tab切换 · 搜索筛选 · 弹框详情</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="toolbar">
|
||||
<input id="q" placeholder="搜索关键词:高市、先岛、存立危机、冲绳、旅游、认知战……" />
|
||||
<select id="narrativeFilter"><option value="">全部叙事方向</option></select>
|
||||
<select id="riskFilter"><option value="">全部风险等级</option><option value="极高">极高</option><option value="高">高</option><option value="中">中</option><option value="中低">中低</option><option value="低">低</option></select>
|
||||
<select id="evidenceFilter"><option value="">全部证据状态</option><option value="已核实">已核实</option><option value="持续更新">持续更新</option><option value="待采集">待采集</option></select>
|
||||
</section>
|
||||
|
||||
<nav class="tabs" id="tabs"></nav>
|
||||
<main class="panel" id="content"></main>
|
||||
<div class="footer-note">本页面为研究型结构化展示,不替代原始文件核验;高风险条目应回溯国会记录、政府文件、机构公告和媒体原文。</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="modalBackdrop" onclick="backdropClose(event)">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<div id="modalMeta" class="eyebrow"></div>
|
||||
<h2 id="modalTitle"></h2>
|
||||
</div>
|
||||
<button class="close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const DATA = {"project": {"title": "日本涉台介入风险叙事专题研究系统", "subtitle": "围绕“介入战争日本祸及自身”叙事的事件、人物、组织、制度、地方、媒体、经济与民意数据库", "version": "1.0", "language": "zh-CN", "last_updated": "2026-06-30", "description": "本数据用于展示日本方向涉台介入风险叙事中的事件、人物、组织、制度、地方、媒体、经济、民意与竞争叙事。"}, "tabs": [{"id": "overview", "name": "总览仪表盘", "dataset": null, "description": "展示专题总风险、叙事分布、热点地区、重点人物和最新事件。"}, {"id": "timeline", "name": "事件时间线", "dataset": "events_timeline", "description": "展示2014年以来日本涉台安全政策、政要表态、地方避难和中日冲突节点。"}, {"id": "actors_orgs", "name": "人物组织", "dataset": ["actors_japan", "organizations"], "description": "展示日本政府、政党、地方政治、专家、媒体和社会组织。"}, {"id": "security_laws", "name": "法律制度", "dataset": "security_laws", "description": "展示存立危机、重要影响、国民保护、日美安保等制度路径。"}, {"id": "local_frontline", "name": "地方前线", "dataset": "local_frontline", "description": "展示冲绳、先岛、石垣、宫古、与那国、九州、山口等地方风险。"}, {"id": "antiwar_networks", "name": "反战护宪网络", "dataset": "antiwar_networks", "description": "展示九条之会、日弁连、冲绳和平运动、地方反导弹组织和工会网络。"}, {"id": "conservative_backlash", "name": "保守反弹", "dataset": "conservative_backlash", "description": "展示威慑、防卫、中国认知战、台湾伙伴等竞争叙事。"}, {"id": "media_platform", "name": "媒体平台", "dataset": "media_platform_samples", "description": "展示全国媒体、地方媒体、YouTube、X、Yahoo、note等传播样本。"}, {"id": "economic_spillover", "name": "经济外溢", "dataset": "economic_spillover", "description": "展示旅游、航空、半导体、供应链、出口管制等风险。"}, {"id": "public_opinion", "name": "民意受众", "dataset": "public_opinion_youth", "description": "展示台湾有事、防卫费、修宪、青年、都市无党派和冲绳地方态度。"}, {"id": "japan_taiwan", "name": "日台关系", "dataset": "japan_taiwan_relations", "description": "展示日台实务窗口、亲台议员网络、地方交流、半导体合作和智库推演。"}, {"id": "corpus_keywords", "name": "语料词库", "dataset": "source_corpus_keywords", "description": "展示原始语料来源、日文关键词、平台监测词和证据状态。"}], "dashboard": {"summary_cards": [{"title": "核心叙事", "value": "介入战争日本祸及自身", "description": "围绕日本涉台介入可能引发战争卷入、地方牺牲、经济反制和社会撕裂的风险叙事。"}, {"title": "数据条目", "value": "164", "description": "覆盖事件、人物、组织、制度、地方、媒体、经济、民意和日台关系。"}, {"title": "高风险地区", "value": "冲绳、先岛、九州", "description": "冲绳本岛、石垣、宫古、与那国、九州和山口是地方风险最集中的区域。"}, {"title": "核心法律入口", "value": "存立危机事态", "description": "台湾有事是否触发日本集体自卫权,是舆论和国会争议核心。"}, {"title": "主要竞争叙事", "value": "威慑才能防止战争", "description": "保守派和安保界常用威慑、防卫、中国威胁等逻辑反击反介入叙事。"}, {"title": "总体风险", "value": "86 / 100", "description": "综合政治触发、地方影响、经济外溢、媒体扩散和反弹强度评估。"}], "narrative_distribution": [{"label": "地方牺牲", "count": 38}, {"label": "护宪反战", "count": 22}, {"label": "威慑防战", "count": 20}, {"label": "经济代价", "count": 18}, {"label": "中性解释", "count": 17}, {"label": "台湾伙伴", "count": 15}, {"label": "战争卷入", "count": 12}, {"label": "专业治理", "count": 12}, {"label": "中国胁迫", "count": 6}, {"label": "中国威胁", "count": 4}], "hotspots": [{"location": "日本", "count": 42}, {"location": "东京", "count": 15}, {"location": "冲绳", "count": 12}, {"location": "日本全国", "count": 9}, {"location": "宫古岛", "count": 5}, {"location": "华盛顿", "count": 4}, {"location": "与那国", "count": 4}, {"location": "石垣", "count": 3}, {"location": "九州/山口", "count": 3}, {"location": "熊本", "count": 3}, {"location": "先岛/九州/山口", "count": 2}, {"location": "中日之间", "count": 2}], "top_risks": [{"id": "E016", "title_cn": "高市早苗称台湾有事可能构成存立危机事态", "category": "首相表态", "summary": "高市早苗在国会答辩中将台湾有事与存立危机事态直接相连。", "detail": "该事件将保守派长期话语推入首相级官Line truncated
|
||||
const state = { activeTab: 'overview' };
|
||||
|
||||
const tabsEl = document.getElementById('tabs');
|
||||
const contentEl = document.getElementById('content');
|
||||
const qEl = document.getElementById('q');
|
||||
const narrativeEl = document.getElementById('narrativeFilter');
|
||||
const riskEl = document.getElementById('riskFilter');
|
||||
const evidenceEl = document.getElementById('evidenceFilter');
|
||||
|
||||
function init() {
|
||||
Object.values(DATA.narrative_labels).forEach(label => {
|
||||
const opt = document.createElement('option'); opt.value = label; opt.textContent = label; narrativeEl.appendChild(opt);
|
||||
});
|
||||
renderTabs();
|
||||
render();
|
||||
[qEl, narrativeEl, riskEl, evidenceEl].forEach(el => el.addEventListener('input', render));
|
||||
}
|
||||
|
||||
function renderTabs() {
|
||||
tabsEl.innerHTML = '';
|
||||
DATA.tabs.forEach(t => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'tab-btn' + (state.activeTab === t.id ? ' active' : '');
|
||||
btn.textContent = t.name;
|
||||
btn.onclick = () => { state.activeTab = t.id; qEl.value=''; renderTabs(); render(); };
|
||||
tabsEl.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function getActiveTab() { return DATA.tabs.find(t => t.id === state.activeTab); }
|
||||
function getItems(tab) {
|
||||
if (!tab || !tab.dataset) return [];
|
||||
if (Array.isArray(tab.dataset)) return tab.dataset.flatMap(k => DATA.datasets[k] || []);
|
||||
return DATA.datasets[tab.dataset] || [];
|
||||
}
|
||||
function textOf(x) {
|
||||
return [x.id, x.title_cn, x.category, x.summary, x.detail, x.location, x.narrative_label, ...(x.tags||[])].join(' ').toLowerCase();
|
||||
}
|
||||
function filtered(items) {
|
||||
const q = qEl.value.trim().toLowerCase();
|
||||
const n = narrativeEl.value;
|
||||
const r = riskEl.value;
|
||||
const e = evidenceEl.value;
|
||||
return items.filter(x => (!q || textOf(x).includes(q)) && (!n || x.narrative_label === n) && (!r || x.risk_level === r) && (!e || x.evidence_status === e));
|
||||
}
|
||||
function riskClass(level) { return level === '极高' ? 'xh' : level === '高' ? 'h' : 'm'; }
|
||||
|
||||
function render() {
|
||||
const tab = getActiveTab();
|
||||
if (state.activeTab === 'overview') return renderOverview();
|
||||
const items = filtered(getItems(tab));
|
||||
const keywordBlock = state.activeTab === 'corpus_keywords' ? renderKeywords() : '';
|
||||
contentEl.innerHTML = `
|
||||
<div class="tab-head">
|
||||
<div><h2 class="tab-title">${tab.name}</h2><p class="tab-desc">${tab.description}</p></div>
|
||||
<div class="count-badge">${items.length} 条</div>
|
||||
</div>
|
||||
${items.length ? `<div class="cards">${items.map(card).join('')}</div>` : '<div class="empty">没有符合当前筛选条件的数据。</div>'}
|
||||
${keywordBlock}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderOverview() {
|
||||
const d = DATA.dashboard;
|
||||
const maxNarr = Math.max(...d.narrative_distribution.map(x => x.count));
|
||||
const maxHot = Math.max(...d.hotspots.map(x => x.count));
|
||||
contentEl.innerHTML = `
|
||||
<div class="tab-head">
|
||||
<div><h2 class="tab-title">总览仪表盘</h2><p class="tab-desc">展示专题总风险、叙事分布、热点地区、重点人物和最新事件。可通过上方搜索框对全库进行粗略检索。</p></div>
|
||||
<div class="count-badge">${Object.values(DATA.datasets).filter(Array.isArray).flat().length - DATA.datasets.keywords.length} 条</div>
|
||||
</div>
|
||||
<div class="summary-grid">${d.summary_cards.map(c => `<div class="summary-card"><h3>${c.title}</h3><div class="value">${c.value}</div><p>${c.description}</p></div>`).join('')}</div>
|
||||
<div class="overview-grid">
|
||||
<div class="box"><h3>叙事分布</h3>${d.narrative_distribution.map(x => `<div class="bar-row"><span>${x.label}</span><div class="bar"><span style="width:${Math.max(8, x.count/maxNarr*100)}%"></span></div><b>${x.count}</b></div>`).join('')}</div>
|
||||
<div class="box"><h3>热点地区</h3>${d.hotspots.slice(0,10).map(x => `<div class="bar-row"><span>${x.location}</span><div class="bar"><span style="width:${Math.max(8, x.count/maxHot*100)}%"></span></div><b>${x.count}</b></div>`).join('')}</div>
|
||||
</div>
|
||||
<div class="overview-grid">
|
||||
<div class="box"><h3>高风险条目</h3><div class="risk-list">${d.top_risks.slice(0,6).map(x => `<div class="risk-mini" onclick="openModalById('${x.id}')"><strong>${x.title_cn}</strong><br><span>${x.category} · 风险 ${x.risk_score} · ${x.narrative_label}</span></div>`).join('')}</div></div>
|
||||
<div class="box"><h3>主要风险驱动</h3><div class="meta">${d.risk_drivers.map(x => `<span class="tag">${x}</span>`).join('')}</div><div class="section"><h3>页面使用建议</h3><p>先从“事件时间线”定位触发节点,再到“法律制度”查看制度路径;涉及地方影响时进入“地方前线”和“反战护宪网络”;涉及传播扩散时进入“媒体平台”;涉及成本量化时进入“经济外溢”和“民意受众”。</p></div></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function card(x) {
|
||||
return `<article class="card">
|
||||
<div class="card-top"><span class="card-id">${x.id}</span><span class="risk ${riskClass(x.risk_level)}">${x.risk_level} · ${x.risk_score}</span></div>
|
||||
<h3>${x.title_cn}</h3>
|
||||
<div class="meta"><span class="tag">${x.category}</span><span class="tag">${x.narrative_label}</span><span class="tag">${x.location || '—'}</span></div>
|
||||
<p>${x.summary}</p>
|
||||
<div class="meta">${(x.tags || []).slice(0,4).map(t => `<span class="tag">${t}</span>`).join('')}</div>
|
||||
<div class="card-footer"><span class="evidence">证据:${x.evidence_status} · 可信度${x.confidence_level}</span><button class="btn" onclick="openModalById('${x.id}')">查看详情</button></div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderKeywords() {
|
||||
return `<div class="keyword-wrap">${DATA.datasets.keywords.map(g => `<div class="keyword-group"><h3>${g.group}</h3>${g.terms.map(t => `<span class="kw">${t}</span>`).join('')}</div>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function allItems() { return Object.entries(DATA.datasets).filter(([k]) => k !== 'keywords').flatMap(([k,v]) => Array.isArray(v) ? v : []); }
|
||||
function openModalById(id) { const x = allItems().find(i => i.id === id); if (x) openModal(x); }
|
||||
function openModal(x) {
|
||||
document.getElementById('modalMeta').textContent = `${x.id} · ${x.category} · ${x.narrative_label}`;
|
||||
document.getElementById('modalTitle').textContent = x.title_cn;
|
||||
const metrics = x.metrics ? `<div class="section"><h3>关键指标</h3><p>${Object.entries(x.metrics).map(([k,v]) => `${k}:${v}`).join(';')}</p></div>` : '';
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
<div class="detail-grid">
|
||||
<div class="detail-cell"><span>风险等级</span><strong>${x.risk_level} · ${x.risk_score}</strong></div>
|
||||
<div class="detail-cell"><span>叙事方向</span><strong>${x.narrative_label}</strong></div>
|
||||
<div class="detail-cell"><span>时间</span><strong>${x.date || '持续'}</strong></div>
|
||||
<div class="detail-cell"><span>地点</span><strong>${x.location || '—'}</strong></div>
|
||||
<div class="detail-cell"><span>证据状态</span><strong>${x.evidence_status}</strong></div>
|
||||
<div class="detail-cell"><span>可信度</span><strong>${x.confidence_level}</strong></div>
|
||||
<div class="detail-cell"><span>类别</span><strong>${x.category}</strong></div>
|
||||
<div class="detail-cell"><span>编号</span><strong>${x.id}</strong></div>
|
||||
</div>
|
||||
<div class="section"><h3>摘要</h3><p>${x.summary}</p></div>
|
||||
<div class="section"><h3>详情解读</h3><p>${x.detail}</p></div>
|
||||
${metrics}
|
||||
<div class="section"><h3>关联标签</h3><p>${(x.tags || []).map(t => `#${t}`).join(' ')}</p></div>
|
||||
<div class="section"><h3>展示建议</h3><p>该条目可与同类事件、相关人物组织、地方节点、法律制度和媒体平台样本联动。若用于二级页面,建议补充时间线、争议双方观点、原始语料和平台传播样本。</p></div>
|
||||
`;
|
||||
document.getElementById('modalBackdrop').classList.add('open');
|
||||
}
|
||||
function closeModal() { document.getElementById('modalBackdrop').classList.remove('open'); }
|
||||
function backdropClose(e) { if (e.target.id === 'modalBackdrop') closeModal(); }
|
||||
window.addEventListener('keydown', e => { if(e.key === 'Escape') closeModal(); });
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+3623
File diff suppressed because it is too large.
Load diff
+1432
File diff suppressed because it is too large.
Load diff
+98
@@ -0,0 +1,98 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>菲律宾“不做美国棋子”叙事专题数据库 v0.2</title>
|
||||
<style>
|
||||
:root{--bg:#f7f1ed;--card:#fffdfb;--ink:#291817;--muted:#7a6764;--line:#eadbd4;--red:#a23434;--red2:#6f1f1f;--blue:#244b74;--blue2:#eaf2fb;--gold:#9f6a1a;--green:#477052;--shadow:0 18px 50px rgba(51,20,14,.09)}
|
||||
*{box-sizing:border-box} body{margin:0;background:linear-gradient(180deg,#f6eee9 0,#fbf7f3 100%);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",Arial,sans-serif;line-height:1.55}
|
||||
header{padding:26px 30px 18px;background:linear-gradient(135deg,#8f2929,#b6473d 62%,#d8a16f);color:white;position:sticky;top:0;z-index:20;box-shadow:0 12px 30px rgba(76,17,17,.18)}
|
||||
.topline{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;max-width:1500px;margin:0 auto}.title h1{font-size:28px;margin:0 0 6px}.title p{margin:0;opacity:.88}.tools{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.search{min-width:280px;border:0;border-radius:14px;padding:12px 14px;font-size:14px;outline:none}.btn,label.btn{border:1px solid rgba(255,255,255,.55);background:rgba(255,255,255,.14);color:white;border-radius:13px;padding:10px 13px;cursor:pointer;font-size:14px}label.btn input{display:none}
|
||||
nav{background:#fff8f5;border-bottom:1px solid var(--line);position:sticky;top:105px;z-index:19}.tabs{display:flex;gap:8px;overflow:auto;padding:12px 28px;max-width:1500px;margin:0 auto}.tab{border:1px solid var(--line);background:#fff;border-radius:999px;padding:10px 15px;white-space:nowrap;cursor:pointer;color:var(--ink)}.tab.active{background:var(--red);color:white;border-color:var(--red)}
|
||||
.wrap{display:grid;grid-template-columns:280px minmax(0,1fr) 260px;gap:18px;max-width:1500px;margin:18px auto;padding:0 24px 40px}.panel,.card,.modal{background:var(--card);border:1px solid var(--line);box-shadow:var(--shadow);border-radius:22px}.filters{padding:16px;position:sticky;top:170px;align-self:start}.filters h3,.side h3{margin:0 0 12px}.field{margin:0 0 12px}.field label{display:block;color:var(--muted);font-size:12px;margin:0 0 5px}.field input,.field select{width:100%;border:1px solid var(--line);background:#fff;border-radius:12px;padding:10px 11px;outline:none}.field input:focus,.field select:focus{border-color:#ce9287}.reset{width:100%;border:1px solid var(--line);background:#fff;border-radius:12px;padding:10px;cursor:pointer}.side{padding:16px;position:sticky;top:170px;align-self:start}.summary-item{display:flex;align-items:center;justify-content:space-between;border:1px solid var(--line);border-radius:14px;padding:10px 12px;margin:8px 0;background:#fff}.summary-item strong{font-size:19px;color:var(--red)}.summary-item span{font-size:13px;color:var(--muted)}
|
||||
main{min-width:0}.section-title{display:flex;align-items:center;justify-content:space-between;gap:14px;margin:0 0 14px}.section-title h2{margin:0;font-size:24px}.section-title .sub{color:var(--muted);font-size:13px}.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:16px}.stat{padding:18px;border-radius:20px;background:#fff;border:1px solid var(--line);box-shadow:var(--shadow)}.stat .num{font-size:32px;font-weight:800;color:var(--red)}.stat .lab{color:var(--muted)}.two-col{display:grid;grid-template-columns:1.15fr .85fr;gap:14px}.cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.card{padding:16px;cursor:pointer;transition:.18s;min-height:176px}.card:hover{transform:translateY(-2px);box-shadow:0 22px 60px rgba(51,20,14,.15);border-color:#d7b9ac}.card h3{margin:8px 0 8px;font-size:18px}.card p{margin:0 0 10px;color:#5b4744}.meta-row{display:flex;gap:7px;align-items:center;flex-wrap:wrap}.pill{display:inline-flex;align-items:center;gap:4px;border:1px solid var(--line);border-radius:999px;padding:4px 8px;font-size:12px;background:#fff;color:#5d4c49;margin:2px}.pill.red{background:#fff0ee;color:var(--red2);border-color:#ebc6bf}.pill.blue{background:var(--blue2);color:var(--blue);border-color:#cfe0ee}.pill.green{background:#eef8ef;color:var(--green);border-color:#cfe6d2}.pill.gold{background:#fff6e6;color:var(--gold);border-color:#ecd7ad}.risk-high{background:#fff0ee;color:#8b1d1d;border-color:#e8beb8}.risk-medium{background:#fff7e8;color:#8a5b08;border-color:#ead6a4}.risk-low{background:#eef9f1;color:#426f49;border-color:#cfe7d5}.small{font-size:12px;color:var(--muted)}.badge-title{font-size:12px;color:var(--muted);margin-top:12px;text-transform:uppercase;letter-spacing:.06em}.timeline{position:relative;padding-left:18px}.timeline:before{content:"";position:absolute;left:4px;top:6px;bottom:6px;width:2px;background:#e3c8c0}.titem{position:relative;margin:0 0 14px}.titem:before{content:"";position:absolute;left:-21px;top:20px;width:12px;height:12px;border-radius:50%;background:var(--red);box-shadow:0 0 0 5px #f8e8e4}.tcard{background:#fff;border:1px solid var(--line);border-radius:18px;padding:15px;cursor:pointer}.tdate{font-size:12px;color:var(--red);font-weight:700}.table-wrap{overflow:auto;background:#fff;border:1px solid var(--line);border-radius:18px}table{border-collapse:separate;border-spacing:0;width:100%;min-width:900px}th,td{border-bottom:1px solid var(--line);padding:12px 12px;text-align:left;vertical-align:top}th{background:#fff5f2;color:#6a3330;font-size:13px;position:sticky;top:0}td{font-size:14px}.viewbar{display:flex;gap:8px;justify-content:flex-end;margin:-4px 0 12px}.viewbar button, .inline-btn{border:1px solid var(--line);background:#fff;border-radius:999px;padding:8px 12px;cursor:pointer}.viewbar button.active{background:var(--red);color:#fff;border-color:var(--red)}.matrix{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.risk-col{border:1px solid var(--line);background:#fff;border-radius:20px;padding:14px}.empty{border:1px dashed #d7bdb5;border-radius:18px;padding:35px;text-align:center;color:var(--muted);background:#fff}.linkish,.rel-card{cursor:pointer}.rel-card{border:1px solid var(--line);border-radius:14px;padding:10px;background:#fff;margin:6px 0}.rel-card strong{display:block}.rel-card span{color:var(--muted);font-size:12px}.mini-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.distance-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}.distance{background:#fff;border:1px solid var(--line);border-radius:16px;padding:12px}.distance strong{display:block;color:var(--red);font-size:22px}.distance span{font-size:12px;color:var(--muted)}
|
||||
.modal-backdrop{position:fixed;inset:0;background:rgba(38,15,12,.5);display:none;align-items:center;justify-content:center;z-index:100;padding:20px}.modal-backdrop.show{display:flex}.modal{width:min(1120px,100%);max-height:90vh;overflow:auto}.modal-head{padding:20px 24px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;gap:12px;align-items:flex-start;position:sticky;top:0;background:#fffdfb;z-index:2}.modal-title{margin:0;font-size:24px}.close{border:0;background:#f4e6e2;border-radius:12px;width:40px;height:40px;font-size:24px;cursor:pointer}.modal-body{padding:20px 24px 28px}.hero{padding:18px;border:1px solid var(--line);border-radius:20px;background:linear-gradient(135deg,#fff7f4,#fffdfb);margin-bottom:14px}.hero h3{margin:0 0 8px}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.detail-section{border:1px solid var(--line);background:#fff;border-radius:18px;padding:15px;margin-bottom:14px}.detail-section h4{margin:0 0 10px;color:var(--red2);font-size:16px}.detail-section p{margin:6px 0}.detail-section ul{margin:8px 0 0;padding-left:20px}.obj-table{width:100%;min-width:0}.obj-table th,.obj-table td{font-size:13px;padding:8px}.full{grid-column:1/-1}.note-box{background:#fff9ef;border:1px solid #ecd5ad;border-radius:16px;padding:12px}.source-list{font-size:12px;color:var(--muted);line-height:1.6}.kpi-strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:12px 0}.kpi{border:1px solid var(--line);border-radius:16px;padding:12px;background:#fff}.kpi b{display:block;color:var(--red);font-size:20px}.kpi span{font-size:12px;color:var(--muted)}
|
||||
@media(max-width:1120px){.wrap{grid-template-columns:1fr}.filters,.side{position:relative;top:0}.cards{grid-template-columns:repeat(2,minmax(0,1fr))}.stats{grid-template-columns:repeat(2,minmax(0,1fr))}.two-col,.matrix,.detail-grid{grid-template-columns:1fr}nav{top:120px}.distance-grid{grid-template-columns:repeat(2,1fr)}}
|
||||
@media(max-width:680px){header{position:relative}.topline{display:block}.tools{margin-top:12px}.search{min-width:100%;width:100%}nav{position:relative;top:0}.wrap{padding:0 12px}.cards{grid-template-columns:1fr}.stats{grid-template-columns:1fr}.distance-grid,.mini-grid,.kpi-strip{grid-template-columns:1fr}.modal-backdrop{padding:6px}.modal-head{padding:16px}.modal-body{padding:14px}.tabs{padding:10px 12px}}
|
||||
@media print{header,nav,.filters,.side,.tools,.modal-backdrop{display:none!important}.wrap{display:block;max-width:none}.card,.panel,.stat{box-shadow:none}.cards{grid-template-columns:1fr 1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><div class="topline"><div class="title"><h1 id="projectTitle">专题数据库</h1><p id="projectSubtitle"></p></div><div class="tools"><input id="globalSearch" class="search" placeholder="全局搜索:EDCA / Cagayan / BAYAN / 台海 / WPS"><label class="btn">导入JSON<input id="jsonFile" type="file" accept="application/json,.json"></label><button class="btn" onclick="downloadJson()">导出JSON</button></div></div></header>
|
||||
<nav><div class="tabs" id="tabs"></div></nav>
|
||||
<div class="wrap"><aside class="panel filters"><h3>筛选器</h3><div class="field"><label>关键词</label><input id="filterKeyword" placeholder="在当前库内搜索"></div><div class="field"><label>年份</label><select id="filterYear"></select></div><div class="field"><label>事件类型</label><select id="filterCategory"></select></div><div class="field"><label>省份</label><select id="filterProvince"></select></div><div class="field"><label>阵营</label><select id="filterCamp"></select></div><div class="field"><label>风险等级</label><select id="filterRisk"></select></div><div class="field"><label>战略方向</label><select id="filterAxis"></select></div><button class="reset" onclick="resetFilters()">清空筛选</button><div class="source-list" style="margin-top:14px">说明:本页面为公开资料研究展示工具,复杂字段以详情弹框结构化展示,不再输出原始JSON。</div></aside><main id="main"></main><aside class="panel side"><h3>数据摘要</h3><div id="summaryPanel"></div><h3 style="margin-top:18px">高频标签</h3><div id="tagCloud"></div></aside></div>
|
||||
<div class="modal-backdrop" id="modalBackdrop" onclick="backdropClose(event)"><div class="modal"><div class="modal-head"><div><h2 id="modalTitle" class="modal-title"></h2><div id="modalMeta" class="meta-row"></div></div><button class="close" onclick="closeModal()">×</button></div><div id="modalBody" class="modal-body"></div></div></div>
|
||||
<script id="db-json" type="application/json">{"meta": {"project_title": "菲律宾“不做美国棋子”叙事专题数据库", "project_subtitle": "Philippines “Not a US Pawn” Narrative Research Dashboard", "version": "0.2", "last_updated": "2026-06-29", "language": "zh-CN", "description": "用于展示菲律宾“菲律宾不做美国棋子”叙事专题的结构化知识库,覆盖EDCA地点、事件线、人物组织、叙事结构、竞争叙事、风险核查和跨库关联查询。", "scope_note": "本数据库服务于公开资料研究、舆情观察、风险评估与专题分析,不用于隐蔽影响、虚假传播或操控性动员。", "revision_note": "v0.2增强版:弹框详情改为结构化页面展示,补充研究字段,尽量保留前期MD知识库中的分析内容。"}, "tabs": [{"id": "overview", "label": "专题总览", "type": "dashboard"}, {"id": "timeline", "label": "事件时间线", "type": "timeline"}, {"id": "edca_sites", "label": "EDCA地点库", "type": "cards_table"}, {"id": "actors", "label": "人物组织库", "type": "cards_table"}, {"id": "narratives", "label": "叙事结构库", "type": "cards"}, {"id": "counter_narratives", "label": "竞争叙事库", "type": "cards"}, {"id": "risks", "label": "风险核查库", "type": "risk_matrix"}, {"id": "search", "label": "综合查询", "type": "search"}], "dictionaries": {"event_categories": {"edca_signature": "EDCA签署/制度化", "edca_expansion": "EDCA扩展", "edca_infrastructure": "EDCA基建", "balikatan": "Balikatan军演", "china_coast_guard": "中国海警事件", "ph_official_protest": "菲律宾官方抗议", "us_ph_meeting": "美菲高层会晤", "anti_edca_protest": "反EDCA/反军演抗议", "multilateral_security": "多边安全合作", "wps_civil_action": "WPS民间行动"}, "strategic_axis": {"taiwan_bashi": "台海—巴士海峡方向", "wps_scs": "西菲律宾海—南海方向", "northern_luzon": "北吕宋前沿方向", "central_luzon": "中吕宋支撑方向", "central_logistics": "中部群岛后勤方向", "mindanao": "棉兰老方向", "hadr": "人道援助与灾害响应"}, "actor_camps": {"marcos_official": "马科斯官方阵营", "duterte_camp": "杜特尔特阵营", "left_anti_base": "左翼反基地阵营", "wps_sovereignty": "WPS主权派", "fact_check": "事实核查机构", "security_expert": "安全专家与智库", "external_actor": "外部行为体"}, "actor_types": {"official_person": "官方人物", "political_person": "政治人物", "organization": "组织", "party_bloc": "政党/党团", "institution": "机构", "expert": "专家", "think_tank": "智库", "media_factcheck": "事实核查/媒体"}, "stance_values": {"support": "支持", "oppose": "反对", "mixed": "复杂/混合", "neutral": "中立", "unknown": "未知"}, "risk_levels": {"low": "低", "medium": "中", "high": "高"}, "site_types": {"naval_base": "海军基地", "airport": "机场", "army_camp": "陆军营地", "air_base": "空军基地", "airfield": "机场/航空场"}, "field_labels": {"id": "编号", "name": "名称", "name_cn": "中文名", "name_en": "英文名", "title": "标题", "summary": "摘要", "brief": "简述", "description": "说明", "province": "省份", "city_or_area": "城市/区域", "designation_year": "纳入年份", "site_type": "地点类型", "strategic_axis": "战略方向", "risk_level": "风险等级", "local_position": "地方态度", "tags": "标签", "distances": "近似距离", "detail": "详情", "strategic_value": "战略意义", "local_political_positions": "地方政治人物表态", "local_reactions": "地方居民抗议或支持", "infrastructure_projects": "基建项目", "risk_controversies": "风险争议", "official_response": "官方回应", "related_events": "关联事件", "related_actors": "关联人物组织", "related_sites": "关联地点", "background": "背景", "opposition_position": "反对方立场", "ph_official_position": "菲律宾官方立场", "us_position": "美国立场", "china_position": "中国立场", "research_note": "研究备注", "category": "类别", "date": "日期", "year": "年份", "location": "地点", "actors": "相关行为体", "narrative_signal": "叙事信号", "counter_narrative": "竞争叙事/反驳框架", "current_role": "当前身份", "camp": "阵营", "type": "类型", "stance_on_edca": "对EDCA立场", "stance_on_wps": "对WPS立场", "influence_level": "影响等级", "narrative_function": "叙事功能", "typical_positions": "典型立场", "typical_keywords": "典型关键词", "risk_note": "风险备注", "profile": "画像", "monitoring_focus": "监测重点", "core_claims": "核心命题", "target_audiences": "目标受众", "typical_expressions": "典型表达", "strength": "叙事强项", "weakness": "叙事弱项", "counter_narratives": "竞争叙事", "main_speakers": "主要提出者", "used_against": "用于反驳", "evidence_types": "证据类型", "advantage": "优势", "limitation": "限制", "trigLine truncated
|
||||
<script>
|
||||
let DB = JSON.parse(document.getElementById('db-json').textContent);
|
||||
const $=s=>document.querySelector(s), $$=s=>Array.from(document.querySelectorAll(s));
|
||||
let state={tab:'overview',view:'cards',filters:{},global:''};
|
||||
const collMap={event:'events',edca_site:'edca_sites',actor:'actors',narrative:'narratives',counter_narrative:'counter_narratives',risk:'risks'};
|
||||
const tabsForType={event:'timeline',edca_site:'edca_sites',actor:'actors',narrative:'narratives',counter_narrative:'counter_narratives',risk:'risks'};
|
||||
function arr(x){return Array.isArray(x)?x:[]}
|
||||
function esc(s){return String(s??'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))}
|
||||
function dict(group,key){return DB.dictionaries?.[group]?.[key] || key || ''}
|
||||
function label(k){return DB.dictionaries?.field_labels?.[k] || k}
|
||||
function titleOf(o){return o?.title || o?.name_cn || o?.name || o?.name_en || o?.id || ''}
|
||||
function summaryOf(o){return o?.summary || o?.brief || o?.description || o?.narrative_function || ''}
|
||||
function risk(level){return level?`<span class="pill risk-${esc(level)}">风险:${esc(dict('risk_levels',level))}</span>`:''}
|
||||
function chips(tags,cls=''){return arr(tags).map(t=>`<span class="pill ${cls}">${esc(t)}</span>`).join('')}
|
||||
function getAll(){let out=[];Object.entries(collMap).forEach(([type,key])=>arr(DB.data?.[key]).forEach(x=>out.push({...x,__type:type})));return out}
|
||||
function find(id){for(const [type,key] of Object.entries(collMap)){const x=arr(DB.data?.[key]).find(i=>i.id===id); if(x) return {...x,__type:type}}return null}
|
||||
function searchText(o){return JSON.stringify(o).toLowerCase()}
|
||||
function matchKw(o,kw){return !kw || searchText(o).includes(kw.toLowerCase())}
|
||||
function setup(){ $('#projectTitle').textContent=DB.meta.project_title; $('#projectSubtitle').textContent=(DB.meta.project_subtitle||'')+' · '+(DB.meta.revision_note||''); renderTabs(); fillFilters(); bind(); render(); }
|
||||
function renderTabs(){ $('#tabs').innerHTML=arr(DB.tabs).map(t=>`<button class="tab ${state.tab===t.id?'active':''}" data-id="${esc(t.id)}">${esc(t.label)}</button>`).join(''); $$('#tabs .tab').forEach(b=>b.onclick=()=>{state.tab=b.dataset.id;renderTabs();render();}); }
|
||||
function optAll(label){return `<option value="">${label}</option>`}
|
||||
function fillFilters(){ const years=[...new Set(arr(DB.data.events).map(e=>e.year).filter(Boolean))].sort((a,b)=>a-b); $('#filterYear').innerHTML=optAll('全部年份')+years.map(y=>`<option>${y}</option>`).join(''); $('#filterCategory').innerHTML=optAll('全部类型')+Object.entries(DB.dictionaries.event_categories||{}).map(([k,v])=>`<option value="${esc(k)}">${esc(v)}</option>`).join(''); const ps=[...new Set(arr(DB.data.edca_sites).map(s=>s.province).filter(Boolean))].sort(); $('#filterProvince').innerHTML=optAll('全部省份')+ps.map(p=>`<option>${esc(p)}</option>`).join(''); $('#filterCamp').innerHTML=optAll('全部阵营')+Object.entries(DB.dictionaries.actor_camps||{}).map(([k,v])=>`<option value="${esc(k)}">${esc(v)}</option>`).join(''); $('#filterRisk').innerHTML=optAll('全部风险')+Object.entries(DB.dictionaries.risk_levels||{}).map(([k,v])=>`<option value="${esc(k)}">${esc(v)}</option>`).join(''); $('#filterAxis').innerHTML=optAll('全部方向')+Object.entries(DB.dictionaries.strategic_axis||{}).map(([k,v])=>`<option value="${esc(k)}">${esc(v)}</option>`).join(''); }
|
||||
function bind(){ ['filterKeyword','filterYear','filterCategory','filterProvince','filterCamp','filterRisk','filterAxis'].forEach(id=>{$('#'+id).oninput=collect;$('#'+id).onchange=collect}); $('#globalSearch').oninput=e=>{state.global=e.target.value.trim(); if(state.global){state.tab='search';renderTabs()} render()}; $('#jsonFile').onchange=importJson; window.onkeydown=e=>{if(e.key==='Escape')closeModal()}; }
|
||||
function collect(){ state.filters={keyword:$('#filterKeyword').value.trim(),year:$('#filterYear').value,category:$('#filterCategory').value,province:$('#filterProvince').value,camp:$('#filterCamp').value,risk:$('#filterRisk').value,axis:$('#filterAxis').value}; render(); }
|
||||
function resetFilters(){['filterKeyword','filterYear','filterCategory','filterProvince','filterCamp','filterRisk','filterAxis','globalSearch'].forEach(id=>$('#'+id).value='');state.filters={};state.global='';render()}
|
||||
function commonFilter(items){const f=state.filters||{};return items.filter(x=>{if(f.keyword&&!matchKw(x,f.keyword))return false;if(f.risk&&x.risk_level!==f.risk)return false;if(f.year&&x.year&&String(x.year)!==String(f.year))return false;if(f.category&&x.category&&x.category!==f.category)return false;if(f.province&&x.province&&x.province!==f.province)return false;if(f.camp&&x.camp&&x.camp!==f.camp)return false;if(f.axis&&x.strategic_axis&&!arr(x.strategic_axis).includes(f.axis))return false;return true})}
|
||||
function render(){renderSummary();const t=state.tab;if(t==='overview')overview();if(t==='timeline')timeline();if(t==='edca_sites')sites();if(t==='actors')actors();if(t==='narratives')narratives();if(t==='counter_narratives')counters();if(t==='risks')risks();if(t==='search')search();}
|
||||
function renderSummary(){const d=DB.data;const rows=[['EDCA地点',arr(d.edca_sites).length],['事件',arr(d.events).length],['人物组织',arr(d.actors).length],['叙事结构',arr(d.narratives).length],['竞争叙事',arr(d.counter_narratives).length],['风险项',arr(d.risks).length],['高风险地点',arr(d.edca_sites).filter(x=>x.risk_level==='high').length],['高风险事件',arr(d.events).filter(x=>x.risk_level==='high').length]];$('#summaryPanel').innerHTML=rows.map(([k,v])=>`<div class="summary-item"><strong>${v}</strong><span>${k}</span></div>`).join(''); const tags={}; getAll().forEach(x=>arr(x.tags).forEach(t=>tags[t]=(tags[t]||0)+1)); $('#tagCloud').innerHTML=Object.entries(tags).sort((a,b)=>b[1]-a[1]).slice(0,20).map(([t,n])=>`<span class="pill">${esc(t)} · ${n}</span>`).join('');}
|
||||
function overview(){const d=DB.data;const high=arr(d.edca_sites).filter(x=>x.risk_level==='high');const eventCats={};arr(d.events).forEach(e=>eventCats[e.category]=(eventCats[e.category]||0)+1); const camps={};arr(d.actors).forEach(a=>camps[a.camp]=(camps[a.camp]||0)+1); $('#main').innerHTML=`<div class="section-title"><div><h2>专题总览</h2><div class="sub">${esc(DB.meta.description)}</div></div><span class="pill red">${esc(DB.meta.version)} · ${esc(DB.meta.last_updated)}</span></div><div class="stats"><div class="stat"><div class="num">${arr(d.edca_sites).length}</div><div class="lab">EDCA地点</div></div><div class="stat"><div class="num">${arr(d.events).length}</div><div class="lab">事件线节点</div></div><div class="stat"><div class="num">${arr(d.actors).length}</div><div class="lab">人物组织</div></div><div class="stat"><div class="num">${high.length}</div><div class="lab">高风险地点</div></div></div><div class="two-col"><div class="panel" style="padding:18px"><h3>核心问题</h3><p>本页面围绕“菲律宾不做美国棋子 / not a US pawn”这一叙事,展示其与EDCA、美菲同盟、Balikatan军演、西菲律宾海主权、杜特尔特—马科斯政治竞争、地方风险和事实核查之间的关系。</p><p class="small">${esc(DB.meta.scope_note||'')}</p>${chips(DB.indexes?.common_tags)}</div><div class="panel" style="padding:18px"><h3>事件类型分布</h3>${Object.entries(eventCats).map(([k,v])=>`<div class="summary-item"><strong>${v}</strong><span>${esc(dict('event_categories',k))}</span></div>`).join('')}</div></div><div style="height:14px"></div><div class="two-col"><div class="panel" style="padding:18px"><h3>阵营分布</h3>${Object.entries(camps).map(([k,v])=>`<div class="summary-item"><strong>${v}</strong><span>${esc(dict('actor_camps',k))}</span></div>`).join('')}</div><div class="panel" style="padding:18px"><h3>展示原则</h3><ul><li>卡片只看摘要,弹框展示完整知识结构。</li><li>长字段按模块呈现,不再显示原始JSON。</li><li>所有关联对象支持点击跳转。</li><li>风险内容同时展示核查重点和边界。</li></ul></div></div><div style="height:14px"></div><div class="section-title"><h2>高风险EDCA地点</h2><button class="inline-btn" onclick="state.tab='edca_sites';renderTabs();render()">查看全部</button></div><div class="cards">${high.map(siteCard).join('')}</div>`}
|
||||
function timeline(){let items=commonFilter(arr(DB.data.events)).sort((a,b)=>String(b.date).localeCompare(String(a.date))); if(state.filters.province){const ids=arr(DB.data.edca_sites).filter(s=>s.province===state.filters.province).map(s=>s.id);items=items.filter(e=>arr(e.related_sites).some(id=>ids.includes(id))||matchKw(e,state.filters.province));} $('#main').innerHTML=`<div class="section-title"><h2>事件时间线</h2><span class="pill blue">命中 ${items.length} 条</span></div>`+(items.length?`<div class="timeline">${items.map(e=>`<div class="titem"><div class="tcard" onclick="openModal('event','${esc(e.id)}')"><div class="tdate">${esc(e.date)} · ${esc(dict('event_categories',e.category))}</div><h3>${esc(e.title)}</h3><p>${esc(e.summary)}</p><div class="meta-row">${risk(e.risk_level)}${chips(e.tags)}</div></div></div>`).join('')}</div>`:empty())}
|
||||
function sites(){let items=commonFilter(arr(DB.data.edca_sites)); $('#main').innerHTML=`<div class="section-title"><h2>EDCA九个地点库</h2><span class="pill blue">命中 ${items.length} 条</span></div>${viewbar('sites')} ${state.view==='table'?siteTable(items):`<div class="cards">${items.map(siteCard).join('')}</div>`}`}
|
||||
function siteCard(s){return `<div class="card" onclick="openModal('edca_site','${esc(s.id)}')"><div class="meta-row"><span class="pill red">${esc(s.province)}</span>${risk(s.risk_level)}</div><h3>${esc(s.name)}</h3><p>${esc(s.brief)}</p><div class="badge-title">战略方向</div><div>${arr(s.strategic_axis).map(a=>`<span class="pill blue">${esc(dict('strategic_axis',a))}</span>`).join('')}</div><div class="badge-title">标签</div>${chips(s.tags)}</div>`}
|
||||
function siteTable(items){return `<div class="table-wrap"><table><thead><tr><th>地点</th><th>省份</th><th>年份</th><th>类型</th><th>战略方向</th><th>地方态度</th><th>风险</th><th>详情</th></tr></thead><tbody>${items.map(s=>`<tr><td><b>${esc(s.name)}</b><br><span class="small">${esc(s.city_or_area)}</span></td><td>${esc(s.province)}</td><td>${esc(s.designation_year)}</td><td>${esc(dict('site_types',s.site_type))}</td><td>${arr(s.strategic_axis).map(a=>esc(dict('strategic_axis',a))).join('<br>')}</td><td>${esc(s.local_position)}</td><td>${risk(s.risk_level)}</td><td><button class="inline-btn" onclick="openModal('edca_site','${esc(s.id)}')">打开</button></td></tr>`).join('')}</tbody></table></div>`}
|
||||
function actors(){let items=commonFilter(arr(DB.data.actors)); $('#main').innerHTML=`<div class="section-title"><h2>人物组织库</h2><span class="pill blue">命中 ${items.length} 条</span></div>${viewbar('actors')} ${state.view==='table'?actorTable(items):`<div class="cards">${items.map(actorCard).join('')}</div>`}`}
|
||||
function actorCard(a){return `<div class="card" onclick="openModal('actor','${esc(a.id)}')"><div class="meta-row"><span class="pill red">${esc(dict('actor_camps',a.camp))}</span><span class="pill">${esc(dict('actor_types',a.type))}</span></div><h3>${esc(a.name_cn||a.name_en)}</h3><p>${esc(a.brief)}</p><div class="badge-title">叙事功能</div><p>${esc(a.narrative_function||'')}</p>${chips(a.tags)}</div>`}
|
||||
function actorTable(items){return `<div class="table-wrap"><table><thead><tr><th>名称</th><th>类型</th><th>阵营</th><th>当前角色</th><th>EDCA立场</th><th>WPS立场</th><th>影响</th><th>详情</th></tr></thead><tbody>${items.map(a=>`<tr><td><b>${esc(a.name_cn||a.name_en)}</b><br><span class="small">${esc(a.name_en||'')}</span></td><td>${esc(dict('actor_types',a.type))}</td><td>${esc(dict('actor_camps',a.camp))}</td><td>${esc(a.current_role)}</td><td>${esc(dict('stance_values',a.stance_on_edca))}</td><td>${esc(dict('stance_values',a.stance_on_wps))}</td><td>${esc(a.influence_level)}</td><td><button class="inline-btn" onclick="openModal('actor','${esc(a.id)}')">打开</button></td></tr>`).join('')}</tbody></table></div>`}
|
||||
function narratives(){const items=commonFilter(arr(DB.data.narratives)); $('#main').innerHTML=`<div class="section-title"><h2>叙事结构库</h2><span class="pill blue">命中 ${items.length} 条</span></div><div class="cards">${items.map(n=>`<div class="card" onclick="openModal('narrative','${esc(n.id)}')"><div class="meta-row"><span class="pill red">${esc(n.category)}</span>${risk(n.risk_level)}</div><h3>${esc(n.title)}</h3><p>${esc(n.summary)}</p>${chips(n.tags)}</div>`).join('')}</div>`}
|
||||
function counters(){const items=commonFilter(arr(DB.data.counter_narratives)); $('#main').innerHTML=`<div class="section-title"><h2>竞争叙事库</h2><span class="pill blue">命中 ${items.length} 条</span></div><div class="cards">${items.map(c=>`<div class="card" onclick="openModal('counter_narrative','${esc(c.id)}')"><div class="meta-row"><span class="pill blue">竞争叙事</span>${risk(c.risk_level)}</div><h3>${esc(c.title)}</h3><p>${esc(c.summary)}</p>${chips(c.tags)}</div>`).join('')}</div>`}
|
||||
function risks(){const items=commonFilter(arr(DB.data.risks)); const levels=['high','medium','low']; $('#main').innerHTML=`<div class="section-title"><h2>风险与事实核查库</h2><span class="pill blue">命中 ${items.length} 条</span></div><div class="matrix">${levels.map(l=>`<div class="risk-col"><h3>${esc(dict('risk_levels',l))}风险</h3>${items.filter(r=>r.risk_level===l).map(r=>`<div class="card" style="min-height:auto;margin-bottom:10px" onclick="openModal('risk','${esc(r.id)}')"><h3>${esc(r.title)}</h3><p>${esc(r.description)}</p>${chips(r.tags)}</div>`).join('')||'<div class="empty">暂无</div>'}</div>`).join('')}</div>`}
|
||||
function search(){const kw=state.global||state.filters.keyword||'';let items=getAll().filter(x=>matchKw(x,kw));items=commonFilter(items); const names={event:'事件',edca_site:'EDCA地点',actor:'人物组织',narrative:'叙事结构',counter_narrative:'竞争叙事',risk:'风险'}; $('#main').innerHTML=`<div class="section-title"><div><h2>综合查询中心</h2><div class="sub">${kw?'关键词:'+esc(kw):'可在顶部或左侧输入关键词,跨库检索所有对象。'}</div></div><span class="pill blue">命中 ${items.length} 条</span></div>`+(items.length?`<div class="table-wrap"><table><thead><tr><th>类型</th><th>标题/名称</th><th>摘要</th><th>标签</th><th>风险</th><th>详情</th></tr></thead><tbody>${items.map(i=>`<tr><td><span class="pill red">${esc(names[i.__type])}</span></td><td><b>${esc(titleOf(i))}</b></td><td>${esc(summaryOf(i)).slice(0,260)}</td><td>${chips(i.tags)}</td><td>${risk(i.risk_level)}</td><td><button class="inline-btn" onclick="openModal('${esc(i.__type)}','${esc(i.id)}')">打开</button></td></tr>`).join('')}</tbody></table></div>`:empty('暂无查询结果。'))}
|
||||
function viewbar(){return `<div class="viewbar"><button class="${state.view==='cards'?'active':''}" onclick="state.view='cards';render()">卡片</button><button class="${state.view==='table'?'active':''}" onclick="state.view='table';render()">表格</button></div>`}
|
||||
function empty(t='没有匹配结果。'){return `<div class="empty">${esc(t)}</div>`}
|
||||
function openModal(type,id){const item=find(id); if(!item)return; $('#modalTitle').textContent=titleOf(item); $('#modalMeta').innerHTML=modalMeta(item); $('#modalBody').innerHTML=modalBody(item,type); $('#modalBackdrop').classList.add('show')}
|
||||
function closeModal(){$('#modalBackdrop').classList.remove('show')}
|
||||
function backdropClose(e){if(e.target.id==='modalBackdrop')closeModal()}
|
||||
function modalMeta(o){let bits=[]; if(o.date)bits.push(`<span class="pill">${esc(o.date)}</span>`); if(o.category)bits.push(`<span class="pill blue">${esc(dict('event_categories',o.category))}</span>`); if(o.province)bits.push(`<span class="pill red">${esc(o.province)}</span>`); if(o.camp)bits.push(`<span class="pill red">${esc(dict('actor_camps',o.camp))}</span>`); if(o.risk_level)bits.push(risk(o.risk_level)); return bits.join('')+chips(o.tags)}
|
||||
function section(title,content,cls=''){if(!content)return''; return `<section class="detail-section ${cls}"><h4>${esc(title)}</h4>${content}</section>`}
|
||||
function paragraph(v){return v?`<p>${esc(v)}</p>`:''}
|
||||
function list(v){if(!v||!arr(v).length)return''; return `<ul>${arr(v).map(x=>`<li>${renderVal(x)}</li>`).join('')}</ul>`}
|
||||
function objGrid(o){if(!o||typeof o!=='object')return''; return `<div class="mini-grid">${Object.entries(o).filter(([k,v])=>v!==undefined&&v!==null&&v!==''&&!(Array.isArray(v)&&!v.length)).map(([k,v])=>`<div class="rel-card"><span>${esc(label(k))}</span><strong>${renderVal(v)}</strong></div>`).join('')}</div>`}
|
||||
function objTable(items){if(!arr(items).length)return''; if(typeof items[0]!=='object')return list(items); const keys=[...new Set(items.flatMap(o=>Object.keys(o)))]; return `<div class="table-wrap"><table class="obj-table"><thead><tr>${keys.map(k=>`<th>${esc(label(k))}</th>`).join('')}</tr></thead><tbody>${items.map(o=>`<tr>${keys.map(k=>`<td>${renderVal(o[k])}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`}
|
||||
function renderVal(v){if(v===undefined||v===null)return''; if(Array.isArray(v)){if(v.length&&typeof v[0]==='object')return objTable(v); return arr(v).map(x=>renderVal(x)).join('<br>')} if(typeof v==='object')return objGrid(v); const s=String(v); const x=find(s); if(x)return `<span class="linkish pill blue" onclick="jump('${esc(s)}')">${esc(titleOf(x))}</span>`; return esc(s)}
|
||||
function rels(ids){return arr(ids).map(id=>{const x=find(id); return x?`<div class="rel-card" onclick="jump('${esc(id)}')"><strong>${esc(titleOf(x))}</strong><span>${esc(typeName(x.__type))} · ${esc(summaryOf(x)).slice(0,88)}</span></div>`:`<div class="rel-card"><strong>${esc(id)}</strong></div>`}).join('')}
|
||||
function typeName(t){return {event:'事件',edca_site:'EDCA地点',actor:'人物组织',narrative:'叙事结构',counter_narrative:'竞争叙事',risk:'风险'}[t]||t}
|
||||
function distances(d){if(!d)return''; return `<div class="distance-grid">${Object.entries(d).map(([k,v])=>`<div class="distance"><strong>${esc(v)} km</strong><span>${esc(label(k))}</span></div>`).join('')}</div>`}
|
||||
function modalBody(o,type){
|
||||
let d=o.detail||{};
|
||||
if(type==='edca_site') return `<div class="hero"><h3>${esc(o.name)}</h3><p>${esc(o.brief)}</p><div class="kpi-strip"><div class="kpi"><b>${esc(o.designation_year)}</b><span>纳入年份</span></div><div class="kpi"><b>${esc(o.province)}</b><span>省份</span></div><div class="kpi"><b>${esc(dict('site_types',o.site_type))}</b><span>地点类型</span></div><div class="kpi"><b>${esc(dict('risk_levels',o.risk_level))}</b><span>风险等级</span></div></div></div><div class="detail-grid">${section('基本信息',objGrid({province:o.province,city_or_area:o.city_or_area,designation_year:o.designation_year,site_type:dict('site_types',o.site_type),strategic_axis:arr(o.strategic_axis).map(a=>dict('strategic_axis',a)).join(' / '),local_position:o.local_position}),'full')}${section('近似距离与战略方向',distances(o.distances),'full')}${section('战略意义',paragraph(d.strategic_value))}${section('叙事解读',list(d.narrative_interpretation))}${section('地方政治人物表态',objTable(d.local_political_positions),'full')}${section('地方居民抗议或支持',objGrid(d.local_reactions))}${section('基建项目',list(d.infrastructure_projects))}${section('风险争议',list(d.risk_controversies))}${section('官方回应',paragraph(d.official_response))}${section('竞争性解释',list(d.counter_reading))}${section('证据线索',list(d.evidence_clues))}${section('展示提示',`<div class="note-box">${esc(d.display_warning||'')}</div>`)}${section('研究缺口',list(d.research_gaps),'full')}${section('关联事件',rels(d.related_events),'full')}${section('关联人物组织',rels(d.related_actors),'full')}</div>`;
|
||||
if(type==='event') return `<div class="hero"><h3>${esc(o.title)}</h3><p>${esc(o.summary)}</p></div><div class="detail-grid">${section('时间地点',objGrid({date:o.date,year:o.year,location:o.location,category:dict('event_categories',o.category)}),'full')}${section('事件背景',paragraph(d.background))}${section('为什么重要',paragraph(d.why_it_matters||o.narrative_signal))}${section('叙事信号',paragraph(o.narrative_signal))}${section('竞争叙事/反驳框架',paragraph(o.counter_narrative))}${section('菲律宾官方立场',paragraph(d.ph_official_position))}${section('美国立场',paragraph(d.us_position))}${section('中国立场',paragraph(d.china_position))}${section('反对方立场',paragraph(d.opposition_position))}${section('叙事复杂性',paragraph(d.narrative_complication))}${section('研究备注',paragraph(d.research_note),'full')}${section('相关行为体',rels(o.actors),'full')}${section('关联地点',rels(o.related_sites),'full')}</div>`;
|
||||
if(type==='actor') return `<div class="hero"><h3>${esc(o.name_cn||o.name_en)}</h3><p>${esc(o.brief)}</p></div><div class="detail-grid">${section('基本信息',objGrid({name_en:o.name_en,type:dict('actor_types',o.type),camp:dict('actor_camps',o.camp),current_role:o.current_role,stance_on_edca:dict('stance_values',o.stance_on_edca),stance_on_wps:dict('stance_values',o.stance_on_wps),influence_level:o.influence_level}),'full')}${section('人物/组织画像',paragraph(d.profile))}${section('叙事功能',paragraph(o.narrative_function))}${section('典型立场',list(d.typical_positions))}${section('典型关键词',list(d.typical_keywords))}${section('监测重点',list(d.monitoring_focus),'full')}${section('风险备注',paragraph(d.risk_note))}${section('研究备注',paragraph(d.research_note))}${section('关联事件',rels(d.related_events),'full')}${section('关联地点',rels(d.related_sites),'full')}</div>`;
|
||||
if(type==='narrative') return `<div class="hero"><h3>${esc(o.title)}</h3><p>${esc(o.summary)}</p></div><div class="detail-grid">${section('核心命题',list(o.core_claims),'full')}${section('目标受众',list(o.target_audiences))}${section('典型表达',list(o.typical_expressions))}${section('适用场景',list(d.application_scenarios))}${section('叙事强项',paragraph(d.strength))}${section('叙事弱项',paragraph(d.weakness))}${section('分析备注',paragraph(d.analysis_note),'full')}${section('待补数据',list(d.data_to_collect))}${section('关联事件',rels(o.related_events),'full')}${section('关联人物组织',rels(o.related_actors),'full')}${section('竞争叙事',rels(d.counter_narratives),'full')}</div>`;
|
||||
if(type==='counter_narrative') return `<div class="hero"><h3>${esc(o.title)}</h3><p>${esc(o.summary)}</p></div><div class="detail-grid">${section('主要提出者',list(o.main_speakers))}${section('证据类型',list(o.evidence_types))}${section('优势',paragraph(d.advantage))}${section('限制',paragraph(d.limitation))}${section('观察场景',list(d.where_to_observe))}${section('对“美国棋子”叙事的影响',paragraph(d.impact_on_proxy_narrative),'full')}${section('用于反驳',rels(o.used_against),'full')}${section('关联事件',rels(d.related_events),'full')}</div>`;
|
||||
if(type==='risk') return `<div class="hero"><h3>${esc(o.title)}</h3><p>${esc(o.description)}</p></div><div class="detail-grid">${section('触发场景',list(o.trigger_scenarios),'full')}${section('事实核查重点',list(o.fact_check_focus),'full')}${section('风险边界与处理建议',list(o.mitigation_guidelines),'full')}${section('展示提示',`<div class="note-box">${esc(o.display_note||'')}</div>`)}${section('关联地点',rels(o.related_sites),'full')}${section('关联人物组织',rels(o.related_actors),'full')}</div>`;
|
||||
return section('详情',renderVal(o),'full')
|
||||
}
|
||||
function jump(id){const x=find(id); if(!x)return; closeModal(); state.tab=tabsForType[x.__type]||'search'; renderTabs(); render(); setTimeout(()=>openModal(x.__type,id),80)}
|
||||
function importJson(e){const file=e.target.files[0]; if(!file)return; const r=new FileReader(); r.onload=ev=>{try{DB=JSON.parse(ev.target.result);fillFilters();renderTabs();render();alert('JSON导入成功')}catch(err){alert('JSON解析失败:'+err.message)}}; r.readAsText(file,'utf-8')}
|
||||
function downloadJson(){const blob=new Blob([JSON.stringify(DB,null,2)],{type:'application/json;charset=utf-8'});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download='philippines_not_us_pawn_narrative_db_v2_export.json';a.click();URL.revokeObjectURL(url)}
|
||||
setup();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+49
-7
@@ -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:
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user