Compare commits
2 Commits
09f9cf2bf0
...
37f7a60b0a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37f7a60b0a | ||
|
|
f9ee644f25 |
@@ -6,6 +6,7 @@ from .documents import router as documents_router
|
|||||||
from .knowledge import router as knowledge_router
|
from .knowledge import router as knowledge_router
|
||||||
from .agent import router as agent_router
|
from .agent import router as agent_router
|
||||||
from .status import router as status_router
|
from .status import router as status_router
|
||||||
|
from .perception import router as perception_router
|
||||||
# Keep package boundaries explicit so backend imports stay predictable.
|
# Keep package boundaries explicit so backend imports stay predictable.
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ api_router.include_router(knowledge_router)
|
|||||||
api_router.include_router(agent_router)
|
api_router.include_router(agent_router)
|
||||||
api_router.include_router(compliance_router)
|
api_router.include_router(compliance_router)
|
||||||
api_router.include_router(status_router)
|
api_router.include_router(status_router)
|
||||||
|
api_router.include_router(perception_router)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"api_router",
|
"api_router",
|
||||||
@@ -26,4 +28,5 @@ __all__ = [
|
|||||||
"agent_router",
|
"agent_router",
|
||||||
"compliance_router",
|
"compliance_router",
|
||||||
"status_router",
|
"status_router",
|
||||||
|
"perception_router",
|
||||||
]
|
]
|
||||||
|
|||||||
67
backend/app/api/routes/perception.py
Normal file
67
backend/app/api/routes/perception.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Define API routes for perception (regulatory intelligence)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from app.shared.bootstrap import get_perception_service
|
||||||
|
from app.shared.async_utils import iter_in_thread
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/perception", tags=["智能感知"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def get_perception_stats():
|
||||||
|
"""Return KPI statistics for the perception dashboard."""
|
||||||
|
return get_perception_service().get_stats()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events")
|
||||||
|
async def list_events(
|
||||||
|
source: str | None = Query(default=None, description="来源筛选 (MIIT/UN-ECE/ISO/国标委/EUR-Lex/IATF)"),
|
||||||
|
impact_level: str | None = Query(default=None, description="影响等级 (high/medium/low)"),
|
||||||
|
limit: int = Query(default=50, ge=1, le=100),
|
||||||
|
):
|
||||||
|
"""Return regulatory events with optional filters."""
|
||||||
|
events = get_perception_service().list_events(
|
||||||
|
source=source,
|
||||||
|
impact_level=impact_level,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return {"events": events, "total": len(events)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events/{event_id}")
|
||||||
|
async def get_event(event_id: str):
|
||||||
|
"""Return a single regulatory event by ID."""
|
||||||
|
event = get_perception_service().get_event(event_id)
|
||||||
|
if event is None:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=404, detail=f"Event {event_id} not found")
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/events/{event_id}/analyze")
|
||||||
|
async def analyze_event(event_id: str):
|
||||||
|
"""Stream SSE impact analysis for a regulatory event."""
|
||||||
|
service = get_perception_service()
|
||||||
|
|
||||||
|
async def event_stream():
|
||||||
|
async for item in iter_in_thread(service.analyze_event(event_id)):
|
||||||
|
event_name = item.get("event", "message")
|
||||||
|
data = item.get("data", "")
|
||||||
|
if isinstance(data, (dict, list)):
|
||||||
|
data = json.dumps(data, ensure_ascii=False)
|
||||||
|
yield f"event: {event_name}\ndata: {data}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_stream(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
1
backend/app/application/perception/__init__.py
Normal file
1
backend/app/application/perception/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Perception application package."""
|
||||||
143
backend/app/application/perception/services.py
Normal file
143
backend/app/application/perception/services.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""Perception application service — event listing and streaming impact analysis."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from app.application.knowledge.services import KnowledgeRetrievalService
|
||||||
|
from app.infrastructure.perception.mock_event_store import MockEventStore
|
||||||
|
from app.services.llm.llm_factory import get_llm_client
|
||||||
|
from app.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
_ANALYSIS_SYSTEM_PROMPT = (
|
||||||
|
"你是汽车行业法规合规专家,专注于中国国家标准(GB)、国际法规(UN-ECE、ISO)"
|
||||||
|
"及欧盟法规(EUR-Lex)的解读与合规建议。回答需专业、简洁、结构清晰。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PerceptionService:
|
||||||
|
"""Orchestrate regulatory event queries and streaming impact analysis."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
event_store: MockEventStore,
|
||||||
|
retrieval_service: KnowledgeRetrievalService,
|
||||||
|
) -> None:
|
||||||
|
self._store = event_store
|
||||||
|
self._retrieval = retrieval_service
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Queries
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def list_events(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source: str | None = None,
|
||||||
|
impact_level: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[dict]:
|
||||||
|
return self._store.filter(source=source, impact_level=impact_level, limit=limit)
|
||||||
|
|
||||||
|
def get_event(self, event_id: str) -> dict | None:
|
||||||
|
return self._store.get(event_id)
|
||||||
|
|
||||||
|
def get_stats(self) -> dict:
|
||||||
|
return self._store.stats()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Streaming analysis
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def analyze_event(self, event_id: str) -> Generator[dict, None, None]:
|
||||||
|
"""Yield SSE-ready dicts: sources → content chunks → done."""
|
||||||
|
event = self._store.get(event_id)
|
||||||
|
if not event:
|
||||||
|
yield {"event": "error", "data": f"事件 {event_id} 不存在"}
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- 1. RAG retrieval: find related library documents ---
|
||||||
|
query = event["title"] + " " + " ".join(event["tags"])
|
||||||
|
chunks: list = []
|
||||||
|
affected_docs: list[dict] = []
|
||||||
|
try:
|
||||||
|
chunks = self._retrieval.retrieve(query=query, top_k=5)
|
||||||
|
seen: set[str] = set()
|
||||||
|
for chunk in chunks:
|
||||||
|
if chunk.doc_id not in seen:
|
||||||
|
seen.add(chunk.doc_id)
|
||||||
|
affected_docs.append(
|
||||||
|
{
|
||||||
|
"doc_id": chunk.doc_id,
|
||||||
|
"doc_name": chunk.doc_name,
|
||||||
|
"score": round(float(chunk.score), 4),
|
||||||
|
"snippet": (chunk.content or "")[:180],
|
||||||
|
"clause": getattr(chunk, "section_title", "") or "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
yield {"event": "sources", "data": json.dumps(affected_docs, ensure_ascii=False)}
|
||||||
|
|
||||||
|
# --- 2. Build context from retrieved chunks ---
|
||||||
|
context_parts = [
|
||||||
|
f"[文档{i}: {c.doc_name}]\n{(c.content or '')[:400]}"
|
||||||
|
for i, c in enumerate(chunks[:5], 1)
|
||||||
|
]
|
||||||
|
context = "\n\n".join(context_parts) if context_parts else "(知识库中暂无相关文档)"
|
||||||
|
|
||||||
|
# --- 3. Build prompt ---
|
||||||
|
effective = event.get("effective_at") or "待定"
|
||||||
|
user_content = f"""请对以下法规动态进行专业影响分析。
|
||||||
|
|
||||||
|
【法规动态】
|
||||||
|
标准编号:{event['standard_code']}
|
||||||
|
标题:{event['title']}
|
||||||
|
来源:{event['source_label']}
|
||||||
|
摘要:{event['summary']}
|
||||||
|
生效日期:{effective}
|
||||||
|
分类:{event['category']}
|
||||||
|
关键词:{', '.join(event['tags'])}
|
||||||
|
|
||||||
|
【知识库关联文档】
|
||||||
|
{context}
|
||||||
|
|
||||||
|
请用 Markdown 格式,从以下四个维度进行分析:
|
||||||
|
|
||||||
|
## 核心变化
|
||||||
|
列出本次法规更新最关键的 3-5 项变化(用 - 列表)
|
||||||
|
|
||||||
|
## 业务影响
|
||||||
|
分析对现有产品、认证流程、技术文档的具体影响
|
||||||
|
|
||||||
|
## 整改建议
|
||||||
|
给出优先级排序的行动清单(标注 🔴高 🟡中 🟢低 优先级)
|
||||||
|
|
||||||
|
## 时间节点
|
||||||
|
关键合规时间表与里程碑提醒"""
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": _ANALYSIS_SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": user_content},
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- 4. Stream LLM response ---
|
||||||
|
try:
|
||||||
|
client = get_llm_client(
|
||||||
|
provider=settings.llm_provider,
|
||||||
|
model=settings.llm_model,
|
||||||
|
)
|
||||||
|
if hasattr(client, "stream_chat"):
|
||||||
|
for chunk in client.stream_chat(messages):
|
||||||
|
yield {"event": "content", "data": chunk}
|
||||||
|
else:
|
||||||
|
response = client.chat(messages)
|
||||||
|
yield {"event": "content", "data": response.content or ""}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
yield {"event": "error", "data": str(exc)}
|
||||||
|
return
|
||||||
|
|
||||||
|
yield {"event": "done", "data": "{}"}
|
||||||
1
backend/app/infrastructure/perception/__init__.py
Normal file
1
backend/app/infrastructure/perception/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Perception infrastructure package."""
|
||||||
421
backend/app/infrastructure/perception/mock_event_store.py
Normal file
421
backend/app/infrastructure/perception/mock_event_store.py
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
"""Mock regulatory event store with 20 high-quality pre-seeded events."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
MOCK_EVENTS: list[dict[str, Any]] = [
|
||||||
|
# ------------------------------------------------------------------ HIGH
|
||||||
|
{
|
||||||
|
"id": "evt-001",
|
||||||
|
"source": "MIIT",
|
||||||
|
"source_label": "工业和信息化部",
|
||||||
|
"standard_code": "GB 18384-2025",
|
||||||
|
"title": "《电动汽车安全要求》国家标准第三版正式发布",
|
||||||
|
"summary": (
|
||||||
|
"新增 IP67 级别高压系统密封防护要求;热失控预警响应时间压缩至 5 分钟;"
|
||||||
|
"调整碰撞安全测试工况,新增侧柱碰工况。本标准于 2026 年 7 月 1 日强制实施。"
|
||||||
|
),
|
||||||
|
"impact_level": "high",
|
||||||
|
"published_at": "2025-11-15",
|
||||||
|
"effective_at": "2026-07-01",
|
||||||
|
"category": "电动汽车安全",
|
||||||
|
"tags": ["电池安全", "高压防护", "碰撞安全", "热失控"],
|
||||||
|
"source_url": "https://openstd.samr.gov.cn",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-002",
|
||||||
|
"source": "UN-ECE",
|
||||||
|
"source_label": "联合国欧洲经委会",
|
||||||
|
"standard_code": "UN R155 Amendment 3",
|
||||||
|
"title": "UN-ECE R155 网络安全法规第三次修订正式生效",
|
||||||
|
"summary": (
|
||||||
|
"新增对 OTA(空中升级)全生命周期的安全审计要求;强化车辆 TARA"
|
||||||
|
"(威胁分析与风险评估)文档化义务;扩展 CSMS 监控范围至售后服务商。"
|
||||||
|
),
|
||||||
|
"impact_level": "high",
|
||||||
|
"published_at": "2026-01-20",
|
||||||
|
"effective_at": "2026-07-01",
|
||||||
|
"category": "网络安全",
|
||||||
|
"tags": ["OTA", "网络安全", "CSMS", "TARA", "R155"],
|
||||||
|
"source_url": "https://unece.org/transport/vehicle-regulations",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-003",
|
||||||
|
"source": "MIIT",
|
||||||
|
"source_label": "工业和信息化部",
|
||||||
|
"standard_code": "GB/T 40429-2026(征求意见稿)",
|
||||||
|
"title": "《汽车整车信息安全技术要求》修订征求意见",
|
||||||
|
"summary": (
|
||||||
|
"增加基于人工智能的异常行为检测要求;新增车云通信双向认证机制规范;"
|
||||||
|
"提出数据最小化原则在车辆 OBD 数据收集中的应用细则。"
|
||||||
|
),
|
||||||
|
"impact_level": "high",
|
||||||
|
"published_at": "2026-03-05",
|
||||||
|
"effective_at": None,
|
||||||
|
"category": "信息安全",
|
||||||
|
"tags": ["信息安全", "数据安全", "AI检测", "OBD"],
|
||||||
|
"source_url": "https://www.miit.gov.cn/",
|
||||||
|
"status": "draft",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-004",
|
||||||
|
"source": "MIIT",
|
||||||
|
"source_label": "工业和信息化部",
|
||||||
|
"standard_code": "NEV 双积分 2026",
|
||||||
|
"title": "2026 年度新能源汽车双积分管理办法年度调整",
|
||||||
|
"summary": (
|
||||||
|
"纯电动乘用车标准车型积分(CAFC)基准值上调 8%;"
|
||||||
|
"提高 A 级及以上续航里程门槛;新增氢燃料电池商用车积分计算细则。"
|
||||||
|
),
|
||||||
|
"impact_level": "high",
|
||||||
|
"published_at": "2026-02-28",
|
||||||
|
"effective_at": "2026-04-01",
|
||||||
|
"category": "新能源政策",
|
||||||
|
"tags": ["双积分", "纯电动", "燃料电池", "碳配额"],
|
||||||
|
"source_url": "https://www.miit.gov.cn/",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-017",
|
||||||
|
"source": "MIIT",
|
||||||
|
"source_label": "工业和信息化部",
|
||||||
|
"standard_code": "智能网联汽车准入管理办法实施细则",
|
||||||
|
"title": "智能网联汽车准入管理实施细则正式落地",
|
||||||
|
"summary": (
|
||||||
|
"明确 L3 及以上自动驾驶功能的准入申报路径;"
|
||||||
|
"要求 OEM 建立数据安全管理体系并完成等保 2.0 三级认证;"
|
||||||
|
"道路测试数据留存期延长至 3 年。"
|
||||||
|
),
|
||||||
|
"impact_level": "high",
|
||||||
|
"published_at": "2026-03-01",
|
||||||
|
"effective_at": "2026-09-01",
|
||||||
|
"category": "智能网联",
|
||||||
|
"tags": ["智能网联", "L3自动驾驶", "准入管理", "数据留存"],
|
||||||
|
"source_url": "https://www.miit.gov.cn/",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-018",
|
||||||
|
"source": "EUR-Lex",
|
||||||
|
"source_label": "欧盟官方公报",
|
||||||
|
"standard_code": "EU Cyber Resilience Act (CRA)",
|
||||||
|
"title": "《欧盟网络韧性法案》核心条款对车联网设备生效",
|
||||||
|
"summary": (
|
||||||
|
"联网汽车 ECU 须满足 CRA「重要类 II」安全要求;"
|
||||||
|
"强制 SBOM(软件物料清单)公开披露;"
|
||||||
|
"OEM 须提供至少 10 年的漏洞修复支持承诺。"
|
||||||
|
),
|
||||||
|
"impact_level": "high",
|
||||||
|
"published_at": "2026-02-15",
|
||||||
|
"effective_at": "2027-01-01",
|
||||||
|
"category": "网络安全",
|
||||||
|
"tags": ["CRA", "SBOM", "漏洞管理", "网络韧性"],
|
||||||
|
"source_url": "https://eur-lex.europa.eu",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
# --------------------------------------------------------------- MEDIUM
|
||||||
|
{
|
||||||
|
"id": "evt-005",
|
||||||
|
"source": "UN-ECE",
|
||||||
|
"source_label": "联合国欧洲经委会",
|
||||||
|
"standard_code": "UN R156 Amendment 2",
|
||||||
|
"title": "UN-ECE R156 软件升级与 SUMS 法规补充修订",
|
||||||
|
"summary": (
|
||||||
|
"明确 SUMS(软件更新管理系统)对 ECU 版本追溯的最低保留年限为 15 年;"
|
||||||
|
"新增售后 OTA 推送的用户知情同意要求规范。"
|
||||||
|
),
|
||||||
|
"impact_level": "medium",
|
||||||
|
"published_at": "2026-01-10",
|
||||||
|
"effective_at": "2026-07-01",
|
||||||
|
"category": "软件升级",
|
||||||
|
"tags": ["OTA", "SUMS", "软件版本", "R156"],
|
||||||
|
"source_url": "https://unece.org/transport/vehicle-regulations",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-006",
|
||||||
|
"source": "国标委",
|
||||||
|
"source_label": "国家标准化管理委员会",
|
||||||
|
"standard_code": "GB/T 35273-2026",
|
||||||
|
"title": "《信息安全技术 个人信息安全规范》更新版发布",
|
||||||
|
"summary": (
|
||||||
|
"将车内人脸识别、声纹采集列为敏感个人信息;"
|
||||||
|
"补充自动驾驶场景下乘员行为数据的去标识化技术规范;"
|
||||||
|
"强化数据出境安全评估触发阈值。"
|
||||||
|
),
|
||||||
|
"impact_level": "medium",
|
||||||
|
"published_at": "2025-12-01",
|
||||||
|
"effective_at": "2026-06-01",
|
||||||
|
"category": "数据安全",
|
||||||
|
"tags": ["个人信息", "PIPL", "数据安全", "生物识别"],
|
||||||
|
"source_url": "https://openstd.samr.gov.cn",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-007",
|
||||||
|
"source": "EUR-Lex",
|
||||||
|
"source_label": "欧盟官方公报",
|
||||||
|
"standard_code": "EU AI Act — Art.13 & Art.14",
|
||||||
|
"title": "《欧盟人工智能法案》第13-14条透明度与人工监督条款正式生效",
|
||||||
|
"summary": (
|
||||||
|
"要求在汽车 ADAS 系统中植入 AI 使用记录日志;"
|
||||||
|
"驾驶员监控 AI 系统须披露决策逻辑;"
|
||||||
|
"高风险 AI 系统需提供人工干预接口。"
|
||||||
|
),
|
||||||
|
"impact_level": "medium",
|
||||||
|
"published_at": "2026-02-01",
|
||||||
|
"effective_at": "2026-08-01",
|
||||||
|
"category": "AI 法规",
|
||||||
|
"tags": ["AI法案", "透明度", "ADAS", "高风险AI"],
|
||||||
|
"source_url": "https://eur-lex.europa.eu",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-008",
|
||||||
|
"source": "ISO",
|
||||||
|
"source_label": "国际标准化组织",
|
||||||
|
"standard_code": "ISO 45001:2025 Amd.1",
|
||||||
|
"title": "ISO 45001 职业健康安全管理体系第一次修正",
|
||||||
|
"summary": (
|
||||||
|
"新增心理健康风险纳入 OHS 危害辨识范围;"
|
||||||
|
"明确远程办公人员安全管理职责;"
|
||||||
|
"更新绩效评价指标体系,新增事故未遂事件统计要求。"
|
||||||
|
),
|
||||||
|
"impact_level": "medium",
|
||||||
|
"published_at": "2025-10-20",
|
||||||
|
"effective_at": "2026-01-01",
|
||||||
|
"category": "EHS 管理",
|
||||||
|
"tags": ["ISO 45001", "EHS", "职业健康", "安全管理"],
|
||||||
|
"source_url": "https://www.iso.org",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-009",
|
||||||
|
"source": "MIIT",
|
||||||
|
"source_label": "工业和信息化部",
|
||||||
|
"standard_code": "GB/T 28001-2026(征求意见)",
|
||||||
|
"title": "《汽车产品安全召回管理规程》修订征求意见",
|
||||||
|
"summary": (
|
||||||
|
"扩展召回触发条件,将 OTA 推送导致的功能异常纳入强制报告范围;"
|
||||||
|
"缩短重大安全隐患召回启动时限至 15 个工作日。"
|
||||||
|
),
|
||||||
|
"impact_level": "medium",
|
||||||
|
"published_at": "2026-03-15",
|
||||||
|
"effective_at": None,
|
||||||
|
"category": "召回管理",
|
||||||
|
"tags": ["召回", "OTA", "安全隐患", "产品安全"],
|
||||||
|
"source_url": "https://www.miit.gov.cn/",
|
||||||
|
"status": "draft",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-010",
|
||||||
|
"source": "国标委",
|
||||||
|
"source_label": "国家标准化管理委员会",
|
||||||
|
"standard_code": "GB 38031-2025",
|
||||||
|
"title": "《电动汽车用动力蓄电池安全要求》修订版发布",
|
||||||
|
"summary": (
|
||||||
|
"新增电池系统针刺、浸水、挤压等极端工况测试程序;"
|
||||||
|
"热扩散防护等级要求升级;"
|
||||||
|
"强化 BMS(电池管理系统)状态监测数据记录要求。"
|
||||||
|
),
|
||||||
|
"impact_level": "medium",
|
||||||
|
"published_at": "2025-09-15",
|
||||||
|
"effective_at": "2026-03-01",
|
||||||
|
"category": "电池安全",
|
||||||
|
"tags": ["动力电池", "BMS", "热扩散", "安全测试"],
|
||||||
|
"source_url": "https://openstd.samr.gov.cn",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-016",
|
||||||
|
"source": "UN-ECE",
|
||||||
|
"source_label": "联合国欧洲经委会",
|
||||||
|
"standard_code": "UN R100 Rev.4(草案)",
|
||||||
|
"title": "UN R100 电动汽车安全认证法规第四次修订草案发布",
|
||||||
|
"summary": (
|
||||||
|
"拟对 400V 以上高压系统的绝缘电阻监测提出实时 CAN 总线传输要求;"
|
||||||
|
"新增极低温工况(-40°C)的电池性能验证程序。"
|
||||||
|
),
|
||||||
|
"impact_level": "medium",
|
||||||
|
"published_at": "2026-04-08",
|
||||||
|
"effective_at": None,
|
||||||
|
"category": "电动汽车安全",
|
||||||
|
"tags": ["R100", "高压安全", "绝缘监测", "低温性能"],
|
||||||
|
"source_url": "https://unece.org/transport/vehicle-regulations",
|
||||||
|
"status": "draft",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-019",
|
||||||
|
"source": "ISO",
|
||||||
|
"source_label": "国际标准化组织",
|
||||||
|
"standard_code": "ISO/SAE 21434:2026 Amd.1",
|
||||||
|
"title": "ISO/SAE 21434 汽车网络安全工程第一次修正",
|
||||||
|
"summary": (
|
||||||
|
"将 AI 推理组件纳入汽车网络安全工程范围;"
|
||||||
|
"补充端到端加密通信在 V2X 场景中的 TARA 建模要求;"
|
||||||
|
"新增第三方 ECU 供应商 CSMS 审计方法。"
|
||||||
|
),
|
||||||
|
"impact_level": "medium",
|
||||||
|
"published_at": "2026-04-10",
|
||||||
|
"effective_at": "2026-10-01",
|
||||||
|
"category": "网络安全",
|
||||||
|
"tags": ["ISO 21434", "网络安全", "V2X", "AI安全"],
|
||||||
|
"source_url": "https://www.iso.org",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
# ------------------------------------------------------------------ LOW
|
||||||
|
{
|
||||||
|
"id": "evt-011",
|
||||||
|
"source": "ISO",
|
||||||
|
"source_label": "国际标准化组织",
|
||||||
|
"standard_code": "ISO 26262:2026 Ed.3(征求意见)",
|
||||||
|
"title": "ISO 26262 功能安全第三版征求意见启动",
|
||||||
|
"summary": (
|
||||||
|
"拟新增对 AI/ML 组件功能安全验证方法的指导附录;"
|
||||||
|
"讨论 SOTIF(预期功能安全)与 ISO 26262 的协调融合路径。"
|
||||||
|
),
|
||||||
|
"impact_level": "low",
|
||||||
|
"published_at": "2026-04-01",
|
||||||
|
"effective_at": None,
|
||||||
|
"category": "功能安全",
|
||||||
|
"tags": ["功能安全", "ASIL", "AI安全", "SOTIF"],
|
||||||
|
"source_url": "https://www.iso.org",
|
||||||
|
"status": "consultation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-012",
|
||||||
|
"source": "EUR-Lex",
|
||||||
|
"source_label": "欧盟官方公报",
|
||||||
|
"standard_code": "REACH Regulation Update 2026",
|
||||||
|
"title": "欧盟 REACH 法规限制物质清单更新(第 22 批)",
|
||||||
|
"summary": (
|
||||||
|
"新增 3 种 SVHCs(高度关注物质),包括特定阻燃剂和密封材料成分;"
|
||||||
|
"汽车零部件豁免条款调整,影响部分内饰材料供应商。"
|
||||||
|
),
|
||||||
|
"impact_level": "low",
|
||||||
|
"published_at": "2026-01-30",
|
||||||
|
"effective_at": "2026-09-01",
|
||||||
|
"category": "环保法规",
|
||||||
|
"tags": ["REACH", "SVHCs", "环保", "化学品管理"],
|
||||||
|
"source_url": "https://eur-lex.europa.eu",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-013",
|
||||||
|
"source": "MIIT",
|
||||||
|
"source_label": "工业和信息化部",
|
||||||
|
"standard_code": "CCER 汽车碳配额 2026",
|
||||||
|
"title": "自愿减排(CCER)汽车行业核算方法学更新",
|
||||||
|
"summary": (
|
||||||
|
"更新纯电动汽车全生命周期碳排放核算边界;"
|
||||||
|
"新增动力电池回收环节碳减排量认定方法;"
|
||||||
|
"与全国碳市场对接的企业碳账户数据接口规范发布。"
|
||||||
|
),
|
||||||
|
"impact_level": "low",
|
||||||
|
"published_at": "2026-02-10",
|
||||||
|
"effective_at": "2026-06-01",
|
||||||
|
"category": "碳排放",
|
||||||
|
"tags": ["CCER", "碳排放", "碳中和", "碳核算"],
|
||||||
|
"source_url": "https://www.miit.gov.cn/",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-014",
|
||||||
|
"source": "IATF",
|
||||||
|
"source_label": "国际汽车工作组",
|
||||||
|
"standard_code": "IATF 16949:2025 CSR 通告",
|
||||||
|
"title": "IATF 16949 质量管理体系客户特殊要求更新通告",
|
||||||
|
"summary": (
|
||||||
|
"多家主机厂(OEM)同步更新 CSR,涵盖软件定义汽车(SDV)"
|
||||||
|
"场景下的质量过程管控;电子电气 BOM 变更管理流程补充规范。"
|
||||||
|
),
|
||||||
|
"impact_level": "low",
|
||||||
|
"published_at": "2026-03-20",
|
||||||
|
"effective_at": "2026-07-01",
|
||||||
|
"category": "质量管理",
|
||||||
|
"tags": ["IATF 16949", "质量管理", "SDV", "CSR"],
|
||||||
|
"source_url": "https://www.iatfglobaloversight.org",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-015",
|
||||||
|
"source": "国标委",
|
||||||
|
"source_label": "国家标准化管理委员会",
|
||||||
|
"standard_code": "GB 7258-2025 勘误",
|
||||||
|
"title": "《机动车运行安全技术条件》年度勘误发布",
|
||||||
|
"summary": (
|
||||||
|
"更正第 12 章灯光系统技术要求中的参数引用错误;"
|
||||||
|
"澄清前雾灯安装位置尺寸定义;此次为勘误性修订,不影响已认证车型。"
|
||||||
|
),
|
||||||
|
"impact_level": "low",
|
||||||
|
"published_at": "2026-01-05",
|
||||||
|
"effective_at": "2026-01-05",
|
||||||
|
"category": "运行安全",
|
||||||
|
"tags": ["GB 7258", "灯光", "运行安全", "勘误"],
|
||||||
|
"source_url": "https://openstd.samr.gov.cn",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-020",
|
||||||
|
"source": "国标委",
|
||||||
|
"source_label": "国家标准化管理委员会",
|
||||||
|
"standard_code": "GB/T 27930-2026",
|
||||||
|
"title": "《电动汽车非车载传导式充电通信协议》更新版发布",
|
||||||
|
"summary": (
|
||||||
|
"兼容 CHAdeMO 4.0 与 CCS2 双协议栈;"
|
||||||
|
"新增大功率充电(>350kW)通信握手流程;"
|
||||||
|
"强化充电过程 BMS 实时诊断数据上报规范。"
|
||||||
|
),
|
||||||
|
"impact_level": "low",
|
||||||
|
"published_at": "2026-03-25",
|
||||||
|
"effective_at": "2026-12-01",
|
||||||
|
"category": "充电标准",
|
||||||
|
"tags": ["充电协议", "BMS", "大功率充电", "CHAdeMO"],
|
||||||
|
"source_url": "https://openstd.samr.gov.cn",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Index for fast lookup
|
||||||
|
_EVENT_INDEX: dict[str, dict] = {e["id"]: e for e in MOCK_EVENTS}
|
||||||
|
|
||||||
|
|
||||||
|
class MockEventStore:
|
||||||
|
"""In-memory mock store for regulatory events."""
|
||||||
|
|
||||||
|
def all(self) -> list[dict]:
|
||||||
|
return list(MOCK_EVENTS)
|
||||||
|
|
||||||
|
def get(self, event_id: str) -> dict | None:
|
||||||
|
return _EVENT_INDEX.get(event_id)
|
||||||
|
|
||||||
|
def filter(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source: str | None = None,
|
||||||
|
impact_level: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[dict]:
|
||||||
|
events = list(MOCK_EVENTS)
|
||||||
|
if source:
|
||||||
|
events = [e for e in events if e["source"] == source]
|
||||||
|
if impact_level:
|
||||||
|
events = [e for e in events if e["impact_level"] == impact_level]
|
||||||
|
events.sort(key=lambda e: e["published_at"], reverse=True)
|
||||||
|
return events[:limit]
|
||||||
|
|
||||||
|
def stats(self) -> dict:
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
events = MOCK_EVENTS
|
||||||
|
cutoff = (date.today() - timedelta(days=90)).isoformat()
|
||||||
|
return {
|
||||||
|
"total": len(events),
|
||||||
|
"high_impact": sum(1 for e in events if e["impact_level"] == "high"),
|
||||||
|
"medium_impact": sum(1 for e in events if e["impact_level"] == "medium"),
|
||||||
|
"low_impact": sum(1 for e in events if e["impact_level"] == "low"),
|
||||||
|
"recent_90d": sum(1 for e in events if e["published_at"] >= cutoff),
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ from app.infrastructure.vectorstore.bm25_retriever import BM25Retriever
|
|||||||
from app.infrastructure.vectorstore.dense_retriever import DenseRetriever
|
from app.infrastructure.vectorstore.dense_retriever import DenseRetriever
|
||||||
from app.infrastructure.vectorstore.milvus_vector_index import MilvusVectorIndex
|
from app.infrastructure.vectorstore.milvus_vector_index import MilvusVectorIndex
|
||||||
from app.infrastructure.vectorstore.cross_encoder_reranker import OpenAICompatibleReranker
|
from app.infrastructure.vectorstore.cross_encoder_reranker import OpenAICompatibleReranker
|
||||||
|
from app.infrastructure.perception.mock_event_store import MockEventStore
|
||||||
|
from app.application.perception.services import PerceptionService
|
||||||
# Keep shared wiring centralized so dependency construction remains consistent.
|
# Keep shared wiring centralized so dependency construction remains consistent.
|
||||||
|
|
||||||
|
|
||||||
@@ -151,3 +153,12 @@ def get_agent_conversation_service() -> AgentConversationService:
|
|||||||
answer_generator=OpenAICompatibleAnswerGenerator(),
|
answer_generator=OpenAICompatibleAnswerGenerator(),
|
||||||
conversation_store=get_conversation_store(),
|
conversation_store=get_conversation_store(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_perception_service() -> PerceptionService:
|
||||||
|
"""Return perception service for regulatory intelligence."""
|
||||||
|
return PerceptionService(
|
||||||
|
event_store=MockEventStore(),
|
||||||
|
retrieval_service=get_retrieval_service(),
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import { CompliancePage } from './pages/Compliance';
|
|||||||
import { DocsPage } from './pages/Docs';
|
import { DocsPage } from './pages/Docs';
|
||||||
import { StatusPage } from './pages/Status';
|
import { StatusPage } from './pages/Status';
|
||||||
import { RagChatPage } from './pages/RagChat';
|
import { RagChatPage } from './pages/RagChat';
|
||||||
|
import { PerceptionPage } from './pages/Perception';
|
||||||
|
|
||||||
const PageContent = () => {
|
const PageContent = () => {
|
||||||
const { activeTab } = useApp();
|
const { activeTab } = useApp();
|
||||||
|
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
|
case 'perception':
|
||||||
|
return <PerceptionPage />;
|
||||||
case 'docs':
|
case 'docs':
|
||||||
return <DocsPage />;
|
return <DocsPage />;
|
||||||
case 'compliance':
|
case 'compliance':
|
||||||
@@ -19,7 +22,7 @@ const PageContent = () => {
|
|||||||
case 'rag':
|
case 'rag':
|
||||||
return <RagChatPage />;
|
return <RagChatPage />;
|
||||||
default:
|
default:
|
||||||
return <CompliancePage />;
|
return <PerceptionPage />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
128
frontend/src/api/perception.ts
Normal file
128
frontend/src/api/perception.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
const PERCEPTION_API_BASE = '/api/v1';
|
||||||
|
|
||||||
|
export type ImpactLevel = 'high' | 'medium' | 'low';
|
||||||
|
export type EventStatus = 'enacted' | 'draft' | 'consultation';
|
||||||
|
export type EventSource = 'MIIT' | 'UN-ECE' | 'ISO' | '国标委' | 'EUR-Lex' | 'IATF';
|
||||||
|
|
||||||
|
export interface RegulationEvent {
|
||||||
|
id: string;
|
||||||
|
source: EventSource;
|
||||||
|
source_label: string;
|
||||||
|
standard_code: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
impact_level: ImpactLevel;
|
||||||
|
published_at: string;
|
||||||
|
effective_at: string | null;
|
||||||
|
category: string;
|
||||||
|
tags: string[];
|
||||||
|
source_url: string;
|
||||||
|
status: EventStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PerceptionStats {
|
||||||
|
total: number;
|
||||||
|
high_impact: number;
|
||||||
|
medium_impact: number;
|
||||||
|
low_impact: number;
|
||||||
|
recent_90d: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventListResponse {
|
||||||
|
events: RegulationEvent[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AffectedDoc {
|
||||||
|
doc_id: string;
|
||||||
|
doc_name: string;
|
||||||
|
score: number;
|
||||||
|
snippet: string;
|
||||||
|
clause: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalysisSSEMessage {
|
||||||
|
type: 'sources' | 'content' | 'done' | 'error';
|
||||||
|
docs?: AffectedDoc[];
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPerceptionStats(): Promise<PerceptionStats> {
|
||||||
|
const res = await fetch(`${PERCEPTION_API_BASE}/perception/stats`);
|
||||||
|
if (!res.ok) throw new Error(`stats failed: ${res.status}`);
|
||||||
|
return res.json() as Promise<PerceptionStats>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEvents(params?: {
|
||||||
|
source?: string;
|
||||||
|
impact_level?: string;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<EventListResponse> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.source) query.set('source', params.source);
|
||||||
|
if (params?.impact_level) query.set('impact_level', params.impact_level);
|
||||||
|
if (params?.limit) query.set('limit', String(params.limit));
|
||||||
|
const res = await fetch(`${PERCEPTION_API_BASE}/perception/events?${query.toString()}`);
|
||||||
|
if (!res.ok) throw new Error(`list events failed: ${res.status}`);
|
||||||
|
return res.json() as Promise<EventListResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function analyzeEvent(
|
||||||
|
eventId: string,
|
||||||
|
onMessage: (msg: AnalysisSSEMessage) => void,
|
||||||
|
onComplete?: () => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${PERCEPTION_API_BASE}/perception/events/${eventId}/analyze`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'text/event-stream' },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!res.ok || !res.body) throw new Error(`analyze failed: ${res.status}`);
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const parts = buffer.split('\n\n');
|
||||||
|
buffer = parts.pop() ?? '';
|
||||||
|
for (const block of parts) {
|
||||||
|
if (!block.trim()) continue;
|
||||||
|
let eventName = 'message';
|
||||||
|
const dataLines: string[] = [];
|
||||||
|
for (const line of block.split('\n')) {
|
||||||
|
if (line.startsWith('event:')) eventName = line.slice(6).trim();
|
||||||
|
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
|
||||||
|
}
|
||||||
|
const payload = dataLines.join('\n');
|
||||||
|
if (!payload) continue;
|
||||||
|
|
||||||
|
if (eventName === 'sources') {
|
||||||
|
try {
|
||||||
|
const docs = JSON.parse(payload) as AffectedDoc[];
|
||||||
|
onMessage({ type: 'sources', docs });
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
} else if (eventName === 'content') {
|
||||||
|
onMessage({ type: 'content', text: payload });
|
||||||
|
} else if (eventName === 'done') {
|
||||||
|
onMessage({ type: 'done' });
|
||||||
|
} else if (eventName === 'error') {
|
||||||
|
onMessage({ type: 'error', text: payload });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buffer.trim()) {
|
||||||
|
// flush remaining
|
||||||
|
}
|
||||||
|
onComplete?.();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||||
|
onMessage({ type: 'error', text: err instanceof Error ? err.message : String(err) });
|
||||||
|
onComplete?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useTheme, useApp } from '../../contexts';
|
|||||||
import type { TabId } from '../../contexts';
|
import type { TabId } from '../../contexts';
|
||||||
|
|
||||||
const tabs: Array<{ id: TabId; label: string }> = [
|
const tabs: Array<{ id: TabId; label: string }> = [
|
||||||
|
{ id: 'perception', label: '智能感知' },
|
||||||
{ id: 'docs', label: '文档管理' },
|
{ id: 'docs', label: '文档管理' },
|
||||||
{ id: 'compliance', label: '合规分析' },
|
{ id: 'compliance', label: '合规分析' },
|
||||||
{ id: 'status', label: '系统状态' },
|
{ id: 'status', label: '系统状态' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createContext } from 'react';
|
import { createContext } from 'react';
|
||||||
|
|
||||||
export type TabId = 'docs' | 'compliance' | 'status' | 'rag';
|
export type TabId = 'perception' | 'docs' | 'compliance' | 'status' | 'rag';
|
||||||
|
|
||||||
export interface AppContextValue {
|
export interface AppContextValue {
|
||||||
activeTab: TabId;
|
activeTab: TabId;
|
||||||
|
|||||||
207
frontend/src/pages/Perception/AnalysisPanel.tsx
Normal file
207
frontend/src/pages/Perception/AnalysisPanel.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { useTheme } from '../../contexts';
|
||||||
|
import type { RegulationEvent, AffectedDoc } from '../../api/perception';
|
||||||
|
|
||||||
|
interface AnalysisPanelProps {
|
||||||
|
event: RegulationEvent | null;
|
||||||
|
analyzing: boolean;
|
||||||
|
analysisText: string;
|
||||||
|
affectedDocs: AffectedDoc[];
|
||||||
|
onAnalyze: () => void;
|
||||||
|
onAbort: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal markdown renderer — handles ##/### headings, **bold**, bullet lists
|
||||||
|
function MarkdownText({ text, textColor, accent }: { text: string; textColor: string; accent: string }) {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
return (
|
||||||
|
<div style={{ fontSize: 14, lineHeight: 1.75, color: textColor }}>
|
||||||
|
{lines.map((line, i) => {
|
||||||
|
if (line.startsWith('## ')) {
|
||||||
|
return <div key={i} style={{ fontSize: 15, fontWeight: 700, color: accent, marginTop: 18, marginBottom: 6 }}>{line.slice(3)}</div>;
|
||||||
|
}
|
||||||
|
if (line.startsWith('### ')) {
|
||||||
|
return <div key={i} style={{ fontSize: 13, fontWeight: 700, marginTop: 12, marginBottom: 4 }}>{line.slice(4)}</div>;
|
||||||
|
}
|
||||||
|
if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||||
|
const content = line.slice(2);
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 4, paddingLeft: 8 }}>
|
||||||
|
<span style={{ color: accent, flexShrink: 0 }}>·</span>
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: content.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (/^\d+\./.test(line)) {
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ marginBottom: 4, paddingLeft: 8 }}>
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: line.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!line.trim()) return <div key={i} style={{ height: 8 }} />;
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ marginBottom: 4 }}>
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: line.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMPACT_COLORS = { high: '#d64545', medium: '#ff8800', low: '#00d4aa' };
|
||||||
|
const SOURCE_COLORS: Record<string, string> = {
|
||||||
|
MIIT: '#e20074', 'UN-ECE': '#4a90d9', ISO: '#7b68ee',
|
||||||
|
'国标委': '#00b89c', 'EUR-Lex': '#f5a623', IATF: '#9b59b6',
|
||||||
|
};
|
||||||
|
const STATUS_LABEL: Record<string, string> = { enacted: '已生效', draft: '征求意见', consultation: '公众咨询' };
|
||||||
|
|
||||||
|
export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
|
||||||
|
event, analyzing, analysisText, affectedDocs, onAnalyze, onAbort,
|
||||||
|
}) => {
|
||||||
|
const { theme, isDark } = useTheme();
|
||||||
|
const analysisRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
|
||||||
|
<div style={{ fontSize: 48, opacity: 0.15 }}>◈</div>
|
||||||
|
<div style={{ fontSize: 14, color: theme.text3 }}>选择左侧法规动态以查看智能影响分析</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const impactColor = IMPACT_COLORS[event.impact_level];
|
||||||
|
const srcColor = SOURCE_COLORS[event.source] || theme.accent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 0 }}>
|
||||||
|
{/* Event header */}
|
||||||
|
<div style={{
|
||||||
|
padding: '20px 24px',
|
||||||
|
background: theme.bgCard,
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
borderLeft: `4px solid ${impactColor}`,
|
||||||
|
marginBottom: 16,
|
||||||
|
flexShrink: 0,
|
||||||
|
boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none',
|
||||||
|
}}>
|
||||||
|
{/* Source + status */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: srcColor, background: srcColor + '18', borderRadius: 4, padding: '3px 8px' }}>{event.source}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 11, color: theme.text3 }}>{event.standard_code}</span>
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: 11, color: event.status === 'enacted' ? theme.green : '#ff8800', fontWeight: 600 }}>
|
||||||
|
{STATUS_LABEL[event.status] ?? event.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700, color: theme.text, lineHeight: 1.4, marginBottom: 10 }}>
|
||||||
|
{event.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div style={{ fontSize: 13, color: theme.text2, lineHeight: 1.6, marginBottom: 12 }}>
|
||||||
|
{event.summary}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||||
|
{event.tags.map(tag => (
|
||||||
|
<span key={tag} style={{ fontSize: 11, color: theme.text3, background: theme.bgHover, borderRadius: 4, padding: '2px 8px', border: `1px solid ${theme.border}` }}>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dates + Analyze button */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div className="mono" style={{ fontSize: 11, color: theme.text3 }}>
|
||||||
|
发布:{event.published_at}
|
||||||
|
{event.effective_at && <span style={{ marginLeft: 12 }}>生效:<span style={{ color: impactColor }}>{event.effective_at}</span></span>}
|
||||||
|
</div>
|
||||||
|
{analyzing ? (
|
||||||
|
<button onClick={onAbort} style={{ padding: '7px 18px', borderRadius: 8, border: '1px solid #d64545', background: 'transparent', color: '#d64545', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>
|
||||||
|
■ 停止
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={onAnalyze} style={{ padding: '7px 18px', borderRadius: 8, border: 'none', background: theme.gradientAccent, color: '#fff', cursor: 'pointer', fontSize: 13, fontWeight: 600, boxShadow: '0 2px 8px rgba(226,0,116,0.3)' }}>
|
||||||
|
⚡ 触发智能分析
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Affected documents */}
|
||||||
|
{affectedDocs.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 16, flexShrink: 0 }}>
|
||||||
|
<div className="mono" style={{ fontSize: 11, color: theme.text3, letterSpacing: '1px', marginBottom: 8 }}>
|
||||||
|
关联文档({affectedDocs.length})
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{affectedDocs.map(doc => (
|
||||||
|
<div key={doc.doc_id} style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: theme.bgCard,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
borderLeft: `3px solid ${theme.accent}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: theme.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{doc.doc_name}
|
||||||
|
</div>
|
||||||
|
{doc.snippet && (
|
||||||
|
<div style={{ fontSize: 12, color: theme.text3, marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{doc.snippet}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="mono" style={{ fontSize: 11, color: theme.accent, flexShrink: 0 }}>
|
||||||
|
{Math.round(doc.score * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Streaming analysis output */}
|
||||||
|
{(analysisText || analyzing) && (
|
||||||
|
<div ref={analysisRef} style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '20px 24px',
|
||||||
|
background: theme.bgCard,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: !isDark ? '0 2px 8px rgba(0,0,0,0.03)' : 'none',
|
||||||
|
}}>
|
||||||
|
<div className="mono" style={{ fontSize: 11, color: theme.accent, letterSpacing: '1px', marginBottom: 14 }}>
|
||||||
|
ANALYSIS {analyzing && <span style={{ animation: 'blink 1s step-end infinite' }}>▌</span>}
|
||||||
|
</div>
|
||||||
|
{analysisText && (
|
||||||
|
<MarkdownText text={analysisText} textColor={theme.text2} accent={theme.accent} />
|
||||||
|
)}
|
||||||
|
{analyzing && !analysisText && (
|
||||||
|
<div style={{ color: theme.text3, fontSize: 13 }}>正在分析法规影响...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty analysis state */}
|
||||||
|
{!analysisText && !analyzing && (
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<div style={{ textAlign: 'center', color: theme.text3, fontSize: 13 }}>
|
||||||
|
点击「触发智能分析」查看 AI 影响评估
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
157
frontend/src/pages/Perception/EventFeed.tsx
Normal file
157
frontend/src/pages/Perception/EventFeed.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTheme } from '../../contexts';
|
||||||
|
import type { RegulationEvent, ImpactLevel, EventSource } from '../../api/perception';
|
||||||
|
|
||||||
|
const IMPACT_CONFIG: Record<ImpactLevel, { color: string; label: string; dot: string }> = {
|
||||||
|
high: { color: '#d64545', label: '高影响', dot: '●' },
|
||||||
|
medium: { color: '#ff8800', label: '中影响', dot: '●' },
|
||||||
|
low: { color: '#00d4aa', label: '低影响', dot: '●' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
enacted: '已生效',
|
||||||
|
draft: '征求意见',
|
||||||
|
consultation: '公众咨询',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_COLORS: Record<string, string> = {
|
||||||
|
MIIT: '#e20074',
|
||||||
|
'UN-ECE': '#4a90d9',
|
||||||
|
ISO: '#7b68ee',
|
||||||
|
'国标委': '#00b89c',
|
||||||
|
'EUR-Lex': '#f5a623',
|
||||||
|
IATF: '#9b59b6',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EventFeedProps {
|
||||||
|
events: RegulationEvent[];
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
filterSource: string;
|
||||||
|
filterImpact: string;
|
||||||
|
onFilterSource: (v: string) => void;
|
||||||
|
onFilterImpact: (v: string) => void;
|
||||||
|
stats: { total: number; high_impact: number; medium_impact: number; low_impact: number; recent_90d: number } | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EventFeed: React.FC<EventFeedProps> = ({
|
||||||
|
events, selectedId, onSelect,
|
||||||
|
filterSource, filterImpact, onFilterSource, onFilterImpact,
|
||||||
|
stats, loading,
|
||||||
|
}) => {
|
||||||
|
const { theme, isDark } = useTheme();
|
||||||
|
|
||||||
|
const sources: EventSource[] = ['MIIT', 'UN-ECE', 'ISO', '国标委', 'EUR-Lex', 'IATF'];
|
||||||
|
const impacts: ImpactLevel[] = ['high', 'medium', 'low'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 16 }}>
|
||||||
|
|
||||||
|
{/* KPI mini-cards */}
|
||||||
|
{stats && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
|
||||||
|
{[
|
||||||
|
{ label: '总计', value: stats.total, color: theme.text },
|
||||||
|
{ label: '高影响', value: stats.high_impact, color: '#d64545' },
|
||||||
|
{ label: '中影响', value: stats.medium_impact, color: '#ff8800' },
|
||||||
|
{ label: '近90天', value: stats.recent_90d, color: theme.accent },
|
||||||
|
].map(({ label, value, color }) => (
|
||||||
|
<div key={label} style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
background: theme.bgCard,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: !isDark ? '0 2px 6px rgba(226,0,116,0.05)' : 'none',
|
||||||
|
}}>
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 2, background: color }} />
|
||||||
|
<div className="mono" style={{ fontSize: 10, color: theme.text3, letterSpacing: '0.5px' }}>{label}</div>
|
||||||
|
<div className="mono" style={{ fontSize: 22, fontWeight: 700, color }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter row */}
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => onFilterSource('')}
|
||||||
|
style={{ padding: '4px 10px', borderRadius: 20, border: `1px solid ${filterSource === '' ? theme.accent : theme.border}`, background: filterSource === '' ? theme.accent + '20' : 'transparent', color: filterSource === '' ? theme.accent : theme.text3, fontSize: 11, cursor: 'pointer' }}
|
||||||
|
>全部来源</button>
|
||||||
|
{sources.map(s => (
|
||||||
|
<button key={s} onClick={() => onFilterSource(filterSource === s ? '' : s)}
|
||||||
|
style={{ padding: '4px 10px', borderRadius: 20, border: `1px solid ${filterSource === s ? (SOURCE_COLORS[s] || theme.accent) : theme.border}`, background: filterSource === s ? (SOURCE_COLORS[s] || theme.accent) + '20' : 'transparent', color: filterSource === s ? (SOURCE_COLORS[s] || theme.accent) : theme.text3, fontSize: 11, cursor: 'pointer' }}>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button onClick={() => onFilterImpact('')}
|
||||||
|
style={{ padding: '4px 10px', borderRadius: 20, border: `1px solid ${filterImpact === '' ? theme.accent : theme.border}`, background: filterImpact === '' ? theme.accent + '20' : 'transparent', color: filterImpact === '' ? theme.accent : theme.text3, fontSize: 11, cursor: 'pointer' }}>
|
||||||
|
全部等级
|
||||||
|
</button>
|
||||||
|
{impacts.map(lvl => (
|
||||||
|
<button key={lvl} onClick={() => onFilterImpact(filterImpact === lvl ? '' : lvl)}
|
||||||
|
style={{ padding: '4px 10px', borderRadius: 20, border: `1px solid ${filterImpact === lvl ? IMPACT_CONFIG[lvl].color : theme.border}`, background: filterImpact === lvl ? IMPACT_CONFIG[lvl].color + '22' : 'transparent', color: filterImpact === lvl ? IMPACT_CONFIG[lvl].color : theme.text3, fontSize: 11, cursor: 'pointer' }}>
|
||||||
|
{IMPACT_CONFIG[lvl].dot} {IMPACT_CONFIG[lvl].label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event list */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{loading && (
|
||||||
|
<div className="mono" style={{ fontSize: 12, color: theme.text3, padding: '16px 0' }}>加载中...</div>
|
||||||
|
)}
|
||||||
|
{!loading && events.length === 0 && (
|
||||||
|
<div style={{ fontSize: 13, color: theme.text3, padding: '32px 0', textAlign: 'center' }}>暂无法规动态</div>
|
||||||
|
)}
|
||||||
|
{events.map(evt => {
|
||||||
|
const cfg = IMPACT_CONFIG[evt.impact_level];
|
||||||
|
const isSelected = evt.id === selectedId;
|
||||||
|
const srcColor = SOURCE_COLORS[evt.source] || theme.accent;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={evt.id}
|
||||||
|
onClick={() => onSelect(evt.id)}
|
||||||
|
style={{
|
||||||
|
padding: '14px 16px',
|
||||||
|
background: isSelected ? (isDark ? '#1e1e35' : '#fdf0f7') : theme.bgCard,
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${isSelected ? theme.accent : theme.border}`,
|
||||||
|
borderLeft: `4px solid ${cfg.color}`,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
boxShadow: isSelected ? `0 0 0 1px ${theme.accent}40` : (!isDark ? '0 1px 4px rgba(0,0,0,0.04)' : 'none'),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Source + Status row */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 700, color: srcColor, background: srcColor + '18', borderRadius: 4, padding: '2px 7px' }}>{evt.source}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 10, color: theme.text3 }}>{evt.standard_code}</span>
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: 10, color: evt.status === 'enacted' ? theme.green : '#ff8800', background: evt.status === 'enacted' ? theme.green + '18' : '#ff880018', borderRadius: 4, padding: '2px 6px', fontWeight: 600 }}>
|
||||||
|
{STATUS_LABEL[evt.status] ?? evt.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: theme.text, lineHeight: 1.4, marginBottom: 6, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||||
|
{evt.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date + impact */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span className="mono" style={{ fontSize: 11, color: theme.text3 }}>
|
||||||
|
{evt.published_at}{evt.effective_at ? ` → ${evt.effective_at}` : ''}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 10, color: cfg.color, fontWeight: 700 }}>{cfg.dot} {cfg.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
144
frontend/src/pages/Perception/PerceptionPage.tsx
Normal file
144
frontend/src/pages/Perception/PerceptionPage.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTheme } from '../../contexts';
|
||||||
|
import { Content } from '../../components/layout/Content';
|
||||||
|
import { TPattern } from '../../components/common/TPattern';
|
||||||
|
import {
|
||||||
|
listEvents,
|
||||||
|
getPerceptionStats,
|
||||||
|
analyzeEvent,
|
||||||
|
type RegulationEvent,
|
||||||
|
type PerceptionStats,
|
||||||
|
type AffectedDoc,
|
||||||
|
} from '../../api/perception';
|
||||||
|
import { EventFeed } from './EventFeed';
|
||||||
|
import { AnalysisPanel } from './AnalysisPanel';
|
||||||
|
|
||||||
|
export const PerceptionPage: React.FC = () => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
// Feed state
|
||||||
|
const [events, setEvents] = useState<RegulationEvent[]>([]);
|
||||||
|
const [stats, setStats] = useState<PerceptionStats | null>(null);
|
||||||
|
const [feedLoading, setFeedLoading] = useState(true);
|
||||||
|
const [filterSource, setFilterSource] = useState('');
|
||||||
|
const [filterImpact, setFilterImpact] = useState('');
|
||||||
|
|
||||||
|
// Selected event
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const selectedEvent = events.find(e => e.id === selectedId) ?? null;
|
||||||
|
|
||||||
|
// Analysis state
|
||||||
|
const [analyzing, setAnalyzing] = useState(false);
|
||||||
|
const [analysisText, setAnalysisText] = useState('');
|
||||||
|
const [affectedDocs, setAffectedDocs] = useState<AffectedDoc[]>([]);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// Load events + stats
|
||||||
|
const loadFeed = useCallback(async () => {
|
||||||
|
setFeedLoading(true);
|
||||||
|
try {
|
||||||
|
const [evtRes, statsRes] = await Promise.all([
|
||||||
|
listEvents({
|
||||||
|
source: filterSource || undefined,
|
||||||
|
impact_level: filterImpact || undefined,
|
||||||
|
}),
|
||||||
|
getPerceptionStats(),
|
||||||
|
]);
|
||||||
|
setEvents(evtRes.events);
|
||||||
|
setStats(statsRes);
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setFeedLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterSource, filterImpact]);
|
||||||
|
|
||||||
|
useEffect(() => { void loadFeed(); }, [loadFeed]);
|
||||||
|
|
||||||
|
// When selecting a new event, clear previous analysis
|
||||||
|
const handleSelectEvent = (id: string) => {
|
||||||
|
if (id === selectedId) return;
|
||||||
|
abortRef.current?.abort();
|
||||||
|
setSelectedId(id);
|
||||||
|
setAnalysisText('');
|
||||||
|
setAffectedDocs([]);
|
||||||
|
setAnalyzing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnalyze = useCallback(() => {
|
||||||
|
if (!selectedId || analyzing) return;
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
setAnalysisText('');
|
||||||
|
setAffectedDocs([]);
|
||||||
|
setAnalyzing(true);
|
||||||
|
|
||||||
|
void analyzeEvent(
|
||||||
|
selectedId,
|
||||||
|
(msg) => {
|
||||||
|
if (msg.type === 'sources' && msg.docs) {
|
||||||
|
setAffectedDocs(msg.docs);
|
||||||
|
} else if (msg.type === 'content' && msg.text) {
|
||||||
|
setAnalysisText(prev => prev + msg.text);
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
setAnalysisText(prev => prev + `\n\n⚠ 分析出错:${msg.text ?? '未知错误'}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => setAnalyzing(false),
|
||||||
|
ctrl.signal,
|
||||||
|
);
|
||||||
|
}, [selectedId, analyzing]);
|
||||||
|
|
||||||
|
const handleAbort = () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
setAnalyzing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Content wide>
|
||||||
|
<style>{`
|
||||||
|
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
|
||||||
|
`}</style>
|
||||||
|
<TPattern />
|
||||||
|
|
||||||
|
{/* Page header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 16, marginBottom: 24 }}>
|
||||||
|
<h1 style={{ fontSize: 20, fontWeight: 700, color: theme.text, margin: 0 }}>智能感知</h1>
|
||||||
|
<span style={{ fontSize: 13, color: theme.text3 }}>法规动态实时追踪 · 知识库影响分析</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Split layout */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '400px 1fr',
|
||||||
|
gap: 24,
|
||||||
|
height: 'calc(100vh - 220px)',
|
||||||
|
minHeight: 560,
|
||||||
|
}}>
|
||||||
|
{/* Left: Event feed */}
|
||||||
|
<EventFeed
|
||||||
|
events={events}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={handleSelectEvent}
|
||||||
|
filterSource={filterSource}
|
||||||
|
filterImpact={filterImpact}
|
||||||
|
onFilterSource={setFilterSource}
|
||||||
|
onFilterImpact={setFilterImpact}
|
||||||
|
stats={stats}
|
||||||
|
loading={feedLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right: Analysis panel */}
|
||||||
|
<AnalysisPanel
|
||||||
|
event={selectedEvent}
|
||||||
|
analyzing={analyzing}
|
||||||
|
analysisText={analysisText}
|
||||||
|
affectedDocs={affectedDocs}
|
||||||
|
onAnalyze={handleAnalyze}
|
||||||
|
onAbort={handleAbort}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Content>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
frontend/src/pages/Perception/index.ts
Normal file
1
frontend/src/pages/Perception/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { PerceptionPage } from './PerceptionPage';
|
||||||
Reference in New Issue
Block a user