From 899e3bce4392212c0aad2dc57f9f7e3375ae580f Mon Sep 17 00:00:00 2001 From: linkong Date: Thu, 11 Jun 2026 16:47:24 +0800 Subject: [PATCH] release: bump version to 0.71.0 --- .gitignore | 5 +- TODO.md | 1 + VERSION | 2 +- backend/app/api/v1/earth.py | 192 ++++- backend/app/core/enums.py | 1 + backend/app/services/earth_news.py | 75 ++ backend/app/services/earth_news_manual.py | 693 +++++++++++++++++ backend/app/services/earth_news_store.py | 67 +- backend/pytest.ini | 1 + backend/tests/test_earth_news.py | 57 +- backend/tests/test_earth_news_manual.py | 252 ++++++ backend/tests/test_enum_contracts.py | 2 +- backend/tests/test_motion_agent.py | 117 +++ docs/CHANGELOG.md | 16 + docs/plans/README.md | 1 + ...rth-motion-capture-gesture-control-plan.md | 2 + ...arth-motion-gesture-interaction-v2-plan.md | 2 + .../motion-agent-v2-control-protocol-plan.md | 175 +++++ docs/technical/en/earth-frontend-context.md | 4 +- docs/technical/en/earth-news-sources.md | 53 ++ docs/technical/en/manual.md | 5 +- docs/technical/en/ops-planet-sh-startup.md | 57 +- docs/technical/en/platform-data-flows.md | 12 +- docs/technical/en/quickstart.md | 2 +- docs/technical/zh/earth-frontend-context.md | 4 +- docs/technical/zh/earth-news-sources.md | 53 ++ docs/technical/zh/manual.md | 5 +- docs/technical/zh/ops-planet-sh-startup.md | 57 +- docs/technical/zh/platform-data-flows.md | 12 +- docs/technical/zh/quickstart.md | 2 +- docs/version-history.md | 3 +- downloads/usbipd-win/usbipd-win-5.3.0.msi | Bin 0 -> 4501504 bytes frontend/package.json | 2 +- frontend/public/earth/index.html | 44 ++ frontend/public/earth/js/controls.js | 104 ++- .../public/earth/js/earth-interactables.js | 18 + frontend/public/earth/js/info-card.js | 16 + frontend/public/earth/js/main.js | 249 +++++- .../public/earth/js/motion-agent-provider.js | 33 + frontend/public/earth/js/motion-control.js | 31 + .../public/earth/js/motion-control.test.js | 38 + frontend/public/earth/js/motion-protocol.js | 4 + frontend/public/earth/js/news-locale.js | 2 + frontend/public/earth/js/news.js | 32 +- .../src/admin/pages/PlainResourcePages.tsx | 430 ++++++++++- motion_agent/cameras.py | 4 +- motion_agent/cli.py | 6 +- motion_agent/config.py | 20 +- motion_agent/events.py | 51 +- motion_agent/recognizer.py | 393 +++++++++- motion_agent/server.py | 454 +++++++++-- motion_agent/state.py | 2 + motion_agent/worker.py | 293 +++++++ planet.sh | 718 +++++++++++++++++- pyproject.toml | 2 +- uv.lock | 2 +- 56 files changed, 4618 insertions(+), 260 deletions(-) create mode 100644 backend/app/services/earth_news_manual.py create mode 100644 backend/tests/test_earth_news_manual.py create mode 100644 docs/plans/motion-agent-v2-control-protocol-plan.md create mode 100644 downloads/usbipd-win/usbipd-win-5.3.0.msi create mode 100644 motion_agent/worker.py diff --git a/.gitignore b/.gitignore index bae5d6fe..bf4cc62d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,10 @@ __pycache__/ build/ develop-eggs/ dist/ -downloads/ +downloads/* +!downloads/usbipd-win/ +downloads/usbipd-win/* +!downloads/usbipd-win/usbipd-win-5.3.0.msi eggs/ .eggs/ /lib/ diff --git a/TODO.md b/TODO.md index 3d37cb1e..191996d9 100644 --- a/TODO.md +++ b/TODO.md @@ -4,6 +4,7 @@ This file is the active backlog only. Completed history belongs in `docs/CHANGEL ## Earth +- [ ] Motion Agent v2 hardening: tune the implemented MediaPipe gesture recognizer across camera placements, exercise the UE command/control client, run reconnect and dual-camera soak tests, and continue the v3 calibrated 3D roadmap described in [Motion Agent v2 Control Protocol And 3D Calibration Roadmap](/home/ray/dev/linkong/planet/docs/plans/motion-agent-v2-control-protocol-plan.md). - [ ] Earth AI command entry: merge natural-language and speech-triggered LLM commands into the existing Earth search panel as described in [Agent Runtime, Earth LLM Command, And Speech Entry Plan](/home/ray/dev/linkong/planet/docs/plans/agents-earth-command-runtime-plan.md). - [ ] Earth action executor: implement safe visualization actions for layer toggles, batch highlights, filters, focus, result panels, and clear-highlight behavior. - [ ] Earth entity matching: support stable entity ids and batch matching for Beidou satellites, mainland China compute centers, BGP, news, vessels, and cables. diff --git a/VERSION b/VERSION index 534b316a..e6c73154 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.70.0 +0.71.0 diff --git a/backend/app/api/v1/earth.py b/backend/app/api/v1/earth.py index 89e7d7e2..16474964 100644 --- a/backend/app/api/v1/earth.py +++ b/backend/app/api/v1/earth.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any from uuid import uuid4 -from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel, Field from sqlalchemy import delete, func, select, text @@ -27,6 +27,20 @@ from app.services.earth_news import ( save_earth_news_sources_payload, test_news_source_config, ) +from app.services.earth_news_manual import ( + broadcast_manual_news_changed, + create_manual_news_group, + delete_manual_news_item, + get_news_record_or_404, + import_manual_news_items, + list_news_groups, + list_news_records, + parse_manual_news_import_upload, + rename_manual_news_group, + reprocess_manual_news_item, + serialize_news_record, + upsert_manual_news_item, +) from app.services.earth_boundaries import ( EarthBoundaryBuildError, get_boundary_build_status, @@ -119,6 +133,26 @@ class EarthNewsSourceTestPayload(BaseModel): source: dict[str, Any] = Field(default_factory=dict) +class EarthNewsManualItemPayload(BaseModel): + title: str = Field(default="", max_length=500) + summary: str = Field(default="", max_length=1200) + content: str = Field(default="", max_length=12000) + url: str = Field(default="", max_length=2000) + source: str = Field(default="", max_length=255) + region: str = Field(default="global", max_length=80) + published_at: str | None = None + category: str = Field(default="other", max_length=80) + tags: list[str] = Field(default_factory=list) + location: dict[str, Any] | None = None + homepage_url: str = Field(default="", max_length=2000) + content_language: str = Field(default="", max_length=32) + group_id: str | None = Field(default=None, max_length=120) + + +class EarthNewsManualGroupPayload(BaseModel): + name: str = Field(default="", max_length=120) + + def _normalize_earth_brand_payload(payload: dict[str, Any] | None) -> dict[str, str]: merged = DEFAULT_EARTH_BRAND.copy() if payload: @@ -375,6 +409,162 @@ async def test_earth_news_source( return await test_news_source_config(payload.source, db=db) +@router.get("/news-groups") +async def list_earth_news_groups_admin( + _current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return await list_news_groups(db) + + +@router.post("/news-groups") +async def create_earth_news_group_admin( + payload: EarthNewsManualGroupPayload, + _current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + group = await create_manual_news_group(db, payload.name) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + await db.commit() + return {"status": "ok", "group": group} + + +@router.put("/news-groups/{group_id:path}") +async def rename_earth_news_group_admin( + group_id: str, + payload: EarthNewsManualGroupPayload, + _current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + group = await rename_manual_news_group(db, group_id, payload.name) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + await db.commit() + await broadcast_manual_news_changed() + return {"status": "ok", "group": group} + + +@router.get("/news-items") +async def list_earth_news_items_admin( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), + source_type: str | None = Query(None), + region: str | None = Query(None), + category: str | None = Query(None), + status_filter: str | None = Query(None, alias="status"), + group_id: str | None = Query(None), + _current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return await list_news_records( + db, + page=page, + page_size=page_size, + source_type=source_type, + region=region, + category=category, + status_filter=status_filter, + group_id=group_id, + ) + + +@router.post("/news-items") +async def create_earth_news_item_admin( + payload: EarthNewsManualItemPayload, + _current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + result = await upsert_manual_news_item(db, payload.model_dump()) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + await db.commit() + await broadcast_manual_news_changed() + return {"status": "ok", "created": result.created, "queued": result.queued, "item": serialize_news_record(result.item)} + + +@router.post("/news-items/import") +async def import_earth_news_items_admin( + file: UploadFile = File(...), + group_id: str | None = Form(default=None), + _current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + payload = await parse_manual_news_import_upload(await file.read()) + result = await import_manual_news_items(db, payload, group_id=group_id) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + await db.commit() + await broadcast_manual_news_changed() + return {"status": "ok", **result} + + +@router.put("/news-items/{item_id:path}") +async def update_earth_news_item_admin( + item_id: str, + payload: EarthNewsManualItemPayload, + _current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + existing = await get_news_record_or_404(db, item_id) + if existing is None: + raise HTTPException(status_code=404, detail="News item not found.") + try: + result = await upsert_manual_news_item( + db, + payload.model_dump(), + item_id_override=item_id, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + await db.commit() + await broadcast_manual_news_changed() + return {"status": "ok", "created": result.created, "queued": result.queued, "item": serialize_news_record(result.item)} + + +@router.delete("/news-items/{item_id:path}") +async def delete_earth_news_item_admin( + item_id: str, + _current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + deleted = await delete_manual_news_item(db, item_id) + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + if not deleted: + raise HTTPException(status_code=404, detail="News item not found.") + await db.commit() + await broadcast_manual_news_changed() + return {"status": "deleted", "id": item_id} + + +@router.post("/news-items/{item_id:path}/reprocess") +async def reprocess_earth_news_item_admin( + item_id: str, + _current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + existing = await get_news_record_or_404(db, item_id) + if existing is None: + raise HTTPException(status_code=404, detail="News item not found.") + try: + queued = await reprocess_manual_news_item(db, item_id) + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + await db.commit() + await broadcast_manual_news_changed() + return {"status": "queued" if queued else "not_queued", "queued": queued, "id": item_id} + + @router.get("/oobe-status") async def get_earth_oobe_status( current_user: User | None = Depends(_get_optional_current_user), diff --git a/backend/app/core/enums.py b/backend/app/core/enums.py index 5e849600..4b04ad77 100644 --- a/backend/app/core/enums.py +++ b/backend/app/core/enums.py @@ -66,6 +66,7 @@ class NewsSourceType(StrEnum): ATOM = "atom" AGGREGATED = "aggregated" REFERENCE = "reference" + MANUAL = "manual" class NewsEnrichmentStatus(StrEnum): diff --git a/backend/app/services/earth_news.py b/backend/app/services/earth_news.py index ed0977c4..d69efb79 100644 --- a/backend/app/services/earth_news.py +++ b/backend/app/services/earth_news.py @@ -2282,6 +2282,50 @@ def _filter_news_items_by_source_ids( ] +def _news_item_source_id(item: ParsedNewsItem) -> str: + return item.id.split(":", 1)[0] if ":" in item.id else "" + + +def _is_news_item_display_ready(item: ParsedNewsItem, *, locale: str) -> bool: + return bool( + _get_locale_text(item, "title", locale=locale) + and _get_locale_text(item, "summary", locale=locale) + ) + + +def _diversify_news_items_for_locale( + items: list[ParsedNewsItem], + *, + active_region: str, + limit: int, + locale: str, +) -> list[ParsedNewsItem]: + ranked = sorted( + _rank_and_trim_items(items, active_region=active_region, limit=max(len(items), limit)), + key=lambda item: (not _is_news_item_display_ready(item, locale=locale),), + ) + buckets: dict[str, list[ParsedNewsItem]] = {} + order: list[str] = [] + for item in ranked: + key = _news_item_source_id(item) or item.source or item.feed_name or item.id + if key not in buckets: + buckets[key] = [] + order.append(key) + buckets[key].append(item) + + diversified: list[ParsedNewsItem] = [] + while len(diversified) < limit and order: + next_order: list[str] = [] + for source_id in order: + bucket = buckets.get(source_id) or [] + if bucket and len(diversified) < limit: + diversified.append(bucket.pop(0)) + if bucket: + next_order.append(source_id) + order = next_order + return diversified + + async def _call_store_list_items(list_fn, db: AsyncSession, **kwargs): try: return await list_fn(db, **kwargs) @@ -2717,6 +2761,37 @@ async def get_earth_news_payload( categories=categories, source_ids=source_ids, ) + if not source_ids: + ready_sources = { + _news_item_source_id(item) + for item in items + if _is_news_item_display_ready(item, locale=locale) + } + missing_ready_sources = [ + source.id + for source in sources + if source.id and source.id not in ready_sources + ] + if missing_ready_sources: + extra_items: list[ParsedNewsItem] = [] + for missing_source_id in missing_ready_sources: + extra_items.extend( + await _call_store_list_items( + list_earth_news_items, + db, + active_region=active_region, + limit=3, + categories=categories, + source_ids={missing_source_id}, + ) + ) + if extra_items: + items = _diversify_news_items_for_locale( + [*items, *extra_items], + active_region=active_region, + limit=limit, + locale=locale, + ) if hasattr(db, "execute"): cruise_items = await _call_store_list_items( list_earth_news_cruise_items, diff --git a/backend/app/services/earth_news_manual.py b/backend/app/services/earth_news_manual.py new file mode 100644 index 00000000..b8da4941 --- /dev/null +++ b/backend/app/services/earth_news_manual.py @@ -0,0 +1,693 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +import hashlib +import html +import json +import re +from typing import Any + +from bs4 import BeautifulSoup +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.enums import NewsEnrichmentStatus, NewsSourceType, NewsTaggingSource +from app.core.websocket.broadcaster import broadcaster +from app.models.earth_news import EarthNewsItem +from app.models.system_setting import SystemSetting +from app.services.earth_news import ( + ALLOWED_NEWS_CATEGORY_KEYS, + DEFAULT_NEWS_LOCALE, + REGION_ANCHORS, + NewsFeedEndpoint, + NewsFeedSource, + NewsTargetLocation, + ParsedNewsItem, + apply_news_classification, + build_anchor_location_patch, + build_target_location_job_payload, + build_target_location_patch, + _serialize_item, +) +from app.services.earth_news_queue import enqueue_target_location_job +from app.services.earth_news_store import record_to_parsed_news_item + + +MANUAL_NEWS_SOURCE_ID = "manual" +MANUAL_NEWS_SOURCE_LABEL = "手动添加" +MANUAL_NEWS_MAX_IMPORT_ITEMS = 500 +MANUAL_NEWS_MAX_TITLE_LENGTH = 500 +MANUAL_NEWS_MAX_SUMMARY_LENGTH = 1200 +MANUAL_NEWS_MAX_CONTENT_LENGTH = 12000 +EARTH_NEWS_MANUAL_GROUPS_CATEGORY = "earth_news_manual_groups" +DEFAULT_MANUAL_NEWS_GROUP_ID = "manual-default" +DEFAULT_MANUAL_NEWS_GROUP_NAME = "新建新闻组" + + +@dataclass(frozen=True) +class ManualNewsWriteResult: + item: EarthNewsItem + created: bool + queued: bool + + +@dataclass(frozen=True) +class ManualNewsGroup: + id: str + name: str + sort_order: int = 0 + + +def _clean_text(value: object, *, max_length: int) -> str: + raw = "" if value is None else str(value) + text = BeautifulSoup(html.unescape(raw), "html.parser").get_text(" ", strip=True) + text = re.sub(r"\s+", " ", text).strip() + if len(text) > max_length: + return text[: max_length - 1].rstrip() + "…" + return text + + +def _parse_datetime(value: object) -> datetime | None: + if value is None or str(value).strip() == "": + return None + if isinstance(value, datetime): + parsed = value + else: + try: + parsed = datetime.fromisoformat(str(value).strip().replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError("published_at 必须是 ISO8601 时间。") from exc + if parsed.tzinfo is None: + return parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + +def _detect_language(*parts: str) -> str: + text = " ".join(part for part in parts if part) + cjk_count = len(re.findall(r"[\u4e00-\u9fff]", text)) + latin_count = len(re.findall(r"[A-Za-z]", text)) + return "zh-CN" if cjk_count >= max(4, latin_count // 3) else "en-US" + + +def _manual_item_id(*, title: str, published_at: datetime | None, url: str, source: str) -> str: + published = published_at.isoformat() if published_at else "" + basis = "\n".join([title.strip().lower(), published, url.strip().lower(), source.strip().lower()]) + return f"manual:{hashlib.sha1(basis.encode('utf-8')).hexdigest()[:16]}" + + +def _manual_group_id(name: str) -> str: + basis = f"{name.strip().lower()}\n{datetime.now(UTC).isoformat()}" + return f"manual-group:{hashlib.sha1(basis.encode('utf-8')).hexdigest()[:10]}" + + +def _news_meta(record: EarthNewsItem) -> dict[str, Any]: + location_meta = record.location_meta if isinstance(record.location_meta, dict) else {} + news_meta = location_meta.get("news_meta") + return dict(news_meta) if isinstance(news_meta, dict) else {} + + +def _record_source_type(record: EarthNewsItem) -> str: + return str(_news_meta(record).get("feed_type") or _news_meta(record).get("source_type") or "rss") + + +def _record_manual_group_id(record: EarthNewsItem) -> str: + return str(_news_meta(record).get("manual_group_id") or DEFAULT_MANUAL_NEWS_GROUP_ID) + + +def _rss_group_id(record: EarthNewsItem) -> str: + basis = "\n".join( + [ + _record_source_type(record), + str(record.feed_name or ""), + str(record.source or ""), + ] + ) + return f"rss:{hashlib.sha1(basis.encode('utf-8')).hexdigest()[:12]}" + + +def _default_manual_group() -> dict[str, Any]: + return { + "id": DEFAULT_MANUAL_NEWS_GROUP_ID, + "name": DEFAULT_MANUAL_NEWS_GROUP_NAME, + "sort_order": 0, + } + + +def _normalize_manual_groups_payload(payload: Any) -> list[dict[str, Any]]: + raw_groups = payload.get("groups") if isinstance(payload, dict) else None + normalized: list[dict[str, Any]] = [] + seen: set[str] = set() + for index, item in enumerate(raw_groups if isinstance(raw_groups, list) else []): + if not isinstance(item, dict): + continue + group_id = str(item.get("id") or "").strip() + name = _clean_text(item.get("name"), max_length=120) + if not group_id or not name or group_id in seen: + continue + normalized.append( + { + "id": group_id, + "name": name, + "sort_order": int(item.get("sort_order") or index), + } + ) + seen.add(group_id) + if DEFAULT_MANUAL_NEWS_GROUP_ID not in seen: + normalized.insert(0, _default_manual_group()) + return sorted(normalized, key=lambda item: (int(item.get("sort_order") or 0), str(item.get("name") or ""))) + + +async def _get_manual_groups_record(db: AsyncSession) -> SystemSetting | None: + result = await db.execute( + select(SystemSetting).where(SystemSetting.category == EARTH_NEWS_MANUAL_GROUPS_CATEGORY) + ) + return result.scalar_one_or_none() + + +async def get_manual_news_groups(db: AsyncSession) -> list[dict[str, Any]]: + record = await _get_manual_groups_record(db) + return _normalize_manual_groups_payload(record.payload if record else None) + + +async def _save_manual_news_groups(db: AsyncSession, groups: list[dict[str, Any]]) -> list[dict[str, Any]]: + normalized = _normalize_manual_groups_payload({"groups": groups}) + record = await _get_manual_groups_record(db) + payload = {"groups": normalized} + if record is None: + db.add(SystemSetting(category=EARTH_NEWS_MANUAL_GROUPS_CATEGORY, payload=payload)) + else: + record.payload = payload + await db.flush() + return normalized + + +async def resolve_manual_news_group(db: AsyncSession, group_id: str | None) -> ManualNewsGroup: + normalized_id = str(group_id or DEFAULT_MANUAL_NEWS_GROUP_ID).strip() or DEFAULT_MANUAL_NEWS_GROUP_ID + groups = await get_manual_news_groups(db) + match = next((item for item in groups if item.get("id") == normalized_id), None) + if match is None and normalized_id != DEFAULT_MANUAL_NEWS_GROUP_ID: + raise ValueError(f"手动新闻组不存在:{normalized_id}") + match = match or _default_manual_group() + return ManualNewsGroup( + id=str(match["id"]), + name=str(match["name"]), + sort_order=int(match.get("sort_order") or 0), + ) + + +async def create_manual_news_group(db: AsyncSession, name: str) -> dict[str, Any]: + group_name = _clean_text(name, max_length=120) + if not group_name: + raise ValueError("新闻组名称不能为空。") + groups = await get_manual_news_groups(db) + group = {"id": _manual_group_id(group_name), "name": group_name, "sort_order": len(groups)} + groups.append(group) + await _save_manual_news_groups(db, groups) + return group + + +async def rename_manual_news_group(db: AsyncSession, group_id: str, name: str) -> dict[str, Any]: + group_name = _clean_text(name, max_length=120) + if not group_name: + raise ValueError("新闻组名称不能为空。") + groups = await get_manual_news_groups(db) + match = next((item for item in groups if item.get("id") == group_id), None) + if match is None: + raise ValueError(f"手动新闻组不存在:{group_id}") + match["name"] = group_name + await _save_manual_news_groups(db, groups) + + result = await db.execute( + select(EarthNewsItem).where( + EarthNewsItem.location_meta.op("->")("news_meta").op("->>")("feed_type") == NewsSourceType.MANUAL.value + ) + ) + for record in result.scalars().all(): + if _record_manual_group_id(record) != group_id: + continue + location_meta = dict(record.location_meta or {}) + news_meta = dict(location_meta.get("news_meta") or {}) + news_meta["manual_group_name"] = group_name + location_meta["news_meta"] = news_meta + record.location_meta = location_meta + await db.flush() + return match + + +def _normalize_region(value: object) -> str: + region = str(value or "global").strip().lower() or "global" + if region not in REGION_ANCHORS: + raise ValueError(f"region 不支持:{region}") + return region + + +def _normalize_tags(value: object) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + parts = re.split(r"[,,\n]", value) + elif isinstance(value, list): + parts = [str(item) for item in value] + else: + raise ValueError("tags 必须是字符串数组或逗号分隔字符串。") + return [item.strip() for item in parts if item.strip()][:20] + + +def _normalize_location(value: object) -> NewsTargetLocation | None: + if value in (None, ""): + return None + if not isinstance(value, dict): + raise ValueError("location 必须是对象。") + lat = value.get("latitude") + lon = value.get("longitude") + if lat in (None, "") and lon in (None, ""): + return None + try: + latitude = float(lat) + longitude = float(lon) + except (TypeError, ValueError) as exc: + raise ValueError("location.latitude / longitude 必须是数字。") from exc + if not -90 <= latitude <= 90 or not -180 <= longitude <= 180: + raise ValueError("location 经纬度超出范围。") + label = _clean_text(value.get("label"), max_length=255) + if not label: + label = f"{latitude:.4f}, {longitude:.4f}" + return NewsTargetLocation( + latitude=latitude, + longitude=longitude, + label=label, + source="manual_location", + confidence=1.0, + country=_clean_text(value.get("country"), max_length=100) or None, + city=_clean_text(value.get("city"), max_length=100) or None, + ) + + +def _manual_source(source_name: str, *, region: str) -> NewsFeedSource: + return NewsFeedSource( + id=MANUAL_NEWS_SOURCE_ID, + name=source_name or MANUAL_NEWS_SOURCE_LABEL, + region=region, + feed_url="", + homepage_url="", + source_type=NewsSourceType.MANUAL.value, + default_category="other", + source_tags=("manual",), + ) + + +def _manual_feed(category: str) -> NewsFeedEndpoint: + return NewsFeedEndpoint( + id=MANUAL_NEWS_SOURCE_ID, + name=MANUAL_NEWS_SOURCE_LABEL, + url="", + type=NewsSourceType.MANUAL.value, + default_category=category or "other", + tags=("manual",), + priority=1, + ) + + +def parsed_manual_news_item( + payload: dict[str, Any], + *, + item_id_override: str | None = None, +) -> tuple[ParsedNewsItem, NewsTargetLocation | None, str]: + title = _clean_text(payload.get("title"), max_length=MANUAL_NEWS_MAX_TITLE_LENGTH) + if not title: + raise ValueError("title 不能为空。") + content = _clean_text(payload.get("content"), max_length=MANUAL_NEWS_MAX_CONTENT_LENGTH) + summary = _clean_text(payload.get("summary"), max_length=MANUAL_NEWS_MAX_SUMMARY_LENGTH) + if not summary: + summary = _clean_text(content, max_length=240) if content else title + source = _clean_text(payload.get("source"), max_length=255) or MANUAL_NEWS_SOURCE_LABEL + region = _normalize_region(payload.get("region")) + published_at = _parse_datetime(payload.get("published_at")) or datetime.now(UTC) + url = str(payload.get("url") or "").strip() + category = str(payload.get("category") or "other").strip().lower() or "other" + if category not in ALLOWED_NEWS_CATEGORY_KEYS: + raise ValueError(f"category 不支持:{category}") + tags = _normalize_tags(payload.get("tags")) + target = _normalize_location(payload.get("location")) + language = str(payload.get("content_language") or "").strip() or _detect_language(title, summary, content) + localizations = { + language: { + "title": title, + "summary": summary, + } + } + item = ParsedNewsItem( + id=item_id_override + or _manual_item_id(title=title, published_at=published_at, url=url, source=source), + title=title, + summary=summary, + url=url, + source=source, + feed_name=MANUAL_NEWS_SOURCE_LABEL, + feed_region=region, + homepage_url=str(payload.get("homepage_url") or ""), + published_at=published_at, + content_language=language, + localizations=localizations, + enrichment_status=NewsEnrichmentStatus.PENDING.value, + source_tags=["manual"], + feed_id=MANUAL_NEWS_SOURCE_ID, + feed_type=NewsSourceType.MANUAL.value, + feed_default_category=category, + category=category, + item_tags=tags, + tagging_source=NewsTaggingSource.MANUAL.value if payload.get("category") else NewsTaggingSource.RULES.value, + tagging_confidence=0.9 if payload.get("category") else 0.0, + ) + source_config = _manual_source(source, region=region) + feed = _manual_feed(category) + apply_news_classification(item, source_config, feed=feed) + if payload.get("category"): + item.category = category + item.tagging_source = NewsTaggingSource.MANUAL.value + item.tagging_confidence = 0.9 + if tags: + item.item_tags = sorted(set([*item.item_tags, *tags])) + return item, target, content + + +def _manual_editable(record: EarthNewsItem) -> bool: + if record.id.startswith("manual:"): + return True + news_meta = (record.location_meta or {}).get("news_meta") if isinstance(record.location_meta, dict) else None + return isinstance(news_meta, dict) and news_meta.get("feed_type") == NewsSourceType.MANUAL.value + + +async def _broadcast_news_reload() -> None: + await broadcaster.broadcast_earth_update( + { + "action": "database_changed", + "source": "earth_news_items", + "layers": ["news"], + "refresh_strategy": "reload", + } + ) + + +async def upsert_manual_news_item( + db: AsyncSession, + payload: dict[str, Any], + *, + item_id_override: str | None = None, + group_id: str | None = None, +) -> ManualNewsWriteResult: + item, target, content = parsed_manual_news_item(payload, item_id_override=item_id_override) + group = await resolve_manual_news_group(db, group_id or payload.get("group_id")) + existing = await db.get(EarthNewsItem, item.id) + created = existing is None + patch = build_target_location_patch(item, target) if target else build_anchor_location_patch(item) + patch_meta = dict(patch.get("location_meta") or {}) + patch_news_meta = dict(patch_meta.get("news_meta") or {}) + patch_news_meta["feed_type"] = NewsSourceType.MANUAL.value + patch_news_meta["source_type"] = NewsSourceType.MANUAL.value + patch_news_meta["manual_group_id"] = group.id + patch_news_meta["manual_group_name"] = group.name + patch_meta["news_meta"] = patch_news_meta + patch["location_meta"] = patch_meta + now = datetime.now(UTC) + record = existing or EarthNewsItem( + id=item.id, + title=item.title, + summary=item.summary, + content_language=item.content_language, + localizations=dict(item.localizations or {}), + url=item.url, + source=item.source, + feed_name=item.feed_name, + region=item.feed_region, + homepage_url=item.homepage_url, + published_at=item.published_at, + latitude=patch["latitude"], + longitude=patch["longitude"], + location_label=patch["location_label"], + location_source=patch["location_source"], + verified=patch["verified"], + location_meta=patch["location_meta"], + first_seen_at=now, + last_seen_at=now, + resolved_at=now if patch["verified"] else None, + enrichment_status=item.enrichment_status, + ) + if existing is None: + db.add(record) + else: + if not _manual_editable(record): + raise PermissionError("RSS 新闻不允许通过手动新闻接口编辑。") + record.title = item.title + record.summary = item.summary + record.content_language = item.content_language + record.localizations = dict(item.localizations or {}) + record.url = item.url + record.source = item.source + record.feed_name = item.feed_name + record.region = item.feed_region + record.homepage_url = item.homepage_url + record.published_at = item.published_at + record.last_seen_at = now + if target is None and record.location_source == "manual_location": + merged_meta = dict(record.location_meta or {}) + patch_meta = patch.get("location_meta") if isinstance(patch, dict) else None + patch_news_meta = patch_meta.get("news_meta") if isinstance(patch_meta, dict) else None + if isinstance(patch_news_meta, dict): + merged_meta["news_meta"] = patch_news_meta + record.location_meta = merged_meta + else: + record.location_meta = patch["location_meta"] + if target: + record.latitude = patch["latitude"] + record.longitude = patch["longitude"] + record.location_label = patch["location_label"] + record.location_source = patch["location_source"] + record.verified = patch["verified"] + record.resolved_at = now + elif record.location_source != "manual_location": + record.latitude = patch["latitude"] + record.longitude = patch["longitude"] + record.location_label = patch["location_label"] + record.location_source = patch["location_source"] + record.verified = patch["verified"] + record.resolved_at = None + record.enrichment_status = NewsEnrichmentStatus.PENDING.value + record.enrichment_error = None + record.enriched_at = None + if content: + meta = dict(record.location_meta or {}) + meta["manual_content"] = content + record.location_meta = meta + await db.flush() + + queued = await enqueue_target_location_job(build_target_location_job_payload(item), force=True) + if queued: + record.enrichment_status = NewsEnrichmentStatus.QUEUED.value + await db.flush() + return ManualNewsWriteResult(item=record, created=created, queued=queued) + + +async def import_manual_news_items( + db: AsyncSession, + payload: list[Any], + *, + group_id: str | None = None, +) -> dict[str, Any]: + if len(payload) > MANUAL_NEWS_MAX_IMPORT_ITEMS: + raise ValueError(f"单次最多导入 {MANUAL_NEWS_MAX_IMPORT_ITEMS} 条。") + created = 0 + updated = 0 + queued = 0 + errors: list[dict[str, Any]] = [] + for index, raw_item in enumerate(payload): + if not isinstance(raw_item, dict): + errors.append({"index": index, "error": "条目必须是 JSON 对象。"}) + continue + try: + result = await upsert_manual_news_item(db, raw_item, group_id=group_id) + created += 1 if result.created else 0 + updated += 0 if result.created else 1 + queued += 1 if result.queued else 0 + except Exception as exc: + errors.append({"index": index, "error": str(exc)}) + if errors and created == 0 and updated == 0: + raise ValueError("导入失败,未写入任何新闻。") + return {"created": created, "updated": updated, "queued": queued, "failed": len(errors), "errors": errors} + + +async def parse_manual_news_import_upload(raw_bytes: bytes) -> list[Any]: + try: + payload = json.loads(raw_bytes.decode("utf-8-sig")) + except UnicodeDecodeError as exc: + raise ValueError("JSON 文件必须使用 UTF-8 编码。") from exc + except json.JSONDecodeError as exc: + raise ValueError(f"JSON 解析失败:第 {exc.lineno} 行第 {exc.colno} 列。") from exc + if not isinstance(payload, list): + raise ValueError("JSON 顶层必须是数组。") + return payload + + +def serialize_news_record(record: EarthNewsItem, *, locale: str = DEFAULT_NEWS_LOCALE) -> dict[str, Any]: + item = record_to_parsed_news_item(record) + payload = _serialize_item(item, active_region=item.feed_region, locale=locale) + news_meta = _news_meta(record) + payload["editable"] = _manual_editable(record) + payload["source_type"] = payload.get("feed_type") + payload["status"] = record.enrichment_status + payload["translated"] = bool((record.localizations or {}).get("zh-CN") and (record.localizations or {}).get("en-US")) + payload["manual_content"] = (record.location_meta or {}).get("manual_content") if isinstance(record.location_meta, dict) else None + payload["manual_group_id"] = news_meta.get("manual_group_id") + payload["manual_group_name"] = news_meta.get("manual_group_name") + return payload + + +def _record_matches_group(record: EarthNewsItem, group_id: str) -> bool: + source_type = _record_source_type(record) + if source_type == NewsSourceType.MANUAL.value: + return _record_manual_group_id(record) == group_id + return _rss_group_id(record) == group_id + + +async def list_news_records( + db: AsyncSession, + *, + page: int, + page_size: int, + source_type: str | None = None, + region: str | None = None, + category: str | None = None, + status_filter: str | None = None, + group_id: str | None = None, +) -> dict[str, Any]: + page = max(page, 1) + page_size = min(max(page_size, 1), 100) + query = select(EarthNewsItem) + count_query = select(func.count(EarthNewsItem.id)) + filters = [] + if region and region != "all": + filters.append(EarthNewsItem.region == region) + if status_filter and status_filter != "all": + filters.append(EarthNewsItem.enrichment_status == status_filter) + if source_type and source_type != "all": + filters.append(EarthNewsItem.location_meta.op("->")("news_meta").op("->>")("feed_type") == source_type) + if category and category != "all": + filters.append(EarthNewsItem.location_meta.op("->")("news_meta").op("->>")("category") == category) + for clause in filters: + query = query.where(clause) + count_query = count_query.where(clause) + ordered_query = query.order_by(EarthNewsItem.published_at.desc().nullslast(), EarthNewsItem.last_seen_at.desc()) + if group_id: + result = await db.execute(ordered_query) + all_records = [record for record in result.scalars().all() if _record_matches_group(record, group_id)] + total = len(all_records) + records = all_records[(page - 1) * page_size : page * page_size] + else: + total_result = await db.execute(count_query) + result = await db.execute( + ordered_query.offset((page - 1) * page_size).limit(page_size) + ) + records = list(result.scalars().all()) + total = int(total_result.scalar() or 0) + return { + "items": [serialize_news_record(record) for record in records], + "page": page, + "page_size": page_size, + "total": total, + } + + +async def list_news_groups(db: AsyncSession, *, locale: str = DEFAULT_NEWS_LOCALE) -> dict[str, Any]: + manual_groups = await get_manual_news_groups(db) + manual_by_id: dict[str, dict[str, Any]] = { + str(group["id"]): { + "id": str(group["id"]), + "name": str(group["name"]), + "group_type": "manual", + "source_type": NewsSourceType.MANUAL.value, + "editable": True, + "sort_order": int(group.get("sort_order") or 0), + "count": 0, + "items": [], + } + for group in manual_groups + } + rss_by_id: dict[str, dict[str, Any]] = {} + result = await db.execute( + select(EarthNewsItem).order_by(EarthNewsItem.published_at.desc().nullslast(), EarthNewsItem.last_seen_at.desc()) + ) + for record in result.scalars().all(): + serialized = serialize_news_record(record, locale=locale) + source_type = _record_source_type(record) + if source_type == NewsSourceType.MANUAL.value: + group_id = _record_manual_group_id(record) + group = manual_by_id.setdefault( + group_id, + { + "id": group_id, + "name": str(_news_meta(record).get("manual_group_name") or DEFAULT_MANUAL_NEWS_GROUP_NAME), + "group_type": "manual", + "source_type": NewsSourceType.MANUAL.value, + "editable": True, + "sort_order": len(manual_by_id), + "count": 0, + "items": [], + }, + ) + else: + group_id = _rss_group_id(record) + group = rss_by_id.setdefault( + group_id, + { + "id": group_id, + "name": record.feed_name or record.source or "RSS 新闻", + "group_type": "rss", + "source_type": source_type, + "editable": False, + "region": record.region, + "source": record.source, + "feed_name": record.feed_name, + "count": 0, + "items": [], + }, + ) + group["count"] = int(group.get("count") or 0) + 1 + group.setdefault("items", []).append(serialized) + manual_items = sorted(manual_by_id.values(), key=lambda item: (int(item.get("sort_order") or 0), str(item.get("name") or ""))) + rss_items = sorted(rss_by_id.values(), key=lambda item: str(item.get("name") or "")) + return {"groups": [*manual_items, *rss_items], "manual_groups": manual_items, "rss_groups": rss_items} + + +async def get_news_record_or_404(db: AsyncSession, item_id: str) -> EarthNewsItem | None: + return await db.get(EarthNewsItem, item_id) + + +async def delete_manual_news_item(db: AsyncSession, item_id: str) -> bool: + record = await db.get(EarthNewsItem, item_id) + if record is None: + return False + if not _manual_editable(record): + raise PermissionError("RSS 新闻不允许通过手动新闻接口删除。") + await db.execute(delete(EarthNewsItem).where(EarthNewsItem.id == item_id)) + await db.flush() + return True + + +async def reprocess_manual_news_item(db: AsyncSession, item_id: str) -> bool: + record = await db.get(EarthNewsItem, item_id) + if record is None: + return False + if not _manual_editable(record): + raise PermissionError("RSS 新闻不允许通过手动新闻接口重新处理。") + item = record_to_parsed_news_item(record) + queued = await enqueue_target_location_job(build_target_location_job_payload(item), force=True) + if queued: + record.enrichment_status = NewsEnrichmentStatus.QUEUED.value + record.enrichment_error = None + await db.flush() + return queued + + +async def broadcast_manual_news_changed() -> None: + await _broadcast_news_reload() diff --git a/backend/app/services/earth_news_store.py b/backend/app/services/earth_news_store.py index 4a2e48b0..e3909133 100644 --- a/backend/app/services/earth_news_store.py +++ b/backend/app/services/earth_news_store.py @@ -133,42 +133,6 @@ def _source_filter_clause(source_ids: set[str] | None): return EarthNewsItem.location_meta.op("->")("news_meta").op("->>")("source_id").in_(sorted(source_ids)) -def _record_source_id(record: EarthNewsItem) -> str: - location_meta = dict(record.location_meta or {}) - news_meta = location_meta.get("news_meta") if isinstance(location_meta.get("news_meta"), dict) else {} - source_id = str(news_meta.get("source_id") or "").strip() - if source_id: - return source_id - if isinstance(record.id, str) and ":" in record.id: - return record.id.split(":", 1)[0] - return record.feed_name or record.source or record.id - - -def _diversify_records_by_source(records: list[EarthNewsItem], *, limit: int) -> list[EarthNewsItem]: - if limit <= 0 or len(records) <= limit: - return records[:limit] - buckets: dict[str, list[EarthNewsItem]] = {} - order: list[str] = [] - for record in records: - source_id = _record_source_id(record) - if source_id not in buckets: - buckets[source_id] = [] - order.append(source_id) - buckets[source_id].append(record) - - diversified: list[EarthNewsItem] = [] - while len(diversified) < limit and order: - next_order: list[str] = [] - for source_id in order: - bucket = buckets.get(source_id) or [] - if bucket and len(diversified) < limit: - diversified.append(bucket.pop(0)) - if bucket: - next_order.append(source_id) - order = next_order - return diversified - - async def list_earth_news_items( db: AsyncSession, *, @@ -177,7 +141,7 @@ async def list_earth_news_items( categories: set[str] | None = None, source_ids: set[str] | None = None, ) -> list[ParsedNewsItem]: - query_limit = limit if source_ids else min(max(limit * 8, limit), 200) + query_limit = limit if source_ids else min(max(limit * 20, limit), 500) query = ( select(EarthNewsItem) .order_by(*_query_sort_key(active_region)) @@ -382,13 +346,28 @@ async def update_earth_news_item_enrichment( if record is None: return False if "latitude" in patch: - record.latitude = float(patch["latitude"]) - record.longitude = float(patch["longitude"]) - record.location_label = str(patch["location_label"]) - record.location_source = str(patch["location_source"]) - record.verified = bool(patch["verified"]) - record.location_meta = dict(patch.get("location_meta") or {}) - record.resolved_at = datetime.now(UTC) if record.verified else None + patch_meta = dict(patch.get("location_meta") or {}) + if record.location_source == "manual_location": + current_meta = dict(record.location_meta or {}) + patch_news_meta = patch_meta.get("news_meta") + if isinstance(patch_news_meta, dict): + current_meta["news_meta"] = patch_news_meta + current_meta["manual_enrichment"] = { + "resolution_stage": patch_meta.get("resolution_stage"), + "ai_attempted": patch_meta.get("ai_attempted"), + "ai_status": patch_meta.get("ai_status"), + "ai_error": patch_meta.get("ai_error"), + "debug_note": patch_meta.get("debug_note"), + } + record.location_meta = current_meta + else: + record.latitude = float(patch["latitude"]) + record.longitude = float(patch["longitude"]) + record.location_label = str(patch["location_label"]) + record.location_source = str(patch["location_source"]) + record.verified = bool(patch["verified"]) + record.location_meta = patch_meta + record.resolved_at = datetime.now(UTC) if record.verified else None if "content_language" in patch: record.content_language = str(patch.get("content_language") or "en") if "localizations" in patch: diff --git a/backend/pytest.ini b/backend/pytest.ini index 450e2ff2..bea70978 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -1,4 +1,5 @@ [pytest] +pythonpath = .. asyncio_mode = auto testpaths = tests python_files = test_*.py diff --git a/backend/tests/test_earth_news.py b/backend/tests/test_earth_news.py index e7e38de6..0c68f280 100644 --- a/backend/tests/test_earth_news.py +++ b/backend/tests/test_earth_news.py @@ -12,6 +12,7 @@ from app.services.earth_news import ( default_earth_news_sources_payload, normalize_earth_news_sources_payload, _fetch_source, + _diversify_news_items_for_locale, _enrich_items_with_target_locations, _extract_target_location_from_text, _parse_feed_entries, @@ -130,6 +131,47 @@ def test_rank_and_trim_items_prioritizes_active_breaking(): assert [item.id for item in ranked] == ["global:critical", "europe:expired", "europe:regular"] +def test_diversify_news_items_prefers_display_ready_content_across_sources(): + published_at = datetime(2026, 6, 11, 3, 0, tzinfo=UTC) + + def make_item(source_id: str, suffix: str, *, zh_ready: bool) -> ParsedNewsItem: + return ParsedNewsItem( + id=f"{source_id}:{suffix}", + title=f"{source_id} title {suffix}", + summary=f"{source_id} summary {suffix}", + url=f"https://example.com/{source_id}/{suffix}", + source=source_id, + feed_name=source_id, + feed_region="global", + homepage_url="https://example.com", + published_at=published_at, + content_language="en", + localizations={ + "zh-CN": { + "title": f"{source_id} 中文标题 {suffix}", + "summary": f"{source_id} 中文摘要 {suffix}", + } + } if zh_ready else {}, + ) + + items = [ + make_item("source-a", "1", zh_ready=False), + make_item("source-a", "2", zh_ready=False), + make_item("source-a", "3", zh_ready=False), + make_item("source-b", "1", zh_ready=True), + make_item("source-c", "1", zh_ready=True), + ] + + result = _diversify_news_items_for_locale( + items, + active_region="global", + limit=3, + locale="zh-CN", + ) + + assert [item.id.split(":", 1)[0] for item in result] == ["source-b", "source-c", "source-a"] + + def test_serialize_item_falls_back_to_global_anchor(): item = ParsedNewsItem( id="custom:test", @@ -961,9 +1003,11 @@ async def test_earth_news_payload_uses_fresh_database_items_without_rss(monkeypa async def fake_get_earth_news_freshness(_db, *, active_region): return 12, datetime.now(UTC) - async def fake_list_earth_news_items(_db, *, active_region, limit, categories=None): - assert limit == 12 - return [item] + async def fake_list_earth_news_items(_db, *, active_region, limit, categories=None, source_ids=None): + if source_ids is None: + assert limit == 12 + return [item] + return [] async def fail_fetch(_sources): raise AssertionError("fresh database items should not fetch RSS") @@ -1062,8 +1106,8 @@ async def test_earth_news_payload_passes_region_and_category_filters_to_store(mo async def fake_list_earth_news_items(_db, *, active_region, limit, categories=None, source_ids=None): captured["items_region"] = active_region captured["items_categories"] = categories - captured["items_source_ids"] = source_ids - return [item] + captured.setdefault("items_source_ids", []).append(source_ids) + return [item] if source_ids is None else [] async def fake_list_earth_news_cruise_items(_db, *, limit, categories=None, source_ids=None): captured["cruise_categories"] = categories @@ -1090,7 +1134,8 @@ async def test_earth_news_payload_passes_region_and_category_filters_to_store(mo assert captured["freshness_region"] == "europe" assert captured["items_region"] == "europe" assert captured["items_categories"] == {"business", "ecommerce"} - assert captured["items_source_ids"] is None + assert captured["items_source_ids"][0] is None + assert any(source_ids for source_ids in captured["items_source_ids"][1:]) assert captured["cruise_categories"] == {"business", "ecommerce"} assert captured["cruise_source_ids"] is None assert payload["filters"] == { diff --git a/backend/tests/test_earth_news_manual.py b/backend/tests/test_earth_news_manual.py new file mode 100644 index 00000000..3cd65e8f --- /dev/null +++ b/backend/tests/test_earth_news_manual.py @@ -0,0 +1,252 @@ +from datetime import UTC, datetime + +import pytest + +from app.core.enums import NewsSourceType +from app.models.earth_news import EarthNewsItem +from app.models.system_setting import SystemSetting +from app.services.earth_news import REGION_ANCHORS +from app.services.earth_news_manual import ( + DEFAULT_MANUAL_NEWS_GROUP_ID, + create_manual_news_group, + import_manual_news_items, + list_news_groups, + list_news_records, + parse_manual_news_import_upload, + rename_manual_news_group, + upsert_manual_news_item, +) + + +class _FakeResult: + def __init__(self, rows=None, scalar=None): + self.rows = rows or [] + self._scalar = scalar + + def scalar_one_or_none(self): + return self._scalar + + def scalar(self): + return self._scalar + + def scalars(self): + return self + + def all(self): + return self.rows + + +class _FakeNewsSession: + def __init__(self, records=None, setting=None): + self.records = dict(records or {}) + self.setting = setting + + async def get(self, _model, item_id): + return self.records.get(item_id) + + def add(self, item): + if isinstance(item, SystemSetting): + self.setting = item + else: + self.records[item.id] = item + + async def execute(self, stmt): + statement = str(stmt) + if "system_settings" in statement: + return _FakeResult(scalar=self.setting) + if "count" in statement.lower(): + return _FakeResult(scalar=len(self.records)) + return _FakeResult(rows=list(self.records.values())) + + async def flush(self): + return None + + +@pytest.fixture +def fake_news_queue(monkeypatch): + queued = [] + + async def _enqueue(payload, force=False): + queued.append({"payload": payload, "force": force}) + return True + + monkeypatch.setattr("app.services.earth_news_manual.enqueue_target_location_job", _enqueue) + return queued + + +@pytest.mark.asyncio +async def test_manual_news_upsert_uses_region_anchor_and_manual_metadata(fake_news_queue): + db = _FakeNewsSession() + + result = await upsert_manual_news_item( + db, + { + "title": "手动添加的新闻", + "summary": "一条用于测试的手动新闻。", + "source": "人工录入", + "region": "europe", + "published_at": "2026-05-15T03:00:00Z", + "tags": ["manual", "test"], + }, + ) + + anchor = REGION_ANCHORS["europe"] + assert result.created is True + assert result.queued is True + assert result.item.id.startswith("manual:") + assert result.item.feed_name == "手动添加" + assert result.item.source == "人工录入" + assert result.item.latitude == anchor.latitude + assert result.item.longitude == anchor.longitude + assert result.item.location_source == "region_anchor" + assert result.item.verified is False + assert result.item.location_meta["news_meta"]["feed_type"] == NewsSourceType.MANUAL.value + assert result.item.location_meta["news_meta"]["source_type"] == NewsSourceType.MANUAL.value + assert result.item.location_meta["news_meta"]["manual_group_id"] == DEFAULT_MANUAL_NEWS_GROUP_ID + assert len(fake_news_queue) == 1 + + +@pytest.mark.asyncio +async def test_manual_news_duplicate_import_upserts_without_duplicate_rows(fake_news_queue): + db = _FakeNewsSession() + payload = { + "title": "Same manual story", + "source": "Manual Desk", + "published_at": "2026-05-15T03:00:00Z", + "region": "global", + } + + first = await upsert_manual_news_item(db, payload) + second = await upsert_manual_news_item(db, {**payload, "summary": "Updated summary"}) + + assert first.created is True + assert second.created is False + assert len(db.records) == 1 + assert db.records[first.item.id].summary == "Updated summary" + + +@pytest.mark.asyncio +async def test_manual_news_edit_without_location_preserves_manual_coordinates(fake_news_queue): + db = _FakeNewsSession() + created = await upsert_manual_news_item( + db, + { + "title": "Taipei-1 data center update", + "summary": "Initial summary.", + "region": "asia-pacific", + "published_at": "2026-05-15T03:00:00Z", + "location": {"label": "Kaohsiung, Taiwan", "latitude": 22.6273, "longitude": 120.3014}, + }, + ) + + updated = await upsert_manual_news_item( + db, + { + "title": "Taipei-1 data center update", + "summary": "Edited summary only.", + "region": "asia-pacific", + "published_at": "2026-05-15T03:00:00Z", + }, + item_id_override=created.item.id, + ) + + assert updated.created is False + assert updated.item.latitude == pytest.approx(22.6273) + assert updated.item.longitude == pytest.approx(120.3014) + assert updated.item.location_source == "manual_location" + assert updated.item.verified is True + + +@pytest.mark.asyncio +async def test_manual_news_api_service_rejects_rss_records(fake_news_queue): + rss_record = EarthNewsItem( + id="bbc-world:example", + title="RSS story", + summary="RSS summary", + source="BBC World", + feed_name="BBC World", + region="global", + latitude=20, + longitude=0, + location_label="全球", + location_source="region_anchor", + verified=False, + location_meta={"news_meta": {"feed_type": "rss"}}, + first_seen_at=datetime.now(UTC), + last_seen_at=datetime.now(UTC), + ) + db = _FakeNewsSession({rss_record.id: rss_record}) + + with pytest.raises(PermissionError): + await upsert_manual_news_item( + db, + {"title": "Edited title", "region": "global"}, + item_id_override=rss_record.id, + ) + + +@pytest.mark.asyncio +async def test_manual_news_import_reports_per_item_errors(fake_news_queue): + db = _FakeNewsSession() + + result = await import_manual_news_items( + db, + [ + {"title": "Valid manual news", "region": "global"}, + {"summary": "missing title"}, + ], + ) + + assert result["created"] == 1 + assert result["failed"] == 1 + assert result["errors"][0]["index"] == 1 + + +@pytest.mark.asyncio +async def test_manual_news_groups_default_create_and_rename(fake_news_queue): + db = _FakeNewsSession() + + initial = await list_news_groups(db) + assert initial["manual_groups"][0]["id"] == DEFAULT_MANUAL_NEWS_GROUP_ID + assert initial["manual_groups"][0]["name"] == "新建新闻组" + + group = await create_manual_news_group(db, "专题组") + assert group["name"] == "专题组" + assert db.setting is not None + + await upsert_manual_news_item(db, {"title": "Grouped story", "region": "global"}, group_id=group["id"]) + renamed = await rename_manual_news_group(db, group["id"], "重命名专题") + + record = next(iter(db.records.values())) + assert renamed["name"] == "重命名专题" + assert record.location_meta["news_meta"]["manual_group_id"] == group["id"] + assert record.location_meta["news_meta"]["manual_group_name"] == "重命名专题" + + +@pytest.mark.asyncio +async def test_manual_news_list_filters_by_group_id(fake_news_queue): + db = _FakeNewsSession() + group = await create_manual_news_group(db, "导入组") + + await import_manual_news_items( + db, + [ + {"title": "In group", "region": "global"}, + {"title": "Also in group", "region": "global"}, + ], + group_id=group["id"], + ) + await upsert_manual_news_item(db, {"title": "Default group", "region": "global"}) + + grouped = await list_news_records(db, page=1, page_size=20, group_id=group["id"]) + default_group = await list_news_records(db, page=1, page_size=20, group_id=DEFAULT_MANUAL_NEWS_GROUP_ID) + + assert grouped["total"] == 2 + assert {item["manual_group_id"] for item in grouped["items"]} == {group["id"]} + assert default_group["total"] == 1 + + +@pytest.mark.asyncio +async def test_manual_news_import_parser_requires_json_array(): + with pytest.raises(ValueError, match="顶层必须是数组"): + await parse_manual_news_import_upload(b'{"title":"not an array"}') diff --git a/backend/tests/test_enum_contracts.py b/backend/tests/test_enum_contracts.py index f65f6e73..10156731 100644 --- a/backend/tests/test_enum_contracts.py +++ b/backend/tests/test_enum_contracts.py @@ -28,7 +28,7 @@ def test_protocol_enum_values_remain_api_compatible() -> None: assert [item.value for item in NewsImportanceLevel] == ["low", "medium", "high", "critical"] assert [item.value for item in BreakingLevel] == ["none", "watch", "breaking", "critical"] assert [item.value for item in BreakingScope] == ["regional", "global"] - assert [item.value for item in NewsSourceType] == ["rss", "atom", "aggregated", "reference"] + assert [item.value for item in NewsSourceType] == ["rss", "atom", "aggregated", "reference", "manual"] assert [item.value for item in UserRole] == ["viewer", "admin", "super_admin"] assert JobStatus.RUNNING.value == "running" assert PlaygroundMessageKind.THINKING.value == "thinking" diff --git a/backend/tests/test_motion_agent.py b/backend/tests/test_motion_agent.py index 99791d0a..223995cb 100644 --- a/backend/tests/test_motion_agent.py +++ b/backend/tests/test_motion_agent.py @@ -40,6 +40,9 @@ def test_gesture_event_serializes_stable_protocol_fields(): assert payload["seq"] == 7 assert payload["source"] == "motion-agent" assert payload["mode"] == "single" + assert payload["protocol_version"] == "motion.v2" + assert payload["input_mode"] == "single" + assert payload["camera_id"] == "unknown" assert payload["payload"] == {} @@ -88,8 +91,13 @@ def test_motion_server_status_includes_dry_run_camera_and_heartbeat(): assert status["camera_count"] == 1 assert status["active_camera_ids"] == ["dry-run:null-camera"] assert status["recognizer"] == "dry-run" + assert status["protocol_version"] == "motion.v2" + assert status["armed"] is False + assert status["paused"] is False + assert status["devices_open"] is False assert heartbeat == { "timestamp_ms": 123, + "protocol_version": "motion.v2", "source": "motion-agent", "type": "heartbeat", } @@ -109,6 +117,7 @@ def test_skeleton_event_serializes_without_raw_image_fields(): payload = json.loads(event.to_json()) assert payload["type"] == "skeleton" + assert payload["protocol_version"] == "motion.v2" assert payload["matched_gesture"] == "rotate_left" assert payload["confidence"] == 0.91 assert payload["camera_id"] == "usb:0" @@ -120,6 +129,25 @@ def test_skeleton_event_serializes_without_raw_image_fields(): assert "frame" not in payload +def test_v2_gesture_set_accepts_frontend_motion_gestures(): + state = GestureStateMachine(confidence_threshold=0.7, cooldown_ms=0) + + for gesture in [ + "rotate_up", + "rotate_down", + "focus_prev", + "focus_next", + "layer_prev", + "layer_next", + ]: + event = state.accept( + GestureObservation(gesture, confidence=0.9, intensity=0.8, timestamp_ms=1000) + ) + + assert event is not None + assert event.gesture == gesture + + def test_dry_run_recognizer_produces_debug_skeleton(): server = MotionAgentServer(MotionAgentConfig(dry_run=True)) @@ -240,3 +268,92 @@ async def test_motion_agent_cli_reports_dependency_error_without_traceback(monke assert exit_code == 2 assert "Motion agent failed: missing cv stack" in captured.err assert "Traceback" not in captured.err + + +@pytest.mark.asyncio +async def test_motion_agent_command_updates_control_state(): + server = MotionAgentServer(MotionAgentConfig(dry_run=True)) + + armed = await server.handle_command( + json.dumps( + { + "type": "command", + "command": "set_armed", + "request_id": "req-armed", + "payload": {"armed": True}, + } + ) + ) + paused = await server.handle_command( + { + "type": "command", + "command": "set_paused", + "request_id": "req-paused", + "payload": {"paused": True}, + } + ) + + assert armed.ok is True + assert armed.request_id == "req-armed" + assert armed.status["armed"] is True + assert paused.ok is True + assert paused.status["paused"] is True + + +@pytest.mark.asyncio +async def test_motion_agent_open_devices_command_accepts_dual_mode(): + server = MotionAgentServer(MotionAgentConfig(dry_run=True)) + + try: + result = await server.handle_command( + { + "type": "command", + "command": "open_devices", + "request_id": "req-open", + "payload": {"input_mode": "dual_redundant"}, + } + ) + + assert result.ok is True + assert result.status["input_mode"] == "dual_redundant" + assert result.status["active_camera_ids"] == ("dry-run:null-camera",) + assert server._recognition_subprocess is not None + assert server._recognition_subprocess.returncode is None + finally: + await server.stop_recognition_subprocess() + + +def test_motion_agent_dual_fusion_merges_matching_observations(): + server = MotionAgentServer(MotionAgentConfig(dry_run=True)) + server.state.mode = "dual_redundant" + + selected, fusion = server._fuse_observations( + [ + GestureObservation("zoom_in", confidence=0.82, intensity=0.4, camera_id="usb:0"), + GestureObservation("zoom_in", confidence=0.86, intensity=0.8, camera_id="usb:1"), + ] + ) + + assert selected.gesture == "zoom_in" + assert selected.camera_id == "fusion" + assert selected.confidence > 0.86 + assert fusion == { + "source_cameras": ["usb:1", "usb:0"], + "window_ms": 120, + "reason": "matched_observations", + } + + +def test_motion_agent_dual_fusion_suppresses_close_conflict(): + server = MotionAgentServer(MotionAgentConfig(dry_run=True, confidence_threshold=0.7)) + + selected, fusion = server._fuse_observations( + [ + GestureObservation("zoom_in", confidence=0.82, intensity=0.5, camera_id="usb:0"), + GestureObservation("zoom_out", confidence=0.78, intensity=0.5, camera_id="usb:1"), + ] + ) + + assert selected.gesture == "zoom_in" + assert selected.confidence == 0 + assert fusion["reason"] == "conflict_ignored" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 51339d19..a367dd41 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,22 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.71.0] — 2026-06-11 + +Released: 2026-06-11 + +### Highlights +- 将 Motion Agent 升级为可供 Web/UE 共用的双向控制服务,补齐真实 MediaPipe 识别 worker、设备控制、动作白名单和 WSL 摄像头开箱启动链路。 +- 新增 Earth 手动新闻内容组、条目、导入与重处理能力,并改进按 locale 和启用来源进行的新闻补充与多样化。 +- 对齐动捕模式下的 Earth 点击、详情锁定和卫星轨迹交互,同时完善启动脚本、测试 harness 与运维说明。 + +### Added / Fixed / Improved +- Motion Agent 支持命令结果、状态、骨架与手势事件,统一单路/双路输入配置,并随 `planet.sh` 默认启动;仓库内提供 usbipd-win fallback 安装包。 +- Earth 新闻服务集中处理显示就绪判断和来源多样化,避免存储层与编排层重复筛选;新增手动新闻 API 与回归测试。 +- 清理 Motion Agent 重复识别执行路径、前端不稳定随机 key 和过时计划描述,补齐 pytest 路径 harness、双语使用手册、快速开始与数据流文档。 + +--- + ## [0.70.0] — 2026-06-04 Released: 2026-06-04 diff --git a/docs/plans/README.md b/docs/plans/README.md index 3906d5ba..2d249e23 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -33,6 +33,7 @@ - [Earth News Cruise Summary Plan](/home/ray/dev/linkong/planet/docs/plans/earth-news-cruise-summary-plan.md) - [Earth 动作捕捉手势控制计划](/home/ray/dev/linkong/planet/docs/plans/earth-motion-capture-gesture-control-plan.md) - [Earth 动捕交互语义 V2 计划](/home/ray/dev/linkong/planet/docs/plans/earth-motion-gesture-interaction-v2-plan.md) +- [Motion Agent v2 控制协议与 3D 标定路线](/home/ray/dev/linkong/planet/docs/plans/motion-agent-v2-control-protocol-plan.md) - [Earth Presentation 解耦架构计划](/home/ray/dev/linkong/planet/docs/plans/earth-presentation-decoupled-architecture-plan.md) - [Earth Vessel Rendering Performance Plan](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-rendering-performance-plan.md) - [AIS 多源采集、冲突记录与聚合接口计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-ais-aggregation-plan.md) diff --git a/docs/plans/earth-motion-capture-gesture-control-plan.md b/docs/plans/earth-motion-capture-gesture-control-plan.md index 0968fe0b..195268e5 100644 --- a/docs/plans/earth-motion-capture-gesture-control-plan.md +++ b/docs/plans/earth-motion-capture-gesture-control-plan.md @@ -1,5 +1,7 @@ # Earth Motion Capture Gesture Control Plan +> Update: Motion Agent process/device control, UE/Web shared command protocol, dual-camera redundant fusion, and the next 3D calibration route are now tracked in [Motion Agent v2 Control Protocol And 3D Calibration Roadmap](/home/ray/dev/linkong/planet/docs/plans/motion-agent-v2-control-protocol-plan.md). This document remains useful for the original provider split and gesture-control intent. + ## Goal 为 Planet Earth 大屏和未来 3D 展示增加一套解耦的动作捕捉手势控制能力。实时输入分成两条路线:网页端可直接通过浏览器 `getUserMedia` 在本机识别;高级设备可继续使用本机 Motion Capture Edge Agent。两条路线都只输出轻量语义事件,客户端负责把“手势事件”映射到“具体交互函数”。 diff --git a/docs/plans/earth-motion-gesture-interaction-v2-plan.md b/docs/plans/earth-motion-gesture-interaction-v2-plan.md index 0b35050d..bc2bbdee 100644 --- a/docs/plans/earth-motion-gesture-interaction-v2-plan.md +++ b/docs/plans/earth-motion-gesture-interaction-v2-plan.md @@ -2,6 +2,8 @@ **状态**:已实现主体交互,并按实测调整。当前浏览器识别保留右手导航、头部切目标、左手上下切动捕图层、双手张开/收拢缩放;双手上举确认暂时关闭。Motion 目标展示已改为 `CruiseSequencer` + `PresentationController` 的 persistent 展示。 +> Update: Agent-side bidirectional commands, UE/Web shared device control, dual-camera redundant fusion, and future calibrated 3D mode are tracked in [Motion Agent v2 Control Protocol And 3D Calibration Roadmap](/home/ray/dev/linkong/planet/docs/plans/motion-agent-v2-control-protocol-plan.md). + ## Summary 把动捕从“几个单点手势触发函数”升级为一套更像大屏遥控器的交互层:右手负责地球导航,头部负责候选切换,左手上下切换动捕候选图层,双手负责缩放,调试面板支持“只显示骨骼”和暂停匹配。进入动捕模式后,Earth 自动软选中屏幕中心附近的正面可交互目标;确认动作预留为把目标升级为锁定,并用巡航/引导线式详情打开,不再模拟鼠标点击。 diff --git a/docs/plans/motion-agent-v2-control-protocol-plan.md b/docs/plans/motion-agent-v2-control-protocol-plan.md new file mode 100644 index 00000000..0375dc2b --- /dev/null +++ b/docs/plans/motion-agent-v2-control-protocol-plan.md @@ -0,0 +1,175 @@ +# Motion Agent v2 Control Protocol And 3D Calibration Roadmap + +## Summary + +Motion Agent v2 turns the local motion service into a shared WebSocket control plane for the Web Earth page and UE clients. The agent process is still started externally through `planet.sh`, a desktop service, or UE process management. Once the process is running, clients can open cameras, close cameras, switch input mode, arm or pause recognition, and inspect status through the same bidirectional WebSocket protocol. + +This phase implements robust single-camera and dual-camera redundant fusion. True calibrated 3D skeleton reconstruction is deliberately reserved for the v3 calibration phase. + +## v2 Protocol + +The default endpoint remains: + +```text +ws://127.0.0.1:8765/ws/gestures +``` + +The agent emits: + +- `gesture` +- `skeleton` +- `status` +- `heartbeat` +- `command_result` + +Clients send: + +- `open_devices` +- `close_devices` +- `rescan_devices` +- `set_armed` +- `set_paused` +- `set_input_mode` +- `set_camera_config` +- `set_fusion_config` +- `set_debug_options` +- `set_enabled_gestures` +- `get_status` +- `ping` + +All v2 messages include additive compatibility fields such as `protocol_version`, `request_id`, `camera_id`, `input_mode`, and optional `fusion`. Older push-only clients can continue to consume `gesture`, `skeleton`, `status`, and `heartbeat`. + +Example command: + +```json +{ + "type": "command", + "command": "open_devices", + "request_id": "req-001", + "payload": { + "input_mode": "dual_redundant", + "camera_indexes": [0, 1] + } +} +``` + +Example gesture whitelist command: + +```json +{ + "type": "command", + "command": "set_enabled_gestures", + "request_id": "earth-motion-enabled-gestures", + "payload": { + "gestures": ["rotate_left", "rotate_right", "zoom_in", "zoom_out", "confirm"] + } +} +``` + +Example result: + +```json +{ + "type": "command_result", + "protocol_version": "motion.v2", + "request_id": "req-001", + "command": "open_devices", + "ok": true, + "status": { + "armed": false, + "paused": false, + "input_mode": "dual_redundant", + "devices_open": true, + "active_camera_ids": ["usb:0", "usb:1"] + } +} +``` + +## Device And Control State + +Process startup is not a WebSocket feature: the server must exist before a WebSocket client can connect. UE should start the agent as an external process or depend on a system service, then connect to the WebSocket endpoint. + +Device wake and control wake are WebSocket features: + +- `open_devices` opens USB cameras or URL cameras. +- `close_devices` releases them. +- `set_armed` enables gesture execution. +- `set_paused` pauses recognition without closing the connection. + +When `armed=false`, the agent may still emit skeleton and status events, but it does not emit actionable gesture events. + +The Earth settings dialog owns a user-facing gesture whitelist. Unchecked gestures are ignored in the Earth client and are also sent to the Motion Agent through `set_enabled_gestures`, so the server does not broadcast disabled actions to UE/Web consumers. The default whitelist enables the full v2 gesture set; disabling gestures is a local display/control preference and does not change the installed recognition model. + +## Input Modes + +- `single`: one camera. +- `dual_redundant`: two or more cameras observe the same gesture. Matching observations in a short window are fused into a higher-confidence event. +- `single_fallback`: the primary camera is preferred and a secondary input is used as fallback. +- `calibrated_3d`: reserved for v3 and should not be enabled unless a calibration profile exists. + +The v2 dual-camera mode is redundant fusion, not 3D reconstruction. It is meant to improve reliability under occlusion and camera noise without requiring calibration. + +## v3 3D Calibration Roadmap + +The next phase is `Motion Agent v3 3D Calibration`. It upgrades from redundant fusion to calibrated multi-camera skeleton fusion. + +Planned capabilities: + +- Camera intrinsics: focal length, distortion, resolution. +- Camera extrinsics: relative position, rotation, and baseline distance. +- Calibration workflow: checkerboard, AprilTag, or ArUco board. +- Local calibration profile JSON with query, load, reset, and validation commands. +- `skeleton_3d` event with world-space joints, confidence, and source cameras. +- UE coordinate mapping from Motion Agent coordinates to UE world or widget coordinates. + +Reserved v3 input configuration: + +```json +{ + "input_mode": "calibrated_3d", + "calibration_profile": "desk-dual-camera-v1" +} +``` + +Reserved v3 event: + +```json +{ + "type": "skeleton_3d", + "protocol_version": "motion.v3", + "profile": "desk-dual-camera-v1", + "joints": [ + { + "name": "right_wrist", + "x": 0.42, + "y": 1.13, + "z": 0.76, + "confidence": 0.91 + } + ] +} +``` + +## Test Plan + +- Command/result roundtrip for device open, close, rescan, armed, paused, and status. +- Gesture protocol accepts the full Earth v2 gesture set. +- Earth settings can disable individual gestures; disabled gestures are ignored locally and filtered server-side through `set_enabled_gestures`. +- Motion Agent `status` reports the current enabled gesture list for Web/UE diagnostics. +- Single and dual redundant fusion emit compatible gesture payloads. +- Conflicting dual-camera observations below the confidence delta are ignored. +- Web Earth `motion-agent-provider` can send commands over the same socket it uses for events. +- UE mock clients can operate the service without browser-only assumptions. +- Dry-run mode can test commands, status, skeleton, and fusion behavior without camera dependencies. + +## Current Limitations + +- Production recognition uses a real MediaPipe pose pipeline and heuristic gesture recognizer. It still needs environment-specific threshold tuning, camera framing validation, and long-running reliability checks before it can be treated as calibration-free. +- Dual-camera v2 does not triangulate 3D joint positions. +- `calibrated_3d` is documented as a reserved mode and must not be treated as implemented until v3 lands. + +## Implementation Status + +- Implemented: bidirectional command/result protocol, device lifecycle controls, dry-run mode, subprocess recognition worker, MediaPipe pose recognition, gesture whitelist, single-camera mode, dual-redundant/fallback scaffolding, Web client integration, and default `planet.sh` lifecycle integration. +- Remaining v2 hardening: tune recognition thresholds across camera placements, exercise UE command/control integration, and run longer soak tests for device reconnect and dual-camera conflicts. +- Planned v3: calibrated multi-camera 3D skeleton fusion and Motion Agent-to-UE coordinate calibration. diff --git a/docs/technical/en/earth-frontend-context.md b/docs/technical/en/earth-frontend-context.md index 241c9d55..64ad40d2 100644 --- a/docs/technical/en/earth-frontend-context.md +++ b/docs/technical/en/earth-frontend-context.md @@ -102,9 +102,9 @@ Responsibilities: - Map `rotate_left`, `rotate_right`, `rotate_up`, `rotate_down`, `zoom_in`, `zoom_out`, `focus_prev`, `focus_next`, `layer_prev`, `layer_next`, and `confirm` to the action entry points exposed by `main.js`. - Parse `skeleton` debug events and dispatch `earth:motion-debug-frame`. -Gesture recognition may run locally in the browser or inside the local Agent, but neither path sends realtime camera frames to the SaaS cloud. `main.js` exposes rotation, zoom, target focus, layer switching, and confirm entry points, plus a `window.__planetEarth.motion` debug entry. The adapter starts only when `?motion=1` is present, browser local storage contains `planet-earth-motion-control-enabled=true`, or Earth settings enable Motion Debug Mode. +Gesture recognition may run locally in the browser or inside the local Agent, but neither path sends realtime camera frames to the SaaS cloud. `main.js` exposes rotation, zoom, target focus, layer switching, and confirm entry points, plus a `window.__planetEarth.motion` debug entry. The adapter starts only when `?motion=1` is present, browser local storage contains `planet-earth-motion-control-enabled=true`, or Earth settings enable Motion Debug Mode. `shared.motionEnabledGestures` stores the user-approved gesture whitelist; the browser filters locally, and Motion Agent mode also synchronizes it through `set_enabled_gestures`. -[motion-debug-panel.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-debug-panel.js) owns the debug panel. It listens for `earth:motion-debug-frame` and draws normalized skeleton joints and bones on a canvas. The Browser Camera provider also emits `earth:motion-debug-video-source` with the local `