Browse Source

chore: baseline data query agent

刘立冬 2 ngày trước cách đây
commit
21e3d9e0e3
83 tập tin đã thay đổi với 5873 bổ sung0 xóa
  1. 55 0
      .agents/skills/feishu-data-publisher/SKILL.md
  2. 4 0
      .agents/skills/feishu-data-publisher/agents/openai.yaml
  3. 69 0
      .agents/skills/feishu-data-publisher/references/configuration.md
  4. 334 0
      .agents/skills/feishu-data-publisher/scripts/feishu_client.py
  5. 104 0
      .agents/skills/feishu-data-publisher/scripts/publish_table.py
  6. 49 0
      .agents/skills/odps-ad-risk-analysis/SKILL.md
  7. 4 0
      .agents/skills/odps-ad-risk-analysis/agents/openai.yaml
  8. 57 0
      .agents/skills/odps-ad-risk-analysis/references/risk-strategies.md
  9. 87 0
      .agents/skills/odps-ad-risk-analysis/references/sql-templates.md
  10. 61 0
      .agents/skills/odps-ad-risk-analysis/scripts/odps_module.py
  11. 28 0
      .agents/skills/odps-ad-risk-analysis/scripts/run_sql.py
  12. 53 0
      .agents/skills/odps-growth-fission-report/SKILL.md
  13. 4 0
      .agents/skills/odps-growth-fission-report/agents/openai.yaml
  14. 55 0
      .agents/skills/odps-growth-fission-report/references/metrics.md
  15. 37 0
      .agents/skills/odps-growth-fission-report/references/raw-output-contract.md
  16. 264 0
      .agents/skills/odps-growth-fission-report/scripts/format_report.py
  17. 113 0
      .agents/skills/odps-growth-fission-report/scripts/normalize_request.py
  18. 61 0
      .agents/skills/odps-growth-fission-report/scripts/odps_module.py
  19. 28 0
      .agents/skills/odps-growth-fission-report/scripts/run_sql.py
  20. 78 0
      .agents/skills/odps-product-efficiency-report/SKILL.md
  21. 4 0
      .agents/skills/odps-product-efficiency-report/agents/openai.yaml
  22. 61 0
      .agents/skills/odps-product-efficiency-report/references/metrics.md
  23. 40 0
      .agents/skills/odps-product-efficiency-report/references/raw-output-contract.md
  24. 286 0
      .agents/skills/odps-product-efficiency-report/scripts/format_report.py
  25. 109 0
      .agents/skills/odps-product-efficiency-report/scripts/normalize_request.py
  26. 61 0
      .agents/skills/odps-product-efficiency-report/scripts/odps_module.py
  27. 28 0
      .agents/skills/odps-product-efficiency-report/scripts/run_sql.py
  28. 41 0
      .agents/skills/query-odps-data/SKILL.md
  29. 4 0
      .agents/skills/query-odps-data/agents/openai.yaml
  30. 195 0
      .agents/skills/query-odps-data/references/data-catalog.md
  31. 58 0
      .agents/skills/query-user-behavior-path/SKILL.md
  32. 4 0
      .agents/skills/query-user-behavior-path/agents/openai.yaml
  33. 84 0
      .agents/skills/query-user-behavior-path/references/logs-and-definitions.md
  34. 61 0
      .agents/skills/query-user-behavior-path/scripts/odps_module.py
  35. 28 0
      .agents/skills/query-user-behavior-path/scripts/run_sql.py
  36. 207 0
      .agents/skills/query-user-behavior-path/scripts/user_timeline.py
  37. 133 0
      .agents/skills/query-user-behavior-path/scripts/user_timeline_realtime.py
  38. 104 0
      .agents/skills/query-user-behavior-path/scripts/user_timeline_realtime_batch.py
  39. 34 0
      .env.example
  40. 10 0
      .gitignore
  41. 13 0
      AGENTS.md
  42. 37 0
      README.md
  43. 49 0
      findings.md
  44. 62 0
      progress.md
  45. 39 0
      pyproject.toml
  46. 11 0
      scripts/check.sh
  47. 7 0
      scripts/restart.sh
  48. 12 0
      scripts/setup.sh
  49. 33 0
      scripts/start.sh
  50. 17 0
      scripts/status.sh
  51. 29 0
      scripts/stop.sh
  52. 55 0
      scripts/test_openrouter_key.py
  53. 4 0
      src/data_query_agent/__init__.py
  54. 59 0
      src/data_query_agent/__main__.py
  55. 103 0
      src/data_query_agent/codex_runtime.py
  56. 101 0
      src/data_query_agent/codex_worker.py
  57. 44 0
      src/data_query_agent/commands.py
  58. 110 0
      src/data_query_agent/config.py
  59. 45 0
      src/data_query_agent/doctor.py
  60. 242 0
      src/data_query_agent/feishu.py
  61. 74 0
      src/data_query_agent/models.py
  62. 80 0
      src/data_query_agent/odps_client.py
  63. 62 0
      src/data_query_agent/reports.py
  64. 308 0
      src/data_query_agent/service.py
  65. 164 0
      src/data_query_agent/skill_executor.py
  66. 144 0
      src/data_query_agent/sql_guard.py
  67. 142 0
      src/data_query_agent/state.py
  68. 138 0
      task_plan.md
  69. 24 0
      tests/test_authorization.py
  70. 16 0
      tests/test_codex_worker.py
  71. 12 0
      tests/test_commands.py
  72. 14 0
      tests/test_config.py
  73. 9 0
      tests/test_feishu.py
  74. 37 0
      tests/test_generic_skill_catalog.py
  75. 8 0
      tests/test_models.py
  76. 62 0
      tests/test_product_efficiency_formatter.py
  77. 30 0
      tests/test_product_efficiency_skill_contract.py
  78. 12 0
      tests/test_reports.py
  79. 14 0
      tests/test_security.py
  80. 100 0
      tests/test_skill_executor.py
  81. 129 0
      tests/test_sql_guard.py
  82. 53 0
      tests/test_state.py
  83. 72 0
      tests/test_timeline_sql.py

+ 55 - 0
.agents/skills/feishu-data-publisher/SKILL.md

@@ -0,0 +1,55 @@
+---
+name: feishu-data-publisher
+description: 将本地 CSV、XLS 或 XLSX 数据文件上传并导入为飞书在线电子表格,把链接权限设置为“企业内获得链接的人可阅读”,并通过指定飞书机器人将 AI 生成的数据概要和表格卡片发送到目标群聊。用于数据查询完成后发布结果与结论、查找机器人可见群聊、按群名解析 chat_id,或排查飞书上传、导入、权限与群消息问题。
+---
+
+# 飞书数据表发布
+
+将 `SKILL_DIR` 解析为当前 `SKILL.md` 所在的安装目录。所有脚本和参考资料都相对该目录解析;不得假定当前工作目录、作者仓库或作者机器。
+
+## 运行环境
+
+依赖 `httpx`。脚本只读取当前进程中的 `FEISHU_APP_ID`、`FEISHU_APP_SECRET`、`FEISHU_TARGET_CHAT_ID` 和可选的 `FEISHU_TARGET_CHAT_NAME`,不会自动解析 `.env`。项目使用 `.env` 时,执行脚本前先在该项目目录运行 `source .env`。不得把凭证写入 Skill、命令参数、输出文件或回复。
+
+首次使用或权限报错时阅读 [configuration.md](references/configuration.md)。
+
+## 工作流程
+
+1. 确认用户明确要求上传并发送飞书;仅查询数据时不得自动产生外部副作用。
+2. 确认结果文件存在,且后缀为 `.csv`、`.xls` 或 `.xlsx`。
+3. 发布数据报表前,读取校验后的聚合结果或复用上游查询 Skill 的分析,按“群卡片概要规则”准备 `summary_message`。无法解释业务指标时只陈述可验证事实,不得编造结论。
+4. 确认目标群。优先使用用户本次给出的 `chat_id` 或群名,其次使用环境变量。群名不唯一时必须停止并列出候选,不得猜测。
+5. 如需只读检查机器人可见群聊:
+
+   `python3 "$SKILL_DIR/scripts/publish_table.py" --list-chats --name '前端组'`
+
+6. 先执行无副作用预检,并传入实际概要:
+
+   `python3 "$SKILL_DIR/scripts/publish_table.py" report.xlsx --message "$summary_message" --dry-run`
+
+7. 用户确认目标和上传意图后执行发布:
+
+   `python3 "$SKILL_DIR/scripts/publish_table.py" report.xlsx --title '产品效率日报' --message "$summary_message"`
+
+   临时覆盖目标群可使用 `--chat-id` 或 `--chat-name`。不要把群 ID 硬编码进 Skill。
+8. 检查 JSON 结果中的 `url`、`permission` 和 `message_sent`。完整成功顺序必须是:上传临时素材、导入在线表格、设置企业内链接可读、发送含概要的群卡片。权限失败时不得继续发送链接。
+9. 回复中只给出在线表格链接、目标群名称或 ID、权限结果和消息发送结果,并说明概要已随群卡片发送;不要重复整段结论,不得输出 access token、app secret、临时上传 token 或完整 API 响应。
+
+## 群卡片概要规则
+
+- 对数据查询报表,`--message` 必须传入实际数据概要;禁止沿用脚本的通用默认文案。
+- 优先复用上游查询 Skill 基于已校验聚合结果生成的结论,确保群卡片和报表口径一致。
+- 概要建议不超过 800 个中文字符,使用简洁 Lark Markdown,包含查询口径、3 至 6 个核心指标、正负向判断和必要的样本或时效风险。
+- 只写报表可验证的事实;基线为零、数据未收全或分组不均衡时明确提示,不夸大因果。
+
+## 权限规则
+
+- 默认且固定设置 `external_access=false` 和 `link_share_entity=tenant_readable`,对应“企业内获得链接的人可阅读”。
+- 这是宽权限。每次发布前必须确认文件不含不应公开的数据;如用户要求更窄权限,不得沿用此模式,应先改造权限策略。
+- 目标群只用于接收消息,不作为文档协作者;访问由链接权限控制。
+
+## 资源
+
+- `scripts/feishu_client.py`:环境变量驱动的飞书 API 客户端。
+- `scripts/publish_table.py`:群聊查询、无副作用预检和发布入口。
+- `references/configuration.md`:环境变量、应用权限和故障排查。

+ 4 - 0
.agents/skills/feishu-data-publisher/agents/openai.yaml

@@ -0,0 +1,4 @@
+interface:
+  display_name: "飞书数据表发布"
+  short_description: "把本地数据导入飞书表格,并将AI数据结论随卡片发送群聊"
+  default_prompt: "使用 $feishu-data-publisher 将查询结果和数据概要发布为飞书在线表格卡片并通知目标群聊。"

+ 69 - 0
.agents/skills/feishu-data-publisher/references/configuration.md

@@ -0,0 +1,69 @@
+# 配置与权限
+
+## 环境变量
+
+脚本读取当前进程环境变量,不会自动加载 `.env`。在项目根目录创建不进入 Git 的 `.env`,写入以下三个参数:
+
+```bash
+export FEISHU_APP_ID='飞书自建应用 App ID'
+export FEISHU_APP_SECRET='飞书自建应用 App Secret'
+export FEISHU_TARGET_CHAT_ID='oc_a63f05d3a773e8d4d4a57955a618b63b'
+```
+
+当前团队使用的机器人应用名称为“增长投放”。`FEISHU_APP_ID` 和 `FEISHU_APP_SECRET` 的真实值只放在环境变量或 Secret Manager,不写入 Markdown 和 Git。
+
+每次新开终端后,在项目根目录执行:
+
+```bash
+source .env
+```
+
+可用下面的命令确认三个变量已加载,而不显示其值:
+
+```bash
+for name in FEISHU_APP_ID FEISHU_APP_SECRET FEISHU_TARGET_CHAT_ID; do
+  if [ -n "$(printenv "$name")" ]; then echo "$name=set"; else echo "$name=unset"; fi
+done
+```
+
+当前默认 chat_id 对应“前端组”。群 ID 不是应用密钥,但属于环境配置;通用 Skill 只记录配置方法,实际值放在使用者本机的 `.env`。
+
+也可使用群名,由脚本在机器人可见群中精确解析:
+
+```bash
+export FEISHU_TARGET_CHAT_NAME='前端组'
+```
+
+群名可能重名,因此生产环境推荐保存 `chat_id`。群 ID 不是应用密钥,但属于环境配置,不应硬编码进通用 Skill。
+
+可选配置:
+
+```bash
+export FEISHU_BASE_URL='https://open.feishu.cn/open-apis'
+```
+
+## 飞书应用准备
+
+- 应用必须启用机器人能力,并已发布到当前企业。
+- 机器人必须加入目标群,才能查到群并向群发送消息。
+- 应用需具备获取 tenant access token、上传素材、创建与查询云文档导入任务、管理电子表格权限、获取群信息和发送消息所需的权限。
+- 如果上传成功但权限设置失败,检查应用是否有“添加或管理云文档协作者/权限”和电子表格管理权限。
+- 如果可以导入但不能发消息,检查机器人是否在目标群、应用是否已发布,以及消息发送权限。
+
+## API 流程
+
+1. `POST /auth/v3/tenant_access_token/internal`
+2. `POST /drive/v1/medias/upload_all`,使用 `parent_type=ccm_import_open`
+3. `POST /drive/v1/import_tasks`
+4. `GET /drive/v1/import_tasks/{ticket}` 轮询结果
+5. `PATCH /drive/v1/permissions/{token}/public?type=sheet`
+6. `POST /im/v1/messages?receive_id_type=chat_id`
+
+脚本不会打印 tenant access token、App Secret 或临时素材 token。
+
+## 常见错误
+
+- `群名匹配多个群聊`:改用 `--chat-id` 或 `FEISHU_TARGET_CHAT_ID`。
+- `机器人看不到目标群`:先把机器人加入群,再用 `--list-chats` 检查。
+- `导入任务超时`:稍后重试,并确认文件大小和扩展名符合飞书限制。
+- `设置企业内链接可读失败`:确认应用具备文档权限管理能力,且企业安全策略允许租户内链接分享。

+ 334 - 0
.agents/skills/feishu-data-publisher/scripts/feishu_client.py

@@ -0,0 +1,334 @@
+"""Small environment-driven Feishu client for publishing tabular files."""
+
+from __future__ import annotations
+
+import json
+import os
+import time
+from pathlib import Path
+from typing import Any
+
+import httpx
+
+
+SUPPORTED_EXTENSIONS = {"csv", "xls", "xlsx"}
+
+
+class FeishuApiError(RuntimeError):
+    """Raised when Feishu returns a failed response without leaking credentials."""
+
+
+class FeishuPublisher:
+    def __init__(
+        self,
+        app_id: str,
+        app_secret: str,
+        *,
+        base_url: str = "https://open.feishu.cn/open-apis",
+        timeout: float = 30.0,
+        client: httpx.Client | None = None,
+    ) -> None:
+        if not app_id.strip():
+            raise ValueError("FEISHU_APP_ID 未配置")
+        if not app_secret.strip():
+            raise ValueError("FEISHU_APP_SECRET 未配置")
+        self._app_id = app_id.strip()
+        self._app_secret = app_secret.strip()
+        self._base_url = base_url.rstrip("/")
+        self._owns_client = client is None
+        self._client = client or httpx.Client(timeout=timeout)
+
+    @classmethod
+    def from_env(cls, **kwargs: Any) -> "FeishuPublisher":
+        return cls(
+            os.getenv("FEISHU_APP_ID", ""),
+            os.getenv("FEISHU_APP_SECRET", ""),
+            base_url=os.getenv(
+                "FEISHU_BASE_URL", "https://open.feishu.cn/open-apis"
+            ),
+            **kwargs,
+        )
+
+    def close(self) -> None:
+        if self._owns_client:
+            self._client.close()
+
+    def __enter__(self) -> "FeishuPublisher":
+        return self
+
+    def __exit__(self, *_: object) -> None:
+        self.close()
+
+    @staticmethod
+    def _payload(response: httpx.Response, action: str) -> dict[str, Any]:
+        try:
+            response.raise_for_status()
+            payload = response.json()
+        except (httpx.HTTPError, ValueError) as exc:
+            raise FeishuApiError(f"{action}失败:HTTP 响应无效") from exc
+        if payload.get("code") != 0:
+            code = payload.get("code", "unknown")
+            message = payload.get("msg", "unknown error")
+            raise FeishuApiError(f"{action}失败:code={code}, msg={message}")
+        return payload
+
+    @staticmethod
+    def _headers(token: str) -> dict[str, str]:
+        return {"Authorization": f"Bearer {token}"}
+
+    def tenant_access_token(self) -> str:
+        response = self._client.post(
+            f"{self._base_url}/auth/v3/tenant_access_token/internal",
+            json={"app_id": self._app_id, "app_secret": self._app_secret},
+        )
+        return str(
+            self._payload(response, "获取 tenant access token")[
+                "tenant_access_token"
+            ]
+        )
+
+    def list_chats(self, token: str, name: str = "") -> list[dict[str, str]]:
+        matches: list[dict[str, str]] = []
+        page_token = ""
+        while True:
+            params: dict[str, str | int] = {"page_size": 100}
+            if page_token:
+                params["page_token"] = page_token
+            response = self._client.get(
+                f"{self._base_url}/im/v1/chats",
+                headers=self._headers(token),
+                params=params,
+            )
+            data = self._payload(response, "查询机器人可见群聊").get("data", {})
+            for item in data.get("items", []) or []:
+                chat_name = str(item.get("name") or "")
+                chat_id = str(item.get("chat_id") or "")
+                if chat_id and (not name or name in chat_name):
+                    matches.append({"name": chat_name, "chat_id": chat_id})
+            if not data.get("has_more"):
+                break
+            page_token = str(data.get("page_token") or "")
+            if not page_token:
+                break
+        return matches
+
+    def resolve_chat_id(self, token: str, chat_name: str) -> str:
+        exact = [
+            chat
+            for chat in self.list_chats(token, chat_name)
+            if chat["name"] == chat_name
+        ]
+        if not exact:
+            raise FeishuApiError(f"机器人可见群聊中未找到:{chat_name}")
+        if len(exact) > 1:
+            ids = ", ".join(chat["chat_id"] for chat in exact)
+            raise FeishuApiError(
+                f"群名匹配多个群聊:{chat_name};请改用 chat_id。候选:{ids}"
+            )
+        return exact[0]["chat_id"]
+
+    @staticmethod
+    def validate_file(file_path: Path) -> str:
+        if not file_path.is_file():
+            raise FileNotFoundError(f"结果文件不存在:{file_path}")
+        extension = file_path.suffix.lower().lstrip(".")
+        if extension not in SUPPORTED_EXTENSIONS:
+            allowed = ", ".join(sorted(SUPPORTED_EXTENSIONS))
+            raise ValueError(f"不支持的文件类型:.{extension};仅支持 {allowed}")
+        if file_path.stat().st_size == 0:
+            raise ValueError(f"结果文件为空:{file_path}")
+        return extension
+
+    def upload_import_source(
+        self, token: str, file_path: Path, extension: str
+    ) -> str:
+        extra = json.dumps(
+            {"obj_type": "sheet", "file_extension": extension},
+            ensure_ascii=False,
+        )
+        with file_path.open("rb") as handle:
+            response = self._client.post(
+                f"{self._base_url}/drive/v1/medias/upload_all",
+                headers=self._headers(token),
+                data={
+                    "file_name": file_path.name,
+                    "parent_type": "ccm_import_open",
+                    "size": str(file_path.stat().st_size),
+                    "extra": extra,
+                },
+                files={
+                    "file": (
+                        file_path.name,
+                        handle,
+                        "application/octet-stream",
+                    )
+                },
+            )
+        return str(
+            self._payload(response, "上传待导入文件")["data"]["file_token"]
+        )
+
+    def create_import_task(
+        self,
+        token: str,
+        file_token: str,
+        *,
+        extension: str,
+        title: str,
+    ) -> str:
+        response = self._client.post(
+            f"{self._base_url}/drive/v1/import_tasks",
+            headers={**self._headers(token), "Content-Type": "application/json"},
+            json={
+                "file_extension": extension,
+                "file_token": file_token,
+                "type": "sheet",
+                "file_name": title,
+                "point": {"mount_type": 1, "mount_key": ""},
+            },
+        )
+        return str(self._payload(response, "创建表格导入任务")["data"]["ticket"])
+
+    def wait_import_result(
+        self,
+        token: str,
+        ticket: str,
+        *,
+        max_wait: float = 90.0,
+        poll_interval: float = 2.0,
+    ) -> dict[str, Any]:
+        deadline = time.monotonic() + max_wait
+        while time.monotonic() < deadline:
+            response = self._client.get(
+                f"{self._base_url}/drive/v1/import_tasks/{ticket}",
+                headers=self._headers(token),
+            )
+            result = (
+                self._payload(response, "查询表格导入结果")
+                .get("data", {})
+                .get("result", {})
+            )
+            status = result.get("job_status")
+            if status == 0:
+                return result
+            if status == 3:
+                message = result.get("job_error_msg", "unknown error")
+                raise FeishuApiError(f"导入在线表格失败:{message}")
+            time.sleep(poll_interval)
+        raise TimeoutError(f"导入在线表格超过 {max_wait:g} 秒仍未完成")
+
+    def set_tenant_readable(
+        self, token: str, sheet_token: str, file_type: str = "sheet"
+    ) -> None:
+        response = self._client.patch(
+            f"{self._base_url}/drive/v2/permissions/{sheet_token}/public",
+            headers={**self._headers(token), "Content-Type": "application/json"},
+            params={"type": file_type},
+            json={
+                "external_access": False,
+                "link_share_entity": "tenant_readable",
+            },
+        )
+        self._payload(response, "设置企业内链接可读权限")
+
+    def send_sheet_card(
+        self,
+        token: str,
+        *,
+        chat_id: str,
+        title: str,
+        message: str,
+        url: str,
+    ) -> str:
+        card = {
+            "config": {"wide_screen_mode": True},
+            "header": {
+                "template": "blue",
+                "title": {"tag": "plain_text", "content": title},
+            },
+            "elements": [
+                {
+                    "tag": "div",
+                    "text": {"tag": "lark_md", "content": message},
+                },
+                {"tag": "hr"},
+                {
+                    "tag": "action",
+                    "actions": [
+                        {
+                            "tag": "button",
+                            "type": "primary",
+                            "text": {
+                                "tag": "plain_text",
+                                "content": "打开在线表格",
+                            },
+                            "url": url,
+                        }
+                    ],
+                },
+            ],
+        }
+        response = self._client.post(
+            f"{self._base_url}/im/v1/messages",
+            headers={**self._headers(token), "Content-Type": "application/json"},
+            params={"receive_id_type": "chat_id"},
+            json={
+                "receive_id": chat_id,
+                "msg_type": "interactive",
+                "content": json.dumps(card, ensure_ascii=False),
+            },
+        )
+        return str(self._payload(response, "发送飞书群卡片")["data"]["message_id"])
+
+    def publish(
+        self,
+        file_path: Path,
+        *,
+        title: str,
+        message: str,
+        chat_id: str = "",
+        chat_name: str = "",
+        notify: bool = True,
+    ) -> dict[str, Any]:
+        extension = self.validate_file(file_path)
+        token = self.tenant_access_token()
+        resolved_chat_id = chat_id.strip()
+        if notify and not resolved_chat_id:
+            if not chat_name.strip():
+                raise ValueError(
+                    "未配置目标群;请设置 FEISHU_TARGET_CHAT_ID、"
+                    "FEISHU_TARGET_CHAT_NAME 或命令行参数"
+                )
+            resolved_chat_id = self.resolve_chat_id(token, chat_name.strip())
+
+        file_token = self.upload_import_source(token, file_path, extension)
+        ticket = self.create_import_task(
+            token,
+            file_token,
+            extension=extension,
+            title=title,
+        )
+        imported = self.wait_import_result(token, ticket)
+        url = str(imported.get("url") or "")
+        sheet_token = str(imported.get("token") or "")
+        file_type = str(imported.get("type") or "sheet")
+        if not url or not sheet_token:
+            raise FeishuApiError("导入成功响应缺少在线表格 URL 或 token")
+
+        self.set_tenant_readable(token, sheet_token, file_type)
+        message_id = ""
+        if notify:
+            message_id = self.send_sheet_card(
+                token,
+                chat_id=resolved_chat_id,
+                title=title,
+                message=message,
+                url=url,
+            )
+        return {
+            "url": url,
+            "permission": "tenant_readable",
+            "message_sent": bool(message_id),
+            "chat_id": resolved_chat_id if notify else "",
+            "message_id": message_id,
+        }

+ 104 - 0
.agents/skills/feishu-data-publisher/scripts/publish_table.py

@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+"""Publish a CSV/XLS/XLSX file as a Feishu online spreadsheet."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+from pathlib import Path
+
+from feishu_client import FeishuPublisher
+
+
+def build_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(
+        description=(
+            "将 CSV/XLS/XLSX 导入飞书在线表格,设置企业内链接可读,"
+            "并可发送到群聊。"
+        )
+    )
+    parser.add_argument("file", nargs="?", help="待发布的数据文件")
+    parser.add_argument("--title", help="在线表格和消息卡片标题")
+    parser.add_argument("--message", default="数据查询结果已生成,请点击查看。")
+    target = parser.add_mutually_exclusive_group()
+    target.add_argument("--chat-id", help="目标飞书群 chat_id")
+    target.add_argument("--chat-name", help="机器人可见群中的精确群名")
+    parser.add_argument("--no-notify", action="store_true", help="只创建表格,不发群消息")
+    parser.add_argument("--dry-run", action="store_true", help="仅校验本地输入,不调用飞书 API")
+    parser.add_argument("--list-chats", action="store_true", help="只读列出机器人可见群聊")
+    parser.add_argument("--name", default="", help="配合 --list-chats 按名称模糊过滤")
+    return parser
+
+
+def print_json(value: object) -> None:
+    print(json.dumps(value, ensure_ascii=False, indent=2))
+
+
+def main() -> int:
+    args = build_parser().parse_args()
+    try:
+        if args.list_chats:
+            with FeishuPublisher.from_env() as publisher:
+                token = publisher.tenant_access_token()
+                print_json(publisher.list_chats(token, args.name))
+            return 0
+
+        if not args.file:
+            raise ValueError("必须提供结果文件,或使用 --list-chats")
+        file_path = Path(args.file).expanduser().resolve()
+        extension = FeishuPublisher.validate_file(file_path)
+        title = (args.title or file_path.stem).strip()
+        if not title:
+            raise ValueError("表格标题不能为空")
+
+        chat_id = (
+            args.chat_id
+            if args.chat_id is not None
+            else os.getenv("FEISHU_TARGET_CHAT_ID", "")
+        ).strip()
+        chat_name = (
+            args.chat_name
+            if args.chat_name is not None
+            else os.getenv("FEISHU_TARGET_CHAT_NAME", "")
+        ).strip()
+        notify = not args.no_notify
+        if notify and not chat_id and not chat_name:
+            raise ValueError(
+                "未配置目标群;请使用 --chat-id/--chat-name 或设置 "
+                "FEISHU_TARGET_CHAT_ID/FEISHU_TARGET_CHAT_NAME"
+            )
+
+        if args.dry_run:
+            print_json(
+                {
+                    "dry_run": True,
+                    "file": str(file_path),
+                    "file_extension": extension,
+                    "title": title,
+                    "permission": "tenant_readable",
+                    "notify": notify,
+                    "target": chat_id or chat_name,
+                }
+            )
+            return 0
+
+        with FeishuPublisher.from_env() as publisher:
+            result = publisher.publish(
+                file_path,
+                title=title,
+                message=args.message,
+                chat_id=chat_id,
+                chat_name=chat_name,
+                notify=notify,
+            )
+        print_json(result)
+        return 0
+    except Exception as exc:
+        print(f"发布失败:{exc}", file=sys.stderr)
+        return 1
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 49 - 0
.agents/skills/odps-ad-risk-analysis/SKILL.md

@@ -0,0 +1,49 @@
+---
+name: odps-ad-risk-analysis
+description: 查询和维护 ODPS 广告风险用户策略,覆盖广告播放页截图、落地页截图、广告曝光后切后台、落地页隐藏且无打开转化、动态黑名单分层、DAU 交集,以及按 mid 或 uid 定向诊断。用于生成、执行、审查或调整 loghubods 广告风控 SQL。
+---
+
+# ODPS 广告风险分析
+
+将 `SKILL_DIR` 解析为当前 `SKILL.md` 所在的安装目录。所有资源都相对该目录解析;不得假定当前工作目录,更不得引用作者机器上的项目路径。
+
+## 运行环境
+
+需要执行查询时,使用随 Skill 分发的 `scripts/run_sql.py`。它只读取以下环境变量:
+
+- `ODPS_ACCESS_ID`
+- `ODPS_ACCESS_SECRET`
+- `ODPS_PROJECT`
+- `ODPS_ENDPOINT`
+
+依赖 `pyodps` 和 `pandas`。不得把凭证复制到 SQL、脚本、Skill 文件或回复中。只输出 ODPS Instance ID,不输出 LogView URL 或 Token。
+
+## 工作流程
+
+1. 阅读 [risk-strategies.md](references/risk-strategies.md) 和 [sql-templates.md](references/sql-templates.md) 中对应的完整模板。
+2. 确认风险场景,以及用户需要用户清单、UV、事件对、DAU 交集、黑名单层级还是共享表更新。
+3. 在用户指定的输出目录生成 SQL。除非明确要求实时数据,否则默认使用离线表。
+4. 保留场景定义中的事件键、时间窗口、事件粒度和输出字段。
+5. 指定 `mid` 或 `uid` 时,先执行窄范围命中查询,不向终端输出大批用户清单。
+6. 需要执行时运行:
+
+   `python3 "$SKILL_DIR/scripts/run_sql.py" <query.sql> <result.csv>`
+
+7. 输出前校验事件粒度、日期覆盖、行数和黑名单层级行为。
+8. 仅当用户明确要求上传飞书时,使用 `$feishu-data-publisher` 发布最终 CSV 或 Excel;未明确要求时只保留本地结果。
+
+## 不可变规则
+
+- `mid` 对应 `machinecode`。
+- 历史风险清单默认使用 `20260401` 至 `${bizdate}`,除非用户确认其他时间段。
+- 保留最新事件的原始 `loginuid`;最新值为空时不得用旧事件补齐。
+- 共享表中的 3 次及以上永久黑名单必须保留;只动态替换当前 1/2 层级。
+- 只按文档规定的事件对粒度计数,不得擅自按 session 去重。
+- 只有用户明确要求宽松实时检查时才能使用 `simpleevent_log_flow`,并说明短保留周期限制。
+
+## 资源
+
+- [risk-strategies.md](references/risk-strategies.md):策略定义、键、字段、类型、表和输出规则。
+- [sql-templates.md](references/sql-templates.md):标准完整 SQL 模板及诊断变体。
+- `scripts/odps_module.py`:基于环境变量的 ODPS 连接。
+- `scripts/run_sql.py`:可移植的 SQL 转 CSV 执行器。

+ 4 - 0
.agents/skills/odps-ad-risk-analysis/agents/openai.yaml

@@ -0,0 +1,4 @@
+interface:
+  display_name: "ODPS 广告风控查询"
+  short_description: "广告风险用户、黑名单分层与 ODPS SQL 模板"
+  default_prompt: "使用 $odps-ad-risk-analysis 统计广告风险用户、分层黑名单或生成可运行的 ODPS SQL。"

+ 57 - 0
.agents/skills/odps-ad-risk-analysis/references/risk-strategies.md

@@ -0,0 +1,57 @@
+# Risk Strategy Catalog
+
+## Shared identifiers and outputs
+
+- `mid`: `machinecode`.
+- `uid`: the scenario's raw `loginuid`; a null latest value stays null.
+- Standard user output: ``type, mid, uid, risk_level``; `risk_level=0`.
+- Historical start: `20260401`; end: `${bizdate}`.
+- Use offline tables unless the user explicitly requests real-time tables.
+
+## S1: Advertising-playback screenshot
+
+- Event chain: `ad_action_log_own.adView` -> `simpleevent_log.userCaptureScreen`.
+- Key: same `event_dt + mid + subsessionid`; capture timestamp is at or after adView.
+- Exclusion: no `adCloseBtnTap` between adView and capture.
+- User type: `adPlay_capture`.
+- No blacklist tier: one deduplicated `mid` row.
+
+## S2: Advertising landing-page screenshot
+
+- Event: `ad_action_log_own.adUserCaptureScreen`.
+- User type: `adlanding_capture`.
+- No additional landing view or open-conversion condition unless requested.
+- No blacklist tier: one deduplicated `mid` row.
+
+## S3: Self-owned ad backgrounding then return
+
+- Event chain: own-platform `adView` -> within 10 seconds `userActiveEnd` on `pages/swiper/index` -> same-session return to `pages/swiper/index`.
+- Key before background: same `event_dt + mid + sessionid + subsessionid`.
+- Exclusions: no `adCloseBtnTap`, no homepage `pageView` whose `pagesource` ends in `category_55`, both between adView and active end.
+- Count grain: each distinct `mid, sessionid, subsessionid, adview_ts, active_end_ts` pair is one risk event.
+- Types: `adview10s_hide_return_risk_1_forbidden_3`, `adview10s_hide_return_risk_2_forbidden_30`, `adview10s_hide_return_risk_3_forbidden_forever`.
+
+## S4: Self-owned ad landing hide, no open conversion, then return
+
+- Event chain: own-platform `adSelfLandingView` -> within 10 seconds `adSelfLandingHide` -> same-session return to one of the four landing paths.
+- View/hide key: same `event_dt + mid + sessionid + subsessionid + pqtid`.
+- Open-conversion exclusion: `pqtid` must not exist in `ad_own_open_conv`.
+- Return paths: `pages/ad-self-landing/index`, `pages/marketing-landing/index`, `pages/promo-page/index`, `pages/event-landing/index`.
+- Count grain: each distinct `mid, sessionid, subsessionid, pqtid, view_ts, hide_ts` pair is one risk event.
+- Types: `adlanding10s_hide_no_open_return_risk_1_forbidden_3`, `adlanding10s_hide_no_open_return_risk_2_forbidden_30`, `adlanding10s_hide_no_open_return_risk_3_forbidden_forever`.
+
+## Dynamic blacklist tiers
+
+For S3 and S4, tiers are mutually exclusive.
+
+- 1 event: blacklist only when `last_event_dt` is from `${bizdate}-2 days` through `${bizdate}`.
+- 2 events: blacklist only when `last_event_dt` is from `${bizdate}-29 days` through `${bizdate}`.
+- 3+ events: permanent blacklist.
+- Compute dynamic starts with `TO_CHAR(DATEADD(TO_DATE('${bizdate}', 'yyyymmdd'), -N, 'dd'), 'yyyymmdd')`.
+- When writing to a shared target table, retain historical permanent rows, replace current 1/2 rows, and preserve unrelated types.
+
+## DAU and real-time diagnostics
+
+- Confirmed 0709 Path DAU: `useractive_log`, `dt='20260709'`, `businesstype='path'`, distinct `machinecode`.
+- `simpleevent_log_flow` has fields `year, month, day, hour` and no `endRoutePath`.
+- Real-time checks using the flow table must explicitly say that removing `endRoutePath` creates a relaxed metric and only covers the available short retention window.

+ 87 - 0
.agents/skills/odps-ad-risk-analysis/references/sql-templates.md

@@ -0,0 +1,87 @@
+# Canonical SQL Templates
+
+Use `${bizdate}` as `yyyyMMdd`. Do not mix these templates' CTEs across scenarios.
+
+## S1: Playback screenshot user list
+
+```sql
+WITH adview_events AS (
+  SELECT dt event_dt, machinecode mid, subsessionid, CAST(clienttimestamp AS BIGINT) adview_ts
+  FROM loghubods.ad_action_log_own
+  WHERE dt BETWEEN '20260401' AND '${bizdate}' AND businesstype='adView'
+    AND machinecode IS NOT NULL AND machinecode<>'' AND subsessionid IS NOT NULL AND subsessionid<>'' AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+), capture_events AS (
+  SELECT dt event_dt, machinecode mid, loginuid uid, subsessionid, CAST(clienttimestamp AS BIGINT) capture_ts
+  FROM loghubods.simpleevent_log
+  WHERE dt BETWEEN '20260401' AND '${bizdate}' AND businesstype='userCaptureScreen'
+    AND machinecode IS NOT NULL AND machinecode<>'' AND subsessionid IS NOT NULL AND subsessionid<>'' AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+), close_events AS (
+  SELECT dt event_dt, machinecode mid, subsessionid, CAST(clienttimestamp AS BIGINT) close_ts
+  FROM loghubods.ad_action_log_own
+  WHERE dt BETWEEN '20260401' AND '${bizdate}' AND businesstype='adCloseBtnTap'
+    AND machinecode IS NOT NULL AND machinecode<>'' AND subsessionid IS NOT NULL AND subsessionid<>'' AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+), matched AS (
+  SELECT DISTINCT a.mid,c.uid,c.capture_ts
+  FROM adview_events a JOIN capture_events c ON a.event_dt=c.event_dt AND a.mid=c.mid AND a.subsessionid=c.subsessionid AND c.capture_ts>=a.adview_ts
+  WHERE NOT EXISTS (SELECT 1 FROM close_events x WHERE x.event_dt=a.event_dt AND x.mid=a.mid AND x.subsessionid=a.subsessionid AND x.close_ts>a.adview_ts AND x.close_ts<=c.capture_ts)
+), latest_uid AS (
+  SELECT mid,uid FROM (SELECT mid,uid,ROW_NUMBER() OVER(PARTITION BY mid ORDER BY capture_ts DESC) rn FROM matched) t WHERE rn=1
+)
+SELECT 'adPlay_capture' AS `type`, mid, uid, 0 AS risk_level FROM latest_uid;
+```
+
+For the count, keep all CTEs and replace the final select with `SELECT COUNT(*) FROM latest_uid`.
+
+## S2: Landing screenshot user list
+
+```sql
+WITH landing_capture_events AS (
+  SELECT machinecode mid, loginuid uid, CAST(clienttimestamp AS BIGINT) capture_ts
+  FROM loghubods.ad_action_log_own
+  WHERE dt BETWEEN '20260401' AND '${bizdate}' AND businesstype='adUserCaptureScreen'
+    AND machinecode IS NOT NULL AND machinecode<>'' AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+), latest_uid AS (
+  SELECT mid,uid FROM (SELECT mid,uid,ROW_NUMBER() OVER(PARTITION BY mid ORDER BY capture_ts DESC) rn FROM landing_capture_events) t WHERE rn=1
+)
+SELECT 'adlanding_capture' AS `type`, mid, uid, 0 AS risk_level FROM latest_uid;
+```
+
+## S3/S4: Dynamic-tier output contract
+
+Build `risk_events(mid, uid, event_dt, event_ts, ...)` with the scenario-specific keys documented in `risk-strategies.md`, then use this exact suffix:
+
+```sql
+, user_risk AS (
+  SELECT mid, COUNT(*) behavior_cnt, MAX(event_dt) last_event_dt FROM risk_events GROUP BY mid
+), latest_uid AS (
+  SELECT mid,uid FROM (
+    SELECT mid,uid,ROW_NUMBER() OVER(PARTITION BY mid ORDER BY event_ts DESC) rn FROM risk_events
+  ) t WHERE rn=1
+), current_risk_users AS (
+  SELECT r.mid,u.uid,
+    CASE
+      WHEN r.behavior_cnt>=3 THEN '${type_3}'
+      WHEN r.behavior_cnt=2 AND r.last_event_dt BETWEEN TO_CHAR(DATEADD(TO_DATE('${bizdate}','yyyymmdd'),-29,'dd'),'yyyymmdd') AND '${bizdate}' THEN '${type_2}'
+      WHEN r.behavior_cnt=1 AND r.last_event_dt BETWEEN TO_CHAR(DATEADD(TO_DATE('${bizdate}','yyyymmdd'),-2,'dd'),'yyyymmdd') AND '${bizdate}' THEN '${type_1}'
+    END AS risk_type
+  FROM user_risk r LEFT JOIN latest_uid u ON r.mid=u.mid
+)
+SELECT risk_type AS `type`,mid,uid,0 AS risk_level
+FROM current_risk_users WHERE risk_type IS NOT NULL;
+```
+
+### S3 risk_events builder
+
+Use `ad_action_log_own.adView` with `ownAdSystemType='ownPlatform'`; join `simpleevent_log.userActiveEnd` on same day, mid, sessionid, and subsessionid; require `0 <= active_end_ts-adview_ts <= 10000`; exclude close and homepage pageview in that interval; require later same-session `useractive_log.path='pages/swiper/index'`. Set `event_ts=active_end_ts`.
+
+Types: `${type_1}=adview10s_hide_return_risk_1_forbidden_3`, `${type_2}=adview10s_hide_return_risk_2_forbidden_30`, `${type_3}=adview10s_hide_return_risk_3_forbidden_forever`.
+
+### S4 risk_events builder
+
+Use own-platform `adSelfLandingView` and `adSelfLandingHide` on same day, mid, sessionid, subsessionid, pqtid; require `0 <= hide_ts-view_ts <= 10000`; exclude pqtid present in `ad_own_open_conv`; require later same-session `useractive_log.path` in the four documented landing paths. Set `event_ts=hide_ts`.
+
+Types: `${type_1}=adlanding10s_hide_no_open_return_risk_1_forbidden_3`, `${type_2}=adlanding10s_hide_no_open_return_risk_2_forbidden_30`, `${type_3}=adlanding10s_hide_no_open_return_risk_3_forbidden_forever`.
+
+## Shared-table write behavior
+
+For a target table with `type, mid, uid, risk_level`, use `INSERT OVERWRITE` only after unioning: unrelated existing types, existing permanent type-3 users, newly computed type-3 users, and current dynamic type-1/type-2 users excluding permanent mids. Do not retain expired type-1/type-2 rows.

+ 61 - 0
.agents/skills/odps-ad-risk-analysis/scripts/odps_module.py

@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+"""Minimal PyODPS client configured only through environment variables."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+from odps import ODPS, options
+
+
+ENV_ACCESS_ID = "ODPS_ACCESS_ID"
+ENV_ACCESS_SECRET = "ODPS_ACCESS_SECRET"
+ENV_PROJECT = "ODPS_PROJECT"
+ENV_ENDPOINT = "ODPS_ENDPOINT"
+
+
+def require_env(name: str) -> str:
+    value = os.getenv(name)
+    if not value:
+        raise RuntimeError(f"Missing required environment variable: {name}")
+    return value
+
+
+class ODPSClient:
+    def __init__(self, project: str | None = None, endpoint: str | None = None):
+        access_id = require_env(ENV_ACCESS_ID)
+        access_secret = require_env(ENV_ACCESS_SECRET)
+        project_name = project or require_env(ENV_PROJECT)
+        endpoint_url = endpoint or require_env(ENV_ENDPOINT)
+
+        options.connect_timeout = 60
+        options.read_timeout = 1200
+        options.retry_times = 3
+
+        self.odps = ODPS(
+            access_id,
+            access_secret,
+            project=project_name,
+            endpoint=endpoint_url,
+        )
+
+    def execute_sql(self, sql: str):
+        if not sql.strip():
+            raise ValueError("SQL must not be empty")
+
+        instance = self.odps.run_sql(
+            sql,
+            hints={"odps.sql.submit.mode": "script"},
+        )
+        print(f"[ODPS] InstanceId: {instance.id}", flush=True)
+        instance.wait_for_success()
+        with instance.open_reader(tunnel=True) as reader:
+            return reader.to_pandas()
+
+    def execute_sql_result_save_file(self, sql: str, output_file: str | Path):
+        output_path = Path(output_file).expanduser()
+        output_path.parent.mkdir(parents=True, exist_ok=True)
+        data = self.execute_sql(sql)
+        data.to_csv(output_path, index=False, encoding="utf-8-sig")
+        return output_path

+ 28 - 0
.agents/skills/odps-ad-risk-analysis/scripts/run_sql.py

@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+"""Execute an explicit SQL file with the bundled environment-based ODPS client."""
+
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+from odps_module import ODPSClient
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Run an ODPS SQL file and save UTF-8 CSV")
+    parser.add_argument("sql_file", type=Path)
+    parser.add_argument("output_file", type=Path)
+    parser.add_argument("--project", help="Override ODPS_PROJECT for this query")
+    args = parser.parse_args()
+
+    sql = args.sql_file.expanduser().read_text(encoding="utf-8")
+    output_path = ODPSClient(project=args.project).execute_sql_result_save_file(
+        sql,
+        args.output_file,
+    )
+    print(f"[CSV] {output_path.resolve()}", flush=True)
+
+
+if __name__ == "__main__":
+    main()

+ 53 - 0
.agents/skills/odps-growth-fission-report/SKILL.md

@@ -0,0 +1,53 @@
+---
+name: odps-growth-fission-report
+description: 生成、执行、校验并解释参数化 ODPS 首层增长和 T0 裂变实验报表,包含首层 UV、曝光、播放、分享、裂变 UV、STR、T0 裂变率和单位曝光裂变 UV,并按头部、推荐、全部流量拆分。用于产品类型、离线或实时模式、日期、分桶位置、实验尾号、版本、首层规则或企微排除条件变化的查询。
+---
+
+# ODPS 增长裂变报表
+
+将 `SKILL_DIR` 解析为当前 `SKILL.md` 所在的安装目录。所有脚本和参考资料都相对该目录解析;不得假定当前工作目录或作者仓库。
+
+## 运行环境
+
+依赖 `pyodps` 和 `pandas`。随 Skill 分发的 ODPS 客户端读取 `ODPS_ACCESS_ID`、`ODPS_ACCESS_SECRET`、`ODPS_PROJECT` 和 `ODPS_ENDPOINT`。不得把这些值写入请求 JSON、SQL、输出文件或回复。
+
+## 工作流程
+
+1. 阅读 [metrics.md](references/metrics.md) 和 [raw-output-contract.md](references/raw-output-contract.md)。
+2. 在用户指定输出目录把参数写入 `request.json`。
+3. 规范化并校验参数:
+
+   `python3 "$SKILL_DIR/scripts/normalize_request.py" request.json > normalized.json`
+
+4. 明确回显日期、首层规则、模式、数据表、分桶位置和分组、版本策略及企微处理方式。
+5. 生成一个符合原始事实契约的参数化 SQL。不得搜索或依赖外部 `AGENTS.md`、特定日期 SQL 或本地 runner。
+6. 把所有参数一致应用到首层身份、视频行为、源分享和 click 归因,SQL 必须为每天生成完整 16 个桶。
+7. 预检人群口径、同日身份关联、来源归因以及版本和渠道过滤。
+8. 只提交一个 ODPS 实例:
+
+   `python3 "$SKILL_DIR/scripts/run_sql.py" query.sql raw_facts.csv`
+
+9. 生成标准完整报表和聚合报表:
+
+   `python3 "$SKILL_DIR/scripts/format_report.py" normalized.json raw_facts.csv full_report.csv --aggregate-output aggregate_report.csv`
+
+10. 执行 [metrics.md](references/metrics.md) 中的全部校验,并分别解释头部、推荐和全部流量。
+11. 仅当用户明确要求上传飞书时,使用 `$feishu-data-publisher` 发布最终 CSV 或 Excel;未明确要求时只保留本地结果。
+
+## 参数规则
+
+- 必须提供 `app_type`、`date_from`、`date_to`、`data_mode`、`bucket_position_from_end`、`experiment_buckets` 和 `version`。
+- 未明确给出对照桶时,自动使用 `0-f` 中实验桶的补集。
+- `first_layer_rule` 默认 `special-layer-compatible`;只有用户明确要求旧口径时才使用 `depth-zero`。
+- `version: "all"` 表示不限制版本;指定版本时限制首层用户、其视频行为和源分享,不限制回流 click 用户。
+- 实时模式只支持一个自然日;已完成的历史日期使用离线数据源。
+- `exclude_qywx` 默认 false;为 true 时必须贯穿符合条件的来源链路。
+- 裂变只归因于首层用户同日发出的源分享及相同 root session 带回的同日 click 用户。
+- 对照组只有一个桶时,必须说明单桶波动风险。
+
+## 资源
+
+- `scripts/normalize_request.py`:参数规范化与校验。
+- `scripts/odps_module.py`:基于环境变量的 ODPS 连接。
+- `scripts/run_sql.py`:可移植的 SQL 转 CSV 执行器。
+- `scripts/format_report.py`:确定性的 65 列格式器及聚合输出器。

+ 4 - 0
.agents/skills/odps-growth-fission-report/agents/openai.yaml

@@ -0,0 +1,4 @@
+interface:
+  display_name: "增长裂变实验报表"
+  short_description: "按产品日期数据源分桶位置实验尾号和版本生成首层增长裂变实验报表"
+  default_prompt: "使用 $odps-growth-fission-report 查询指定产品、日期、分桶和版本的增长裂变数据。"

+ 55 - 0
.agents/skills/odps-growth-fission-report/references/metrics.md

@@ -0,0 +1,55 @@
+# Growth-fission metric contract
+
+## Source selection
+
+| Mode | First-layer identity | Video actions | Source share and click |
+|---|---|---|---|
+| offline | `loghubods.useractive_log` | `loghubods.video_action_log_applet` | `loghubods.user_share_log` |
+| realtime | `loghubods.useractive_log_per5min` | `loghubods.video_action_log_flow` | `loghubods.user_share_log_per5min` |
+
+## Population and attribution
+
+- Start from `businesstype='path'` useractive records.
+- `special-layer-compatible`: `(userShareDepth='0' AND COALESCE(isSpecialLayer,'0')='0') OR (userShareDepth='1' AND isSpecialLayer='1')`.
+- `depth-zero`: `userShareDepth='0'`.
+- First-layer UV: distinct eligible `machinecode` per date and tail.
+- Join eligible video events by date, `mid=machinecode`, and exact `rootSessionId`.
+- Source shares must be made by the same first-layer `machinecode + rootSessionId` on the same date.
+- Fission layer UV: distinct same-day click `machinecode` returned by those source-share root sessions.
+- Returned click users do not need a version or layer field unless explicitly requested.
+
+## Bucket and source rules
+
+- For position `n`, bucket with `LOWER(SUBSTR(rootSessionId, LENGTH(rootSessionId) - n + 1, 1))`; keep only `0-f`.
+- Head: `pagesource RLIKE 'user-videos-share$'`.
+- Recommendation: `pagesource RLIKE '(detail|category|recommend)$'`.
+- All: no pagesource restriction.
+- Source-split fission is attributed by the source share's pagesource.
+
+## Facts and rates
+
+- Facts: first-layer UV; exposure/play/share PV and UV; fission-layer UV.
+- Exposure/play/share use `videoView`, `videoPlay`, and `videoShareFriend`.
+- Exposure, play, share per first-layer user: respective PV / first-layer UV.
+- STR: share PV / exposure PV.
+- T0 fission rate: fission-layer UV / first-layer UV.
+- Fission yield: fission-layer UV / exposure PV.
+- Relative change: experiment aggregate rate / control aggregate rate - 1.
+- First-layer UV relative change uses experiment per-tail mean versus control per-tail mean.
+
+## Version and channel policy
+
+- Specific version: restrict first-layer useractive, eligible video, and source-share rows. Do not restrict returned click rows.
+- All versions: omit version filters and label output `全部`.
+- `exclude_qywx=true`: exclude records with `rootSourceId/rootsourceid` starting `dyyqw` at each source stage.
+- `exclude_qywx=false`: do not add channel exclusions.
+
+## Output validation
+
+- Retain the established Chinese 65-column order for head, recommendation, and all.
+- Every date contains 16 detail tails, two aggregates, and two per-tail means.
+- Facts are integers; aggregates equal member-tail sums.
+- Use actual experiment/control tail counts in means and first-layer UV comparison.
+- Sort dates descending and tails ascending.
+- Cross-check a T0-only result, when generated, against full-report first-layer UV and all fission UV tail by tail.
+- Call out first-layer rule, channel exclusion, and version policy in the final response; these materially change the population.

+ 37 - 0
.agents/skills/odps-growth-fission-report/references/raw-output-contract.md

@@ -0,0 +1,37 @@
+# Growth-fission raw output contract
+
+Generate one row per `stat_date + bucket`. Every date must contain exactly the 16 lowercase buckets `0-f`; create a bucket spine and left join zero facts when necessary.
+
+## Identity columns
+
+- `stat_date`: `yyyyMMdd`
+- `app_type`: requested product/app type
+- `version_code`: requested version or the literal `all`
+- `bucket`: lowercase `0-f`
+- `first_layer_uv`: distinct eligible first-layer `machinecode`
+
+## Source fact columns
+
+For each prefix `head`, `recommend`, and `all`, emit:
+
+- `<prefix>_exposure_pv`
+- `<prefix>_exposure_uv`
+- `<prefix>_play_pv`
+- `<prefix>_play_uv`
+- `<prefix>_share_pv`
+- `<prefix>_share_uv`
+- `<prefix>_fission_uv`
+
+The complete raw result therefore has 26 columns and 16 rows per date.
+
+## SQL construction rules
+
+1. Select offline or realtime tables from normalized parameters.
+2. Build first-layer identity from `businesstype='path'` using the normalized first-layer rule.
+3. Normalize `rootSessionId`, derive the requested bucket, and keep the first-layer identity at date + machinecode + root session grain.
+4. Join video behavior to first-layer identity by same date, `mid=machinecode`, and exact root session.
+5. Attribute fission from same-day source shares by first-layer users to same-day click users returned by the same root session.
+6. Attribute head/recommendation fission using the source share's `pagesource`; globally deduplicate all-source fission users per date and bucket.
+7. Apply the requested app type, dates, version policy, and enterprise-WeChat exclusion throughout eligible source stages. Do not restrict returned click users by version.
+8. Join all facts to a `0-f` bucket spine and `COALESCE` missing counts to zero.
+9. Order by date descending and bucket ascending.

+ 264 - 0
.agents/skills/odps-growth-fission-report/scripts/format_report.py

@@ -0,0 +1,264 @@
+#!/usr/bin/env python3
+"""Build the standard 65-column growth-fission report from raw ODPS facts."""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+
+import pandas as pd
+
+
+HEX = "0123456789abcdef"
+SOURCES = ["头部", "推荐", "全部"]
+SOURCE_KEYS = {"头部": "head", "推荐": "recommend", "全部": "all"}
+FACT_SUFFIXES = ["曝光PV", "曝光UV", "播放PV", "播放UV", "分享PV", "分享UV", "裂变层UV"]
+FACT_COLUMNS = ["首层UV", *[f"{source}{suffix}" for source in SOURCES for suffix in FACT_SUFFIXES]]
+RATE_SUFFIXES = [
+    "曝光PV/首层UV",
+    "播放PV/首层UV",
+    "分享PV/首层UV",
+    "STR(分享PV/曝光PV)",
+    "T0裂变率(裂变层UV/首层UV)",
+    "裂变层UV/曝光PV",
+]
+
+
+def column_mapping() -> dict[str, str]:
+    mapping = {
+        "stat_date": "日期",
+        "app_type": "产品类型",
+        "version_code": "版本号",
+        "bucket": "尾号",
+        "first_layer_uv": "首层UV",
+    }
+    fields = {
+        "exposure_pv": "曝光PV",
+        "exposure_uv": "曝光UV",
+        "play_pv": "播放PV",
+        "play_uv": "播放UV",
+        "share_pv": "分享PV",
+        "share_uv": "分享UV",
+        "fission_uv": "裂变层UV",
+    }
+    for source, key in SOURCE_KEYS.items():
+        for field, label in fields.items():
+            mapping[f"{key}_{field}"] = f"{source}{label}"
+    return mapping
+
+
+def compact_buckets(buckets: list[str]) -> str:
+    indexes = sorted(HEX.index(bucket) for bucket in buckets)
+    groups: list[str] = []
+    start = previous = indexes[0]
+    for current in indexes[1:] + [None]:
+        if current is not None and current == previous + 1:
+            previous = current
+            continue
+        groups.append(HEX[start] if start == previous else f"{HEX[start]}-{HEX[previous]}")
+        if current is not None:
+            start = previous = current
+    return ",".join(groups)
+
+
+def format_percent(value) -> str:
+    return "" if pd.isna(value) else f"{value * 100:.4f}%"
+
+
+def add_rates(data: pd.DataFrame) -> pd.DataFrame:
+    result = data.copy()
+    first_layer_uv = result["首层UV"].replace(0, pd.NA)
+    for source in SOURCES:
+        exposure = result[f"{source}曝光PV"].replace(0, pd.NA)
+        result[f"{source}曝光PV/首层UV"] = result[f"{source}曝光PV"] / first_layer_uv
+        result[f"{source}播放PV/首层UV"] = result[f"{source}播放PV"] / first_layer_uv
+        result[f"{source}分享PV/首层UV"] = result[f"{source}分享PV"] / first_layer_uv
+        result[f"{source}STR(分享PV/曝光PV)"] = result[f"{source}分享PV"] / exposure
+        result[f"{source}T0裂变率(裂变层UV/首层UV)"] = result[f"{source}裂变层UV"] / first_layer_uv
+        result[f"{source}裂变层UV/曝光PV"] = result[f"{source}裂变层UV"] / exposure
+    return result
+
+
+def add_relative_changes(
+    data: pd.DataFrame,
+    experiment_label: str,
+    control_label: str,
+    experiment_bucket_count: int,
+    control_bucket_count: int,
+) -> pd.DataFrame:
+    result = data.copy()
+    result["首层UV相对对照组变化率"] = ""
+    for stat_date in result["日期"].unique():
+        daily = result["日期"] == stat_date
+        exp = result.index[daily & (result["行类型"] == "分组聚合") & (result["分组"] == experiment_label)]
+        ctrl = result.index[daily & (result["行类型"] == "分组聚合") & (result["分组"] == control_label)]
+        if len(exp) == len(ctrl) == 1:
+            exp_mean = result.loc[exp[0], "首层UV"] / experiment_bucket_count
+            ctrl_mean = result.loc[ctrl[0], "首层UV"] / control_bucket_count
+            if ctrl_mean:
+                change = format_percent(exp_mean / ctrl_mean - 1)
+                mask = daily & (result["分组"] == experiment_label) & result["行类型"].isin(["分组聚合", "每桶均值"])
+                result.loc[mask, "首层UV相对对照组变化率"] = change
+
+        for source in SOURCES:
+            for suffix in RATE_SUFFIXES:
+                metric = f"{source}{suffix}"
+                change_column = f"{metric}相对对照组变化率"
+                if change_column not in result:
+                    result[change_column] = ""
+                for row_type in ["分组聚合", "每桶均值"]:
+                    exp_row = result.index[daily & (result["行类型"] == row_type) & (result["分组"] == experiment_label)]
+                    ctrl_row = result.index[daily & (result["行类型"] == row_type) & (result["分组"] == control_label)]
+                    if len(exp_row) == len(ctrl_row) == 1:
+                        control = result.loc[ctrl_row[0], metric]
+                        if pd.notna(control) and control != 0:
+                            result.loc[exp_row[0], change_column] = format_percent(
+                                result.loc[exp_row[0], metric] / control - 1
+                            )
+    return result
+
+
+def format_output(data: pd.DataFrame) -> pd.DataFrame:
+    result = data.copy()
+    for source in SOURCES:
+        for suffix in ["曝光PV/首层UV", "播放PV/首层UV", "分享PV/首层UV"]:
+            result[f"{source}{suffix}"] = result[f"{source}{suffix}"].round(4)
+        for suffix in ["STR(分享PV/曝光PV)", "T0裂变率(裂变层UV/首层UV)", "裂变层UV/曝光PV"]:
+            result[f"{source}{suffix}"] = result[f"{source}{suffix}"].map(format_percent)
+    result[FACT_COLUMNS] = result[FACT_COLUMNS].round(0).astype("Int64")
+
+    ordered = ["日期", "产品类型", "版本号", "行类型", "分组", "尾号", "首层UV", "首层UV相对对照组变化率"]
+    for source in SOURCES:
+        ordered.extend(
+            [
+                f"{source}曝光PV",
+                f"{source}曝光UV",
+                f"{source}曝光PV/首层UV",
+                f"{source}曝光PV/首层UV相对对照组变化率",
+                f"{source}播放PV",
+                f"{source}播放UV",
+                f"{source}播放PV/首层UV",
+                f"{source}播放PV/首层UV相对对照组变化率",
+                f"{source}分享PV",
+                f"{source}分享UV",
+                f"{source}分享PV/首层UV",
+                f"{source}分享PV/首层UV相对对照组变化率",
+                f"{source}STR(分享PV/曝光PV)",
+                f"{source}STR(分享PV/曝光PV)相对对照组变化率",
+                f"{source}裂变层UV",
+                f"{source}T0裂变率(裂变层UV/首层UV)",
+                f"{source}T0裂变率(裂变层UV/首层UV)相对对照组变化率",
+                f"{source}裂变层UV/曝光PV",
+                f"{source}裂变层UV/曝光PV相对对照组变化率",
+            ]
+        )
+    return result[ordered]
+
+
+def validate_raw(data: pd.DataFrame) -> None:
+    expected = set(column_mapping())
+    missing = sorted(expected - set(data.columns))
+    if missing:
+        raise ValueError(f"raw facts are missing columns: {missing}")
+
+    duplicates = data.duplicated(["stat_date", "bucket"], keep=False)
+    if duplicates.any():
+        raise ValueError("raw facts contain duplicate date/bucket rows")
+
+    for stat_date, daily in data.groupby("stat_date"):
+        buckets = set(daily["bucket"].astype(str).str.lower())
+        if buckets != set(HEX):
+            raise ValueError(f"{stat_date} must contain exactly buckets 0-f; got {sorted(buckets)}")
+        if daily["app_type"].nunique() != 1 or daily["version_code"].nunique() != 1:
+            raise ValueError(f"{stat_date} contains multiple app types or versions")
+
+
+def build_report(raw: pd.DataFrame, config: dict) -> pd.DataFrame:
+    validate_raw(raw)
+    experiment = [str(item) for item in config["experiment_buckets"]]
+    control = [str(item) for item in config["control_buckets"]]
+    exp_label = f"实验组({compact_buckets(experiment)})"
+    ctrl_label = f"对照组({compact_buckets(control)})"
+
+    data = raw.rename(columns=column_mapping()).copy()
+    for column in FACT_COLUMNS:
+        data[column] = pd.to_numeric(data[column], errors="raise")
+    for column in ["日期", "产品类型", "版本号", "尾号"]:
+        data[column] = data[column].astype(str)
+    data["尾号"] = data["尾号"].str.lower()
+    data["行类型"] = "尾号明细"
+    data["分组"] = data["尾号"].map(lambda bucket: exp_label if bucket in experiment else ctrl_label)
+
+    reports = []
+    for stat_date in sorted(data["日期"].unique(), reverse=True):
+        detail = data[data["日期"] == stat_date].copy()
+        detail["_bucket_order"] = detail["尾号"].map(HEX.index)
+        detail = detail.sort_values("_bucket_order").drop(columns="_bucket_order")
+        detail = detail[["日期", "产品类型", "版本号", "行类型", "分组", "尾号", *FACT_COLUMNS]]
+
+        aggregate_rows = []
+        mean_rows = []
+        for group, buckets in [(exp_label, experiment), (ctrl_label, control)]:
+            selected = detail[detail["尾号"].isin(buckets)]
+            common = {
+                "日期": stat_date,
+                "产品类型": detail["产品类型"].iloc[0],
+                "版本号": detail["版本号"].iloc[0],
+                "分组": group,
+            }
+            aggregate_rows.append(
+                {
+                    **common,
+                    "行类型": "分组聚合",
+                    "尾号": compact_buckets(buckets),
+                    **selected[FACT_COLUMNS].sum().to_dict(),
+                }
+            )
+            mean_rows.append(
+                {
+                    **common,
+                    "行类型": "每桶均值",
+                    "尾号": f"{len(buckets)}桶均值",
+                    **selected[FACT_COLUMNS].mean().to_dict(),
+                }
+            )
+        reports.append(pd.concat([detail, pd.DataFrame(aggregate_rows), pd.DataFrame(mean_rows)], ignore_index=True))
+
+    result = pd.concat(reports, ignore_index=True)
+    result = add_rates(result)
+    result = add_relative_changes(result, exp_label, ctrl_label, len(experiment), len(control))
+    return format_output(result)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Format raw growth-fission facts")
+    parser.add_argument("normalized_request", type=Path)
+    parser.add_argument("raw_facts_csv", type=Path)
+    parser.add_argument("full_report_csv", type=Path)
+    parser.add_argument("--aggregate-output", type=Path)
+    args = parser.parse_args()
+
+    config = json.loads(args.normalized_request.read_text(encoding="utf-8"))
+    if config.get("report_kind") != "growth_fission":
+        raise ValueError("normalized request is not a growth-fission request")
+
+    raw = pd.read_csv(
+        args.raw_facts_csv,
+        dtype={"stat_date": str, "app_type": str, "version_code": str, "bucket": str},
+    )
+    report = build_report(raw, config)
+
+    args.full_report_csv.parent.mkdir(parents=True, exist_ok=True)
+    report.to_csv(args.full_report_csv, index=False, encoding="utf-8-sig")
+    print(f"[CSV] {args.full_report_csv.resolve()} ({len(report)} rows)", flush=True)
+
+    if args.aggregate_output:
+        aggregate = report[report["行类型"] == "分组聚合"].copy()
+        args.aggregate_output.parent.mkdir(parents=True, exist_ok=True)
+        aggregate.to_csv(args.aggregate_output, index=False, encoding="utf-8-sig")
+        print(f"[CSV] {args.aggregate_output.resolve()} ({len(aggregate)} rows)", flush=True)
+
+
+if __name__ == "__main__":
+    main()

+ 113 - 0
.agents/skills/odps-growth-fission-report/scripts/normalize_request.py

@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+import argparse
+import json
+import re
+from datetime import datetime, timedelta
+from pathlib import Path
+
+
+HEX = "0123456789abcdef"
+
+
+def parse_date(value):
+    try:
+        return datetime.strptime(str(value), "%Y%m%d").date()
+    except ValueError as exc:
+        raise ValueError(f"invalid YYYYMMDD date: {value}") from exc
+
+
+def parse_buckets(value):
+    if isinstance(value, list):
+        tokens = [str(item).lower() for item in value]
+    else:
+        raw = str(value).lower().replace("、", ",").replace(" ", "")
+        tokens = []
+        for item in raw.split(","):
+            if not item:
+                continue
+            if re.fullmatch(r"[0-9a-f]-[0-9a-f]", item):
+                start, end = HEX.index(item[0]), HEX.index(item[2])
+                if start > end:
+                    raise ValueError(f"descending bucket range: {item}")
+                tokens.extend(HEX[start:end + 1])
+            else:
+                tokens.append(item)
+    invalid = sorted(set(tokens) - set(HEX))
+    if invalid:
+        raise ValueError(f"invalid buckets: {invalid}")
+    return [bucket for bucket in HEX if bucket in set(tokens)]
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Normalize ODPS growth-fission report parameters")
+    parser.add_argument("request", type=Path)
+    args = parser.parse_args()
+    data = json.loads(args.request.read_text(encoding="utf-8"))
+
+    required = ["app_type", "date_from", "date_to", "data_mode", "bucket_position_from_end", "experiment_buckets", "version"]
+    missing = [key for key in required if key not in data]
+    if missing:
+        raise ValueError(f"missing required parameters: {missing}")
+
+    start, end = parse_date(data["date_from"]), parse_date(data["date_to"])
+    if start > end:
+        raise ValueError("date_from must not exceed date_to")
+    mode = str(data["data_mode"]).lower()
+    if mode not in {"offline", "realtime"}:
+        raise ValueError("data_mode must be offline or realtime")
+    if mode == "realtime" and start != end:
+        raise ValueError("realtime mode supports one calendar date")
+
+    position = int(data["bucket_position_from_end"])
+    if position < 1:
+        raise ValueError("bucket_position_from_end must be positive")
+    experiment = parse_buckets(data["experiment_buckets"])
+    if not experiment:
+        raise ValueError("experiment_buckets must not be empty")
+    control = parse_buckets(data["control_buckets"]) if data.get("control_buckets") is not None else [b for b in HEX if b not in experiment]
+    if not control or set(experiment) & set(control):
+        raise ValueError("experiment and control buckets must be non-empty and disjoint")
+    if set(experiment) | set(control) != set(HEX):
+        raise ValueError("experiment and control buckets must cover 0-f")
+
+    rule = data.get("first_layer_rule", "special-layer-compatible")
+    if rule not in {"special-layer-compatible", "depth-zero"}:
+        raise ValueError("first_layer_rule must be special-layer-compatible or depth-zero")
+    version = str(data["version"])
+    version_code = None if version.lower() == "all" else version
+    dates = []
+    current = start
+    while current <= end:
+        dates.append(current.strftime("%Y%m%d"))
+        current += timedelta(days=1)
+
+    tables = {
+        "offline": {"useractive": "loghubods.useractive_log", "video": "loghubods.video_action_log_applet", "share": "loghubods.user_share_log"},
+        "realtime": {"useractive": "loghubods.useractive_log_per5min", "video": "loghubods.video_action_log_flow", "share": "loghubods.user_share_log_per5min"},
+    }[mode]
+    normalized = {
+        "report_kind": "growth_fission",
+        "app_type": str(data["app_type"]),
+        "date_from": dates[0],
+        "date_to": dates[-1],
+        "dates": dates,
+        "date_count": len(dates),
+        "data_mode": mode,
+        "tables": tables,
+        "bucket_position_from_end": position,
+        "bucket_substr_offset": position - 1,
+        "experiment_buckets": experiment,
+        "control_buckets": control,
+        "experiment_bucket_count": len(experiment),
+        "control_bucket_count": len(control),
+        "version": "all" if version_code is None else "specific",
+        "version_code": version_code,
+        "first_layer_rule": rule,
+        "exclude_qywx": bool(data.get("exclude_qywx", False)),
+        "output_dir": data.get("output_dir", "."),
+    }
+    print(json.dumps(normalized, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+    main()

+ 61 - 0
.agents/skills/odps-growth-fission-report/scripts/odps_module.py

@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+"""Minimal PyODPS client configured only through environment variables."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+from odps import ODPS, options
+
+
+ENV_ACCESS_ID = "ODPS_ACCESS_ID"
+ENV_ACCESS_SECRET = "ODPS_ACCESS_SECRET"
+ENV_PROJECT = "ODPS_PROJECT"
+ENV_ENDPOINT = "ODPS_ENDPOINT"
+
+
+def require_env(name: str) -> str:
+    value = os.getenv(name)
+    if not value:
+        raise RuntimeError(f"Missing required environment variable: {name}")
+    return value
+
+
+class ODPSClient:
+    def __init__(self, project: str | None = None, endpoint: str | None = None):
+        access_id = require_env(ENV_ACCESS_ID)
+        access_secret = require_env(ENV_ACCESS_SECRET)
+        project_name = project or require_env(ENV_PROJECT)
+        endpoint_url = endpoint or require_env(ENV_ENDPOINT)
+
+        options.connect_timeout = 60
+        options.read_timeout = 1200
+        options.retry_times = 3
+
+        self.odps = ODPS(
+            access_id,
+            access_secret,
+            project=project_name,
+            endpoint=endpoint_url,
+        )
+
+    def execute_sql(self, sql: str):
+        if not sql.strip():
+            raise ValueError("SQL must not be empty")
+
+        instance = self.odps.run_sql(
+            sql,
+            hints={"odps.sql.submit.mode": "script"},
+        )
+        print(f"[ODPS] InstanceId: {instance.id}", flush=True)
+        instance.wait_for_success()
+        with instance.open_reader(tunnel=True) as reader:
+            return reader.to_pandas()
+
+    def execute_sql_result_save_file(self, sql: str, output_file: str | Path):
+        output_path = Path(output_file).expanduser()
+        output_path.parent.mkdir(parents=True, exist_ok=True)
+        data = self.execute_sql(sql)
+        data.to_csv(output_path, index=False, encoding="utf-8-sig")
+        return output_path

+ 28 - 0
.agents/skills/odps-growth-fission-report/scripts/run_sql.py

@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+"""Execute an explicit SQL file with the bundled environment-based ODPS client."""
+
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+from odps_module import ODPSClient
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Run an ODPS SQL file and save UTF-8 CSV")
+    parser.add_argument("sql_file", type=Path)
+    parser.add_argument("output_file", type=Path)
+    parser.add_argument("--project", help="Override ODPS_PROJECT for this query")
+    args = parser.parse_args()
+
+    sql = args.sql_file.expanduser().read_text(encoding="utf-8")
+    output_path = ODPSClient(project=args.project).execute_sql_result_save_file(
+        sql,
+        args.output_file,
+    )
+    print(f"[CSV] {output_path.resolve()}", flush=True)
+
+
+if __name__ == "__main__":
+    main()

+ 78 - 0
.agents/skills/odps-product-efficiency-report/SKILL.md

@@ -0,0 +1,78 @@
+---
+name: odps-product-efficiency-report
+description: 生成、执行、校验并分析参数化 ODPS 产品效率实验报表,包含 DAU、曝光、播放、分享、回流、STR 和 ROV,并按头部、推荐、全部流量拆分;每次成功查询必须给出数据结论,用户要求飞书时将结论随表格发送。用于产品类型、离线或实时模式、日期范围、rootSessionId 分桶位置、实验/对照尾号、版本过滤或企微排除条件发生变化的查询。
+---
+
+# ODPS 产品效率报表
+
+将 `SKILL_DIR` 解析为当前 `SKILL.md` 所在的安装目录。所有脚本和参考资料都相对该目录解析;不得假定当前工作目录或作者仓库。
+
+## 运行环境
+
+依赖 `pyodps` 和 `pandas`。随 Skill 分发的 ODPS 客户端读取 `ODPS_ACCESS_ID`、`ODPS_ACCESS_SECRET`、`ODPS_PROJECT` 和 `ODPS_ENDPOINT`。不得把这些值写入请求 JSON、SQL、输出文件或回复。
+
+## 执行前强制确认
+
+在生成请求 JSON、SQL、执行 ODPS 或上传飞书前,确认用户已经明确给出以下口径:
+
+- 日期或日期范围;“今天”“昨天”等相对日期需解析为具体 `yyyyMMdd` 并回显。
+- 离线或实时数据。
+- 产品类型 `app_type`。
+- `rootSessionId` 倒数第几位分桶。
+- 哪些尾号属于实验组。
+- 全部版本或指定版本。
+
+任一项缺失时,先用一个简短问题集中询问缺失项;不得从旧报表、目录名、历史习惯或默认值猜测。用户已经明确全部口径时无需重复确认。对照组未指定时,只有在实验组已明确后才可使用 `0-f` 的补集,并在执行前回显。
+
+## 工作流程
+
+1. 阅读 [metrics.md](references/metrics.md) 和 [raw-output-contract.md](references/raw-output-contract.md)。
+2. 执行“执行前强制确认”;存在缺失项时停止,不生成文件、不查询、不上传。
+3. 在用户指定输出目录把参数写入 `request.json`。
+4. 规范化并校验参数:
+
+   `python3 "$SKILL_DIR/scripts/normalize_request.py" request.json > normalized.json`
+
+5. 明确回显日期、模式、数据表、分桶位置和分组、版本策略及企微处理方式。
+6. 生成一个符合原始事实契约的参数化 SQL。不得搜索或依赖外部 `AGENTS.md`、特定日期 SQL 或本地 runner。
+7. 预检全部日期、数据表、产品类型、分桶表达式、版本条件、分组标签和渠道排除条件。离线和实时的活跃、视频日志都必须从 `extparams.$.rootSessionId` 分桶,分享日志使用物理列 `rootsessionid`;特别禁止用离线 `useractive_log.rootsessionid` 计算 DAU。离线 `video_action_log_applet` 和实时 `video_action_log_flow` 都只用各自日期分区、`apptype` 和 `businesstype` 过滤,SQL 中禁止引用物理字段 `business`。分享 PV/UV 使用视频日志的 `businesstype='videoShareFriend'`;回流源分享必须使用分享日志的 `topic='share'`,回流点击必须使用 `topic='click'`,禁止用 `type='share'` 判断源分享。SQL 必须为每天生成完整 16 个桶。
+8. 只提交一个 ODPS 实例:
+
+   `python3 "$SKILL_DIR/scripts/run_sql.py" query.sql raw_facts.csv`
+
+9. 生成标准完整报表和聚合报表:
+
+   `python3 "$SKILL_DIR/scripts/format_report.py" normalized.json raw_facts.csv full_report.csv --aggregate-output aggregate_report.csv`
+
+10. 执行 [metrics.md](references/metrics.md) 中的全部校验,并按“分析与飞书概要契约”输出结论;若某日 16 桶 DAU 全为零而任一行为事实非零,按 rootSessionId 映射错误停止,不得格式化或分析该结果。不得只返回文件链接或原始指标。
+11. 仅当用户明确要求上传飞书时,先生成 `summary_message`,再使用 `$feishu-data-publisher` 发布最终 CSV 或 Excel,并通过 `--message` 把该概要写入群卡片;未明确要求时只保留本地结果。
+12. 发布成功后,在对话回复中只给出飞书链接、权限和发送状态,并说明概要已随群卡片发送;不要重复整段结论。
+
+## 分析与飞书概要契约
+
+每次成功查询都必须以校验通过的聚合报表为依据生成分析:
+
+- 先回显日期、实时或离线、产品、实验/对照桶、版本和数据截止时间。
+- DAU 使用真实桶数比较每桶均值,同时给出实验组总量;不得直接比较不同桶数的 DAU 总量。
+- 分别分析头部、推荐和全部流量,至少覆盖曝光 PV/DAU、播放 PV/DAU、分享 PV/DAU、STR、回流 UV/DAU 和 ROV 的实验值、对照值及相对变化。
+- 明确指出最强正向指标、主要负向或退化指标,以及整体判断。只描述相关变化,不把实验对比表述为已证明的因果关系。
+- 对照组为单桶、实时数据未收全、对照为零或样本过小时必须提示风险;基线为零时写“不可比较”,不得生成无穷变化率。
+
+飞书 `summary_message` 使用简洁的 Lark Markdown,建议不超过 800 个中文字符,依次包含:口径、全部流量核心结论、头部与推荐各一句判断、风险提示。禁止使用“数据查询结果已生成,请点击查看”之类的占位文案替代数据结论。
+
+## 参数规则
+
+- 必须提供 `app_type`、`date_from`、`date_to`、`data_mode`、`bucket_position_from_end`、`experiment_buckets` 和 `version`。
+- 未明确给出对照桶时,自动使用 `0-f` 中实验桶的补集。
+- `version: "all"` 表示不限制版本;指定版本时限制 DAU、视频行为和源分享,不限制回流接收用户。
+- 实时模式只支持一个自然日;已完成的历史日期使用离线数据源。
+- `exclude_qywx` 默认 false;为 true 时必须贯穿所有适用来源。
+- DAU 每桶比较必须使用真实实验桶数和对照桶数。
+- 对照组只有一个桶时,必须说明单桶波动风险。
+
+## 资源
+
+- `scripts/normalize_request.py`:参数规范化与校验。
+- `scripts/odps_module.py`:基于环境变量的 ODPS 连接。
+- `scripts/run_sql.py`:可移植的 SQL 转 CSV 执行器。
+- `scripts/format_report.py`:确定性的 65 列格式器及聚合输出器。

+ 4 - 0
.agents/skills/odps-product-efficiency-report/agents/openai.yaml

@@ -0,0 +1,4 @@
+interface:
+  display_name: "产品效率实验报表"
+  short_description: "生成并分析产品效率实验报表,按需将结论和表格发送飞书"
+  default_prompt: "使用 $odps-product-efficiency-report 查询并分析指定产品效率数据,按需将结论和报表发送飞书。"

+ 61 - 0
.agents/skills/odps-product-efficiency-report/references/metrics.md

@@ -0,0 +1,61 @@
+# Product-efficiency metric contract
+
+## Source selection
+
+| Mode | DAU | Video actions | Share and click |
+|---|---|---|---|
+| offline | `loghubods.useractive_log` | `loghubods.video_action_log_applet` | `loghubods.user_share_log` |
+| realtime | `loghubods.useractive_log_per5min` | `loghubods.video_action_log_flow` | `loghubods.user_share_log_per5min` |
+
+For realtime video-flow partitions, set year, month, and day consistently. For offline tables, group every fact by `dt`; never deduplicate across dates.
+
+## Bucket and source definitions
+
+- Use the following exact field mapping; do not substitute a same-named physical column for an `extparams` field:
+  - Offline `loghubods.useractive_log`: `GET_JSON_OBJECT(extparams, '$.rootSessionId')`.
+  - Offline `loghubods.video_action_log_applet`: `GET_JSON_OBJECT(extparams, '$.rootSessionId')`.
+  - Offline `loghubods.user_share_log`: physical column `rootsessionid`.
+- Realtime field mapping:
+  - `loghubods.useractive_log_per5min`: `GET_JSON_OBJECT(extparams, '$.rootSessionId')`.
+  - `loghubods.video_action_log_flow`: `GET_JSON_OBJECT(extparams, '$.rootSessionId')`.
+  - `loghubods.user_share_log_per5min`: physical column `rootsessionid`.
+- Alias the mapped value as `root_session_id`, then derive and validate the bucket only from that alias.
+- Offline partition mapping: filter each offline table by exact day-valued `dt` and group every fact by `dt`.
+  - For `loghubods.video_action_log_applet`, never add a predicate on the physical `business` field. Filter video events only with `businesstype IN ('videoView', 'videoPlay', 'videoShareFriend')`, together with the requested `dt`, `apptype`, and optional version/channel conditions.
+- Realtime partition mapping:
+  - `useractive_log_per5min` and `user_share_log_per5min`: `dt LIKE 'yyyyMMdd%'`.
+  - `video_action_log_flow`: filter `year`, `month`, and day-valued `dt`; omit `hh` for the current-day cumulative result. Never add a predicate on the physical `business` field; filter events only with `businesstype IN ('videoView', 'videoPlay', 'videoShareFriend')`.
+- For position `n` from the end, use `LOWER(SUBSTR(rootSessionId, LENGTH(rootSessionId) - n + 1, 1))` and retain only `^[0-9a-f]$`.
+- Head: `pagesource RLIKE 'user-videos-share$'`.
+- Recommendation: `pagesource RLIKE '(detail|category|recommend)$'`.
+- All: no pagesource restriction.
+
+## Facts and rates
+
+- DAU: distinct `machinecode` from useractive.
+- Exposure: `businesstype='videoView'`; PV and distinct `mid` UV.
+- Play: `businesstype='videoPlay'`; PV and distinct `mid` UV.
+- Share: count video-action rows with `businesstype='videoShareFriend'`; PV is the row count and UV is distinct `mid`.
+- Return source: select share-log rows with `topic='share'`, retain their `shareid`, `pagesource`, and source `rootsessionid` bucket. Never use `type='share'` to select source shares.
+- Return click: select same-day share-log rows with `topic='click'`, then join source shares and clicks by `shareid`. Return UV is distinct click-side `machinecode`; use the source share's `pagesource` for head/recommendation attribution and its `rootsessionid` for the bucket.
+- STR: share PV / exposure PV.
+- ROV: return UV / exposure PV.
+- Per-user metrics: PV or return UV / same-tail DAU.
+- DAU relative change: experiment per-tail mean / control per-tail mean - 1.
+- Other relative changes: experiment aggregate rate / control aggregate rate - 1.
+
+## Version policy
+
+When a version is specified, filter DAU, video actions, and source-share rows to that version. Do not restrict recipient click rows by version. When version is `all`, omit all version filters and output version label `全部`.
+
+## Output and validation
+
+- Chinese facts: DAU plus exposure/play/share PV/UV and return UV for head, recommendation, and all.
+- Interleave each fact group with its rates and experiment-relative changes; retain the established 65-column order.
+- Fact cells must be integers. Per-user numeric rates use four decimals; percentages use four decimal places.
+- Reject a date when all 16 bucket DAUs sum to zero but any behavior fact is nonzero; this indicates a rootSessionId identity/bucket mapping error, not a valid report. A genuinely all-zero date remains valid and its denominator-based rates are blank/unavailable.
+- Per date: exactly 16 distinct detail tails `0-f`, two aggregates, and two per-tail means, for 20 rows.
+- Aggregate facts must equal the sum of their member detail tails.
+- Per-tail means must use the actual number of tails in each group.
+- Sort dates descending and detail tails ascending.
+- Treat a one-tail control as more volatile and say so in the analysis.

+ 40 - 0
.agents/skills/odps-product-efficiency-report/references/raw-output-contract.md

@@ -0,0 +1,40 @@
+# Product-efficiency raw output contract
+
+Generate one row per `stat_date + bucket`. Every date must contain exactly the 16 lowercase buckets `0-f`; create a bucket spine and left join zero facts when necessary.
+
+## Identity columns
+
+- `stat_date`: `yyyyMMdd`
+- `app_type`: requested product/app type
+- `version_code`: requested version or the literal `all`
+- `bucket`: lowercase `0-f`
+- `dau`: distinct eligible `machinecode`
+
+## Source fact columns
+
+For each prefix `head`, `recommend`, and `all`, emit:
+
+- `<prefix>_exposure_pv`
+- `<prefix>_exposure_uv`
+- `<prefix>_play_pv`
+- `<prefix>_play_uv`
+- `<prefix>_share_pv`
+- `<prefix>_share_uv`
+- `<prefix>_return_uv`
+
+The complete raw result therefore has 26 columns and 16 rows per date.
+
+## SQL construction rules
+
+1. Select offline or realtime tables from normalized parameters.
+2. Normalize `rootSessionId` using the exact mapping in `metrics.md`: useractive and video-action tables use `GET_JSON_OBJECT(extparams, '$.rootSessionId')`; source-share tables use physical `rootsessionid`. Never use physical `useractive_log.rootsessionid` for offline DAU.
+3. Derive the bucket using the normalized `bucket_substr_offset`.
+4. Aggregate every fact independently by date and bucket before joining.
+5. Apply the requested app type, dates, version policy, and enterprise-WeChat exclusion to every applicable source.
+   - Offline `video_action_log_applet` must use exact `dt`, `apptype`, and `businesstype` predicates and must not reference the physical `business` field.
+   - Realtime `video_action_log_flow` must use `year/month/dt`, `apptype`, and `businesstype` predicates and must not reference the physical `business` field.
+6. Attribute return users with two explicit share-log populations: source rows must use `topic='share'`, click rows must use `topic='click'`, and the two populations join by same-day `shareid`. Never use `type='share'` to identify source shares. Attribute bucket and head/recommendation source from the source row's `rootsessionid` and `pagesource`; count distinct click-side `machinecode` as return UV. Do not restrict returned users by version.
+7. Join all facts to a `0-f` bucket spine and `COALESCE` missing counts to zero.
+8. Order by date descending and bucket ascending.
+
+After retrieval, reject any date where `SUM(dau) = 0` while any non-DAU fact is greater than zero. Report this as a rootSessionId mapping error and do not format or analyze the inconsistent rows. When DAU and every behavior fact are genuinely zero, keep the rows and render denominator-based rates as blank/unavailable.

+ 286 - 0
.agents/skills/odps-product-efficiency-report/scripts/format_report.py

@@ -0,0 +1,286 @@
+#!/usr/bin/env python3
+"""Build the standard 65-column product-efficiency report from raw ODPS facts."""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+
+import pandas as pd
+
+
+HEX = "0123456789abcdef"
+SOURCES = ["头部", "推荐", "全部"]
+SOURCE_KEYS = {"头部": "head", "推荐": "recommend", "全部": "all"}
+FACT_SUFFIXES = ["曝光PV", "曝光UV", "播放PV", "播放UV", "分享PV", "分享UV", "回流UV"]
+FACT_COLUMNS = ["DAU", *[f"{source}{suffix}" for source in SOURCES for suffix in FACT_SUFFIXES]]
+RAW_FACT_SUFFIXES = ["exposure_pv", "exposure_uv", "play_pv", "play_uv", "share_pv", "share_uv", "return_uv"]
+RAW_FACT_COLUMNS = ["dau", *[f"{key}_{suffix}" for key in SOURCE_KEYS.values() for suffix in RAW_FACT_SUFFIXES]]
+RATE_SUFFIXES = [
+    "曝光PV/DAU",
+    "播放PV/DAU",
+    "分享PV/DAU",
+    "回流UV/DAU",
+    "STR(分享PV/曝光PV)",
+    "ROV(回流UV/曝光PV)",
+]
+
+
+def column_mapping() -> dict[str, str]:
+    mapping = {
+        "stat_date": "日期",
+        "app_type": "产品类型",
+        "version_code": "版本号",
+        "bucket": "尾号",
+        "dau": "DAU",
+    }
+    fields = {
+        "exposure_pv": "曝光PV",
+        "exposure_uv": "曝光UV",
+        "play_pv": "播放PV",
+        "play_uv": "播放UV",
+        "share_pv": "分享PV",
+        "share_uv": "分享UV",
+        "return_uv": "回流UV",
+    }
+    for source, key in SOURCE_KEYS.items():
+        for field, label in fields.items():
+            mapping[f"{key}_{field}"] = f"{source}{label}"
+    return mapping
+
+
+def compact_buckets(buckets: list[str]) -> str:
+    indexes = sorted(HEX.index(bucket) for bucket in buckets)
+    groups: list[str] = []
+    start = previous = indexes[0]
+    for current in indexes[1:] + [None]:
+        if current is not None and current == previous + 1:
+            previous = current
+            continue
+        groups.append(HEX[start] if start == previous else f"{HEX[start]}-{HEX[previous]}")
+        if current is not None:
+            start = previous = current
+    return ",".join(groups)
+
+
+def format_percent(value) -> str:
+    return "" if pd.isna(value) else f"{value * 100:.4f}%"
+
+
+def add_rates(data: pd.DataFrame) -> pd.DataFrame:
+    result = data.copy()
+    dau = pd.to_numeric(result["DAU"], errors="coerce").astype("float64").mask(lambda values: values == 0)
+    for source in SOURCES:
+        exposure = (
+            pd.to_numeric(result[f"{source}曝光PV"], errors="coerce")
+            .astype("float64")
+            .mask(lambda values: values == 0)
+        )
+        result[f"{source}曝光PV/DAU"] = result[f"{source}曝光PV"] / dau
+        result[f"{source}播放PV/DAU"] = result[f"{source}播放PV"] / dau
+        result[f"{source}分享PV/DAU"] = result[f"{source}分享PV"] / dau
+        result[f"{source}回流UV/DAU"] = result[f"{source}回流UV"] / dau
+        result[f"{source}STR(分享PV/曝光PV)"] = result[f"{source}分享PV"] / exposure
+        result[f"{source}ROV(回流UV/曝光PV)"] = result[f"{source}回流UV"] / exposure
+    return result
+
+
+def add_relative_changes(
+    data: pd.DataFrame,
+    experiment_label: str,
+    control_label: str,
+    experiment_bucket_count: int,
+    control_bucket_count: int,
+) -> pd.DataFrame:
+    result = data.copy()
+    result["DAU相对对照组变化率"] = ""
+    for stat_date in result["日期"].unique():
+        daily = result["日期"] == stat_date
+        exp = result.index[daily & (result["行类型"] == "分组聚合") & (result["分组"] == experiment_label)]
+        ctrl = result.index[daily & (result["行类型"] == "分组聚合") & (result["分组"] == control_label)]
+        if len(exp) == len(ctrl) == 1:
+            exp_mean = result.loc[exp[0], "DAU"] / experiment_bucket_count
+            ctrl_mean = result.loc[ctrl[0], "DAU"] / control_bucket_count
+            if ctrl_mean:
+                change = format_percent(exp_mean / ctrl_mean - 1)
+                mask = daily & (result["分组"] == experiment_label) & result["行类型"].isin(["分组聚合", "每桶均值"])
+                result.loc[mask, "DAU相对对照组变化率"] = change
+
+        for source in SOURCES:
+            for suffix in RATE_SUFFIXES:
+                metric = f"{source}{suffix}"
+                change_column = f"{metric}相对对照组变化率"
+                if change_column not in result:
+                    result[change_column] = ""
+                for row_type in ["分组聚合", "每桶均值"]:
+                    exp_row = result.index[daily & (result["行类型"] == row_type) & (result["分组"] == experiment_label)]
+                    ctrl_row = result.index[daily & (result["行类型"] == row_type) & (result["分组"] == control_label)]
+                    if len(exp_row) == len(ctrl_row) == 1:
+                        control = result.loc[ctrl_row[0], metric]
+                        if pd.notna(control) and control != 0:
+                            result.loc[exp_row[0], change_column] = format_percent(
+                                result.loc[exp_row[0], metric] / control - 1
+                            )
+    return result
+
+
+def format_output(data: pd.DataFrame) -> pd.DataFrame:
+    result = data.copy()
+    for source in SOURCES:
+        for suffix in ["曝光PV/DAU", "播放PV/DAU", "分享PV/DAU"]:
+            result[f"{source}{suffix}"] = pd.to_numeric(
+                result[f"{source}{suffix}"], errors="coerce"
+            ).round(4)
+        for suffix in ["回流UV/DAU", "STR(分享PV/曝光PV)", "ROV(回流UV/曝光PV)"]:
+            result[f"{source}{suffix}"] = result[f"{source}{suffix}"].map(format_percent)
+    result[FACT_COLUMNS] = result[FACT_COLUMNS].round(0).astype("Int64")
+
+    ordered = ["日期", "产品类型", "版本号", "行类型", "分组", "尾号", "DAU", "DAU相对对照组变化率"]
+    for source in SOURCES:
+        ordered.extend(
+            [
+                f"{source}曝光PV",
+                f"{source}曝光UV",
+                f"{source}曝光PV/DAU",
+                f"{source}曝光PV/DAU相对对照组变化率",
+                f"{source}播放PV",
+                f"{source}播放UV",
+                f"{source}播放PV/DAU",
+                f"{source}播放PV/DAU相对对照组变化率",
+                f"{source}分享PV",
+                f"{source}分享UV",
+                f"{source}分享PV/DAU",
+                f"{source}分享PV/DAU相对对照组变化率",
+                f"{source}STR(分享PV/曝光PV)",
+                f"{source}STR(分享PV/曝光PV)相对对照组变化率",
+                f"{source}回流UV",
+                f"{source}回流UV/DAU",
+                f"{source}回流UV/DAU相对对照组变化率",
+                f"{source}ROV(回流UV/曝光PV)",
+                f"{source}ROV(回流UV/曝光PV)相对对照组变化率",
+            ]
+        )
+    return result[ordered]
+
+
+def validate_raw(data: pd.DataFrame) -> None:
+    expected = set(column_mapping())
+    missing = sorted(expected - set(data.columns))
+    if missing:
+        raise ValueError(f"raw facts are missing columns: {missing}")
+
+    duplicates = data.duplicated(["stat_date", "bucket"], keep=False)
+    if duplicates.any():
+        raise ValueError("raw facts contain duplicate date/bucket rows")
+
+    numeric = data[RAW_FACT_COLUMNS].apply(pd.to_numeric, errors="coerce")
+    invalid_columns = numeric.columns[numeric.isna().any()].tolist()
+    if invalid_columns:
+        raise ValueError(f"raw facts contain non-numeric or missing facts: {invalid_columns}")
+    negative_columns = numeric.columns[(numeric < 0).any()].tolist()
+    if negative_columns:
+        raise ValueError(f"raw facts contain negative facts: {negative_columns}")
+
+    for stat_date, daily in data.groupby("stat_date"):
+        buckets = set(daily["bucket"].astype(str).str.lower())
+        if buckets != set(HEX):
+            raise ValueError(f"{stat_date} must contain exactly buckets 0-f; got {sorted(buckets)}")
+        if daily["app_type"].nunique() != 1 or daily["version_code"].nunique() != 1:
+            raise ValueError(f"{stat_date} contains multiple app types or versions")
+        daily_numeric = numeric.loc[daily.index]
+        if daily_numeric["dau"].sum() == 0 and daily_numeric.drop(columns="dau").to_numpy().sum() > 0:
+            raise ValueError(
+                f"{stat_date} has zero DAU in every bucket but nonzero behavior facts; "
+                "verify the useractive rootSessionId mapping"
+            )
+
+
+def build_report(raw: pd.DataFrame, config: dict) -> pd.DataFrame:
+    validate_raw(raw)
+    experiment = [str(item) for item in config["experiment_buckets"]]
+    control = [str(item) for item in config["control_buckets"]]
+    exp_label = f"实验组({compact_buckets(experiment)})"
+    ctrl_label = f"对照组({compact_buckets(control)})"
+
+    data = raw.rename(columns=column_mapping()).copy()
+    for column in FACT_COLUMNS:
+        data[column] = pd.to_numeric(data[column], errors="raise")
+    for column in ["日期", "产品类型", "版本号", "尾号"]:
+        data[column] = data[column].astype(str)
+    data["尾号"] = data["尾号"].str.lower()
+    data["行类型"] = "尾号明细"
+    data["分组"] = data["尾号"].map(lambda bucket: exp_label if bucket in experiment else ctrl_label)
+
+    reports = []
+    for stat_date in sorted(data["日期"].unique(), reverse=True):
+        detail = data[data["日期"] == stat_date].copy()
+        detail["_bucket_order"] = detail["尾号"].map(HEX.index)
+        detail = detail.sort_values("_bucket_order").drop(columns="_bucket_order")
+        detail = detail[["日期", "产品类型", "版本号", "行类型", "分组", "尾号", *FACT_COLUMNS]]
+
+        aggregate_rows = []
+        mean_rows = []
+        for group, buckets in [(exp_label, experiment), (ctrl_label, control)]:
+            selected = detail[detail["尾号"].isin(buckets)]
+            common = {
+                "日期": stat_date,
+                "产品类型": detail["产品类型"].iloc[0],
+                "版本号": detail["版本号"].iloc[0],
+                "分组": group,
+            }
+            aggregate_rows.append(
+                {
+                    **common,
+                    "行类型": "分组聚合",
+                    "尾号": compact_buckets(buckets),
+                    **selected[FACT_COLUMNS].sum().to_dict(),
+                }
+            )
+            mean_rows.append(
+                {
+                    **common,
+                    "行类型": "每桶均值",
+                    "尾号": f"{len(buckets)}桶均值",
+                    **selected[FACT_COLUMNS].mean().to_dict(),
+                }
+            )
+        reports.append(pd.concat([detail, pd.DataFrame(aggregate_rows), pd.DataFrame(mean_rows)], ignore_index=True))
+
+    result = pd.concat(reports, ignore_index=True)
+    result = add_rates(result)
+    result = add_relative_changes(result, exp_label, ctrl_label, len(experiment), len(control))
+    return format_output(result)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Format raw product-efficiency facts")
+    parser.add_argument("normalized_request", type=Path)
+    parser.add_argument("raw_facts_csv", type=Path)
+    parser.add_argument("full_report_csv", type=Path)
+    parser.add_argument("--aggregate-output", type=Path)
+    args = parser.parse_args()
+
+    config = json.loads(args.normalized_request.read_text(encoding="utf-8"))
+    if config.get("report_kind") != "product_efficiency":
+        raise ValueError("normalized request is not a product-efficiency request")
+
+    raw = pd.read_csv(
+        args.raw_facts_csv,
+        dtype={"stat_date": str, "app_type": str, "version_code": str, "bucket": str},
+    )
+    report = build_report(raw, config)
+
+    args.full_report_csv.parent.mkdir(parents=True, exist_ok=True)
+    report.to_csv(args.full_report_csv, index=False, encoding="utf-8-sig")
+    print(f"[CSV] {args.full_report_csv.resolve()} ({len(report)} rows)", flush=True)
+
+    if args.aggregate_output:
+        aggregate = report[report["行类型"] == "分组聚合"].copy()
+        args.aggregate_output.parent.mkdir(parents=True, exist_ok=True)
+        aggregate.to_csv(args.aggregate_output, index=False, encoding="utf-8-sig")
+        print(f"[CSV] {args.aggregate_output.resolve()} ({len(aggregate)} rows)", flush=True)
+
+
+if __name__ == "__main__":
+    main()

+ 109 - 0
.agents/skills/odps-product-efficiency-report/scripts/normalize_request.py

@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+import argparse
+import json
+import re
+from datetime import datetime, timedelta
+from pathlib import Path
+
+
+HEX = "0123456789abcdef"
+
+
+def parse_date(value):
+    try:
+        return datetime.strptime(str(value), "%Y%m%d").date()
+    except ValueError as exc:
+        raise ValueError(f"invalid YYYYMMDD date: {value}") from exc
+
+
+def parse_buckets(value):
+    if isinstance(value, list):
+        tokens = [str(item).lower() for item in value]
+    else:
+        raw = str(value).lower().replace("、", ",").replace(" ", "")
+        tokens = []
+        for item in raw.split(","):
+            if not item:
+                continue
+            if re.fullmatch(r"[0-9a-f]-[0-9a-f]", item):
+                start, end = HEX.index(item[0]), HEX.index(item[2])
+                if start > end:
+                    raise ValueError(f"descending bucket range: {item}")
+                tokens.extend(HEX[start:end + 1])
+            else:
+                tokens.append(item)
+    invalid = sorted(set(tokens) - set(HEX))
+    if invalid:
+        raise ValueError(f"invalid buckets: {invalid}")
+    return [bucket for bucket in HEX if bucket in set(tokens)]
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Normalize ODPS product-efficiency report parameters")
+    parser.add_argument("request", type=Path)
+    args = parser.parse_args()
+    data = json.loads(args.request.read_text(encoding="utf-8"))
+
+    required = ["app_type", "date_from", "date_to", "data_mode", "bucket_position_from_end", "experiment_buckets", "version"]
+    missing = [key for key in required if key not in data]
+    if missing:
+        raise ValueError(f"missing required parameters: {missing}")
+
+    start, end = parse_date(data["date_from"]), parse_date(data["date_to"])
+    if start > end:
+        raise ValueError("date_from must not exceed date_to")
+    mode = str(data["data_mode"]).lower()
+    if mode not in {"offline", "realtime"}:
+        raise ValueError("data_mode must be offline or realtime")
+    if mode == "realtime" and start != end:
+        raise ValueError("realtime mode supports one calendar date")
+
+    position = int(data["bucket_position_from_end"])
+    if position < 1:
+        raise ValueError("bucket_position_from_end must be positive")
+    experiment = parse_buckets(data["experiment_buckets"])
+    if not experiment:
+        raise ValueError("experiment_buckets must not be empty")
+    control = parse_buckets(data["control_buckets"]) if data.get("control_buckets") is not None else [b for b in HEX if b not in experiment]
+    if not control or set(experiment) & set(control):
+        raise ValueError("experiment and control buckets must be non-empty and disjoint")
+    if set(experiment) | set(control) != set(HEX):
+        raise ValueError("experiment and control buckets must cover 0-f")
+
+    version = str(data["version"])
+    version_code = None if version.lower() == "all" else version
+    dates = []
+    current = start
+    while current <= end:
+        dates.append(current.strftime("%Y%m%d"))
+        current += timedelta(days=1)
+
+    tables = {
+        "offline": {"dau": "loghubods.useractive_log", "video": "loghubods.video_action_log_applet", "share": "loghubods.user_share_log"},
+        "realtime": {"dau": "loghubods.useractive_log_per5min", "video": "loghubods.video_action_log_flow", "share": "loghubods.user_share_log_per5min"},
+    }[mode]
+    normalized = {
+        "report_kind": "product_efficiency",
+        "app_type": str(data["app_type"]),
+        "date_from": dates[0],
+        "date_to": dates[-1],
+        "dates": dates,
+        "date_count": len(dates),
+        "data_mode": mode,
+        "tables": tables,
+        "bucket_position_from_end": position,
+        "bucket_substr_offset": position - 1,
+        "experiment_buckets": experiment,
+        "control_buckets": control,
+        "experiment_bucket_count": len(experiment),
+        "control_bucket_count": len(control),
+        "version": "all" if version_code is None else "specific",
+        "version_code": version_code,
+        "exclude_qywx": bool(data.get("exclude_qywx", False)),
+        "output_dir": data.get("output_dir", "."),
+    }
+    print(json.dumps(normalized, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+    main()

+ 61 - 0
.agents/skills/odps-product-efficiency-report/scripts/odps_module.py

@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+"""Minimal PyODPS client configured only through environment variables."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+from odps import ODPS, options
+
+
+ENV_ACCESS_ID = "ODPS_ACCESS_ID"
+ENV_ACCESS_SECRET = "ODPS_ACCESS_SECRET"
+ENV_PROJECT = "ODPS_PROJECT"
+ENV_ENDPOINT = "ODPS_ENDPOINT"
+
+
+def require_env(name: str) -> str:
+    value = os.getenv(name)
+    if not value:
+        raise RuntimeError(f"Missing required environment variable: {name}")
+    return value
+
+
+class ODPSClient:
+    def __init__(self, project: str | None = None, endpoint: str | None = None):
+        access_id = require_env(ENV_ACCESS_ID)
+        access_secret = require_env(ENV_ACCESS_SECRET)
+        project_name = project or require_env(ENV_PROJECT)
+        endpoint_url = endpoint or require_env(ENV_ENDPOINT)
+
+        options.connect_timeout = 60
+        options.read_timeout = 1200
+        options.retry_times = 3
+
+        self.odps = ODPS(
+            access_id,
+            access_secret,
+            project=project_name,
+            endpoint=endpoint_url,
+        )
+
+    def execute_sql(self, sql: str):
+        if not sql.strip():
+            raise ValueError("SQL must not be empty")
+
+        instance = self.odps.run_sql(
+            sql,
+            hints={"odps.sql.submit.mode": "script"},
+        )
+        print(f"[ODPS] InstanceId: {instance.id}", flush=True)
+        instance.wait_for_success()
+        with instance.open_reader(tunnel=True) as reader:
+            return reader.to_pandas()
+
+    def execute_sql_result_save_file(self, sql: str, output_file: str | Path):
+        output_path = Path(output_file).expanduser()
+        output_path.parent.mkdir(parents=True, exist_ok=True)
+        data = self.execute_sql(sql)
+        data.to_csv(output_path, index=False, encoding="utf-8-sig")
+        return output_path

+ 28 - 0
.agents/skills/odps-product-efficiency-report/scripts/run_sql.py

@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+"""Execute an explicit SQL file with the bundled environment-based ODPS client."""
+
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+from odps_module import ODPSClient
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Run an ODPS SQL file and save UTF-8 CSV")
+    parser.add_argument("sql_file", type=Path)
+    parser.add_argument("output_file", type=Path)
+    parser.add_argument("--project", help="Override ODPS_PROJECT for this query")
+    args = parser.parse_args()
+
+    sql = args.sql_file.expanduser().read_text(encoding="utf-8")
+    output_path = ODPSClient(project=args.project).execute_sql_result_save_file(
+        sql,
+        args.output_file,
+    )
+    print(f"[CSV] {output_path.resolve()}", flush=True)
+
+
+if __name__ == "__main__":
+    main()

+ 41 - 0
.agents/skills/query-odps-data/SKILL.md

@@ -0,0 +1,41 @@
+---
+name: query-odps-data
+description: 将通用自然语言数据问题转换为安全、只读、可执行的 MaxCompute/ODPS SQL,并使用已确认的数据表与字段目录。用于没有更具体业务 Skill 匹配、用户进行多轮数据探索,或需要确认指标口径、时间范围、维度、关联关系和分区条件的场景。
+---
+
+# 通用 ODPS 数据查询
+
+将一个数据问题转换为一条可执行的 MaxCompute 查询。保留当前会话已经确认的口径;缺失信息会实质改变结果时,先集中追问一次。
+
+## 工作流程
+
+1. 阅读 [data-catalog.md](references/data-catalog.md),只使用其中确认属于目标表的字段、分区和业务含义。
+2. 识别用户需要的指标、人群、时间范围、维度、过滤条件、对比基线和输出粒度。
+3. 复用当前会话已确认的定义,不得静默改变上一轮口径。
+4. 广告风险、增长裂变、产品效率或单用户行为路径问题优先读取对应的专用业务 Skill;专用 Skill 的详细口径优先于通用目录。
+5. 必需的数据表、指标定义、日期范围或人群不明确时,用一个简短问题集中询问。目录未收录的表或字段也必须先确认,不得按名称猜测。
+6. 信息完整时只生成一条 MaxCompute `SELECT` 或 `WITH ... SELECT` 查询。
+7. 校验或执行反馈 SQL 错误时,只修复失败部分,保留用户原始指标和过滤条件。
+
+## SQL 要求
+
+- 只生成只读 SQL。禁止 `INSERT`、`UPDATE`、`DELETE`、`MERGE`、`CREATE`、`DROP`、`ALTER`、`TRUNCATE`、`USE`、`SET` 和多语句。
+- 只查询已配置项目允许访问的表,不推测或索要凭证。
+- 每张分区表都必须添加明确的分区条件,优先使用有界日期范围。
+- 只选择需要的字段;语义允许时,先聚合大型事件表再进行宽表关联。
+- 只能把目录中明确记录的 JSON 路径用于对应表,不能因物理列同名而替换。尤其禁止用离线 `useractive_log.rootsessionid` 计算实验 DAU。
+- 在结构化结果中写明影响结果的假设,不得把猜测的指标定义隐藏在 SQL 中。
+- 使用 MaxCompute 兼容函数和语法。除非已确认可用,否则不要使用其他方言特有语法。
+- 聚合报表不要添加装饰性的 `LIMIT`;主程序会控制最大返回行数。
+- 不自行执行查询或发布结果;主程序负责 SQL 校验、ODPS 执行、文件生成和飞书发布。
+
+## 追问示例
+
+- “活跃用户”含义不明确:询问是启动 DAU、任意事件用户,还是指定业务定义。
+- 趋势查询没有日期:询问开始、结束日期,以及按日还是按周输出。
+- “转化率”没有分子或分母:同时询问两个事件及归因窗口。
+- 用户继续说“再按渠道拆一下”时,复用上一轮的日期、指标和人群。
+
+## 资源
+
+- [data-catalog.md](references/data-catalog.md):现有查询 Skill 已验证的 15 张表、字段含义、分区方式和事件枚举。该目录只表示已确认范围,不是完整 ODPS 数据字典。

+ 4 - 0
.agents/skills/query-odps-data/agents/openai.yaml

@@ -0,0 +1,4 @@
+interface:
+  display_name: "ODPS 自助查询"
+  short_description: "把自然语言数据问题转成安全、可执行的 MaxCompute SQL"
+  default_prompt: "分析用户的数据问题,先确认口径和时间范围,再生成只读 MaxCompute SQL。"

+ 195 - 0
.agents/skills/query-odps-data/references/data-catalog.md

@@ -0,0 +1,195 @@
+# 通用查询已确认数据目录
+
+本目录汇总现有查询 Skill 实际使用并经 ODPS 元数据确认的表和字段。它不是完整数据字典:未收录的字段不得按名称猜测含义;需要使用时,先向用户确认或补充经过验证的表结构。
+
+## 使用原则
+
+- 离线日志通常用 `dt='yyyyMMdd'`;`*_per5min` 实时表通常用 `dt` 的 `yyyyMMddHHmmss` 范围。
+- `video_action_log_flow` 使用 `year + month + dt + hh` 分区,其中 `dt` 是日;`simpleevent_log_flow` 使用 `year + month + day + hour`。
+- 用户明确要求实时数据时才使用实时表。实时表只代表当前可用窗口,不能替代完整离线日数据。
+- 用户标识不能随意互换:活跃、广告、分享、简单事件和操作日志主要使用 `machinecode`;视频行为和播放日志主要使用 `mid`。只有专用口径明确规定时,才按 `mid=machinecode` 关联。
+- `clienttimestamp` 是字符串形式的客户端毫秒时间戳,排序或时间计算前转为 `BIGINT`。
+- 表字段类型除特别注明外均为 `STRING`。
+
+## 公共字段含义
+
+| 字段 | 已确认含义 |
+|---|---|
+| `dt` | 数据日期或实时分区。离线通常为 `yyyyMMdd`,五分钟表通常为 `yyyyMMddHHmmss`。 |
+| `year/month/day/hour/hh` | Flow 表的年、月、日、小时分区字段;以对应表规则为准。 |
+| `apptype` | 产品类型。 |
+| `versioncode` | 应用版本号。 |
+| `machinecode` | 设备/机器维度用户标识;DAU 通常按它去重。 |
+| `mid` | 视频链路用户标识;视频 PV/UV 常按它统计,跨日志关联必须遵循专用口径。 |
+| `loginuid` | 登录用户 UID;可能为空。 |
+| `clienttimestamp` | 客户端毫秒时间戳,原始类型为字符串。 |
+| `businesstype` | 业务事件类型;事件值见下文“已确认事件枚举”。 |
+| `type` | 分享日志中的事件类型;部分其他日志虽存在同名字段,但不得默认等同 `businesstype`。 |
+| `eventid` | 事件 ID。 |
+| `pagesource` | 当前行为发生的页面或场景来源。 |
+| `rootpagesource` | 根页面/上一层来源路径。 |
+| `path` | 小程序启动或返回路径;`useractive` 的 `businesstype='path'` 常用于启动 DAU。 |
+| `sessionid` | 会话 ID。 |
+| `subsessionid` | 热启动子会话 ID。 |
+| `extparams` | JSON 扩展参数;只能读取本目录为相应表确认过的 JSON 路径。 |
+| `machineinfo_system/system` | 客户端系统信息;常归一化为 iOS 或 Android。 |
+| `machineinfo` | 设备信息 JSON;广告日志使用 `$.system` 取得系统。 |
+| `networktype` | 网络类型。 |
+| `videoid/headvideoid` | 视频 ID/头部视频 ID。 |
+| `shareid` | 分享链路标识,用于同日分享和点击归因。 |
+| `topic` | 分享链路主题;已确认 `share` 表示源分享、`click` 表示点击分享卡片。产品效率回流必须用该字段区分两端。 |
+| `rootsessionid` | 分享或部分实时日志的物理根会话字段;能否用于分桶必须按具体表判断。 |
+| `rootsourceid` | 物理根来源字段;以 `dyyqw` 开头用于企微流量识别。 |
+| `objecttype/objectid/shareobjectid` | 事件对象类型、对象 ID、分享对象 ID。 |
+| `targetuid` | 目标用户 ID。 |
+| `endroutepath` | 前后台切换时所在页面;实时简单事件 Flow 表没有该字段。 |
+| `creativecode` | 广告创意编码。 |
+| `hotsencetype` | 热点/敏感场景类型,具体枚举尚未形成通用定义。 |
+| `pqtid` | 自有广告落地页链路标识,用于关联 view、hide 和转化排除。 |
+| `ownadsystemtype` | 广告系统类型;`ownPlatform` 表示自有平台广告。 |
+| `options` | 操作日志的操作参数。 |
+
+## 表概览
+
+| 表 | 数据模式 | 用途 | 分区 |
+|---|---|---|---|
+| `loghubods.useractive_log` | 离线 | 用户启动、活跃路径和实验人群 | `dt=yyyyMMdd` |
+| `loghubods.useractive_log_per5min` | 实时 | 用户启动和活跃路径实时窗口 | `dt` 时间范围 |
+| `loghubods.video_action_log_applet` | 离线 | 视频曝光、播放、分享行为 | `dt=yyyyMMdd` |
+| `loghubods.video_action_log_flow` | 实时 | 产品效率/裂变使用的视频实时行为 | `year/month/dt/hh` |
+| `loghubods.video_action_log_per5min` | 实时 | 单用户路径使用的视频实时行为 | `dt` 时间范围 |
+| `loghubods.video_play_log` | 离线 | 播放成功、卡顿、有效播放等明细 | `dt=yyyyMMdd` |
+| `loghubods.video_play_log_per5min` | 实时 | 播放明细实时窗口 | `dt` 时间范围 |
+| `loghubods.user_share_log` | 离线 | 分享、点击和回流归因 | `dt=yyyyMMdd` |
+| `loghubods.user_share_log_per5min` | 实时 | 分享、点击和回流实时窗口 | `dt` 时间范围 |
+| `loghubods.simpleevent_log` | 离线 | 页面、按钮、前后台、截图等通用事件 | `dt=yyyyMMdd` |
+| `loghubods.simpleevent_log_flow` | 实时 | 通用事件短期实时窗口 | `year/month/day/hour` |
+| `loghubods.ad_action_log_own` | 离线 | 自有广告生命周期和风险行为 | `dt=yyyyMMdd` |
+| `loghubods.ad_action_log_own_per5min` | 实时 | 自有广告行为实时窗口 | `dt` 时间范围 |
+| `loghubods.operation_log_per5min` | 实时 | 操作事件及其参数 | `dt` 时间范围 |
+| `videoods.dim_video` | 维表 | 用视频 ID 补充视频标题 | 未确认分区,不添加猜测条件 |
+
+## 活跃日志
+
+### `loghubods.useractive_log`
+
+- 用途:离线启动 DAU、用户行为路径、第一层人群和实验分桶。
+- 已用字段:`dt`、`machinecode`、`loginuid`、`clienttimestamp`、`apptype`、`versioncode`、`businesstype`、`eventid`、`system`、`pagesource`、`path`、`sessionid`、`subsessionid`、`extparams`。
+- 已确认 JSON 字段:`extparams.$.rootSessionId` 为实验根会话;增长裂变口径还会使用 `rootSourceId`、`userShareDepth` 和 `isSpecialLayer`。
+- 关键约束:实验 DAU 必须从 `extparams.$.rootSessionId` 分桶,禁止改用物理列 `rootsessionid`。启动 DAU 使用 `businesstype='path'` 后按 `machinecode` 去重。
+
+### `loghubods.useractive_log_per5min`
+
+- 用途:实时启动/活跃路径、实时实验人群。
+- 已用字段与离线活跃表基本一致:`dt`、`machinecode`、`clienttimestamp`、`apptype`、`versioncode`、`businesstype`、`eventid`、`system`、`pagesource`、`path`、`sessionid`、`subsessionid`、`extparams`。
+- 实验根会话同样读取 `extparams.$.rootSessionId`;按自然日查询时使用有界 `dt` 范围或 `dt LIKE 'yyyyMMdd%'`。
+
+## 视频行为日志
+
+### `loghubods.video_action_log_applet`
+
+- 用途:离线视频曝光、播放、分享 PV/UV,以及单用户视频路径。
+- 已用字段:`dt`、`mid`、`clienttimestamp`、`apptype`、`versioncode`、`businesstype`、`pagesource`、`videoid`、`eventid`、`machineinfo_system`、`sessionid`、`subsessionid`、`extparams`。
+- 实验根会话读取 `extparams.$.rootSessionId`;企微排除需要按专用 Skill 使用 `rootSourceId`。
+
+### `loghubods.video_action_log_flow`
+
+- 用途:产品效率和增长裂变的实时视频行为。
+- 字段语义与离线 applet 表一致,已用 `year`、`month`、`dt`、`hh`、`mid`、`apptype`、`versioncode`、`businesstype`、`pagesource`、`extparams`。
+- `rootSessionId` 从 `extparams` 读取。累计当日结果可省略 `hh`,但必须同时限制 `year`、`month` 和日值 `dt`。
+
+### `loghubods.video_action_log_per5min`
+
+- 用途:单用户实时行为路径;不是产品效率实时统计的视频表。
+- 已用字段:`dt`、`mid`、`clienttimestamp`、`apptype`、`businesstype`、`pagesource`、`videoid`、`machineinfo_system`、`hotsencetype`、`sessionid`、`subsessionid`。
+- 已确认物理字段还有 `rootsessionid`,但通用查询不能据此替代其他表的 JSON 根会话。
+
+## 视频播放日志
+
+### `loghubods.video_play_log`
+
+- 用途:离线播放质量和播放状态明细。
+- 已用字段:`dt`、`mid`、`clienttimestamp`、`apptype`、`versioncode`、`businesstype`、`eventid`、`pagesource`、`videoid`、`machineinfo_system`、`sessionid`、`subsessionid`。
+
+### `loghubods.video_play_log_per5min`
+
+- 用途:播放质量和播放状态实时窗口。
+- 已用字段与离线播放表一致,并确认存在 `hotsencetype`、`rootsessionid`、`rootsourceid`。
+
+## 分享日志
+
+### `loghubods.user_share_log`
+
+- 用途:离线分享、点击卡片、回流和裂变归因。
+- 已用字段:`dt`、`machinecode`、`loginuid`、`clienttimestamp`、`apptype`、`versioncode`、`type`、`topic`、`pagesource`、`shareid`、`shareobjectid`、`rootpagesource`、`rootsessionid`、`rootsourceid`、`sessionid`、`subsessionid`、`eventid`、`usersharedepth`。
+- 产品效率回流中,源分享使用 `topic='share'`,点击使用 `topic='click'`,两端按同日 `shareid` 关联;禁止用 `type='share'` 判断源分享。分享源按物理 `rootsessionid` 归入实验桶。
+
+### `loghubods.user_share_log_per5min`
+
+- 用途:实时分享、点击、回流和裂变归因。
+- 已用字段与离线分享表一致;行为路径展示时可把 `type` 别名为 `businesstype`,但产品效率回流的源分享/点击判断必须分别使用 `topic='share'` 和 `topic='click'`。
+- 使用物理 `rootsessionid`,按 `dt` 的当日实时范围过滤。
+
+## 简单事件日志
+
+### `loghubods.simpleevent_log`
+
+- 用途:离线页面曝光、按钮、截图、前后台切换等通用事件,也是广告风险事件链来源。
+- 已用字段:`dt`、`machinecode`、`loginuid`、`clienttimestamp`、`apptype`、`versioncode`、`businesstype`、`eventid`、`system`、`pagesource`、`endroutepath`、`objecttype`、`objectid`、`videoid`、`sessionid`、`subsessionid`、`extparams`。
+- `isAdPlaying` 和 `creativeCode` 从 `extparams` 对应 JSON 字段解析;不要把它们当作已确认物理列。
+
+### `loghubods.simpleevent_log_flow`
+
+- 用途:通用事件的短期实时窗口。
+- 已用字段:`year`、`month`、`day`、`hour`、`machinecode`、`loginuid`、`clienttimestamp`、`apptype`、`versioncode`、`businesstype`、`eventid`、`pagesource`、`objecttype`、`videoid`、`sessionid`、`subsessionid`、`extparams`。
+- 该实时表没有 `endRoutePath`;移除这一条件得到的指标属于放宽口径,必须在结论中说明。
+
+## 广告日志
+
+### `loghubods.ad_action_log_own`
+
+- 用途:离线广告请求、加载、曝光、播放、关闭、落地页行为和风险事件链。
+- 已用字段:`dt`、`machinecode`、`loginuid`、`clienttimestamp`、`apptype`、`businesstype`、`eventid`、`pagesource`、`machineinfo`、`creativecode`、`headvideoid`、`hotsencetype`、`sessionid`、`subsessionid`、`pqtid`、`ownadsystemtype`。
+- 风险分析中 `ownadsystemtype='ownPlatform'` 表示自有平台广告;`pqtid` 连接落地页 view/hide 与转化排除。
+
+### `loghubods.ad_action_log_own_per5min`
+
+- 用途:广告行为实时窗口。
+- 已用字段与离线广告表基本一致,按 `dt` 时间范围过滤。
+
+## 操作日志与视频维表
+
+### `loghubods.operation_log_per5min`
+
+- 用途:实时操作事件;没有 `businesstype`,使用 `eventid` 识别操作。
+- 已用字段:`dt`、`machinecode`、`loginuid`、`clienttimestamp`、`apptype`、`versioncode`、`eventid`、`pagesource`、`rootpagesource`、`objectid`、`targetuid`、`networktype`、`options`、`extparams`、`machineinfo_system`、`sessionid`、`subsessionid`。
+
+### `videoods.dim_video`
+
+- 用途:从视频 ID 补充视频标题。
+- 已确认字段:`videoid BIGINT`(视频 ID)、`title STRING`(视频标题)。事件日志的视频 ID 为字符串时,关联前显式转换为兼容类型。
+
+## 已确认事件枚举
+
+| 来源 | 条件 | 中文含义 |
+|---|---|---|
+| 视频行为 | `businesstype='videoView'` | 视频曝光 |
+| 视频行为 | `businesstype='videoPlay'` | 视频播放 |
+| 视频行为 | `businesstype='videoShareFriend'` | 分享给好友 |
+| 视频行为 | `pagesource RLIKE 'user-videos-share$'` | 头部视频场景 |
+| 视频行为 | `pagesource RLIKE '(detail|category|recommend)$'` | 推荐场景 |
+| 广告 | `adRequest` / `adLoaded` / `adView` / `adPlay` / `adCloseBtnTap` | 广告请求/加载/曝光/播放/关闭 |
+| 广告 | `adUserCaptureScreen` | 广告落地页截图 |
+| 广告 | `adSelfLandingView` / `adSelfLandingHide` | 自有广告落地页进入/隐藏 |
+| 播放 | `videoPlaySuccess` / `videoPlaySlow` / `videoRealPlay` | 播放成功/播放卡顿/有效播放 |
+| 分享 | `topic='share'` / `topic='click'` | 源分享/点击分享卡片;通过 `shareid` 形成回流链路 |
+| 活跃 | `businesstype='path'` | 打开应用/启动路径记录 |
+| 简单事件 | `pageView` | 页面曝光,具体页面由 `pagesource` 判断 |
+| 简单事件 | `userCaptureScreen` | 用户截图 |
+| 简单事件 | `userPause` | 视频暂停 |
+| 简单事件 | `userActiveEnd` / `userActiveStart` | 小程序进入后台/前台 |
+| 简单事件 | `buttonClick` | 按钮点击,具体按钮由 `objecttype` 判断 |
+| 简单事件 | `eventid='107001'` / `eventid='107002'` | 视频封面加载完成/失败 |
+| 任意来源 | `eventid='130010'` | 广告组件加入页面 |
+
+未在此表确认的事件不要自行翻译;保留原始事件值,或向用户确认后再补充目录。

+ 58 - 0
.agents/skills/query-user-behavior-path/SKILL.md

@@ -0,0 +1,58 @@
+---
+name: query-user-behavior-path
+description: 从 loghubods 视频、广告、播放、简单事件、用户活跃和分享日志中查询、合并并解释单个用户的完整离线或明确指定的实时行为时间线。用于按 mid 或 machinecode 检查完整行为路径、生成按时间排序的 Excel、补充中文行为定义,或诊断用户旅程。
+---
+
+# 查询用户完整行为路径
+
+将 `SKILL_DIR` 解析为当前 `SKILL.md` 所在的安装目录。所有命令都通过该目录调用随 Skill 分发的脚本;不得假定作者的工作区路径。
+
+## 运行环境
+
+依赖 `pyodps`、`pandas` 和 `openpyxl`。随 Skill 分发的 ODPS 客户端读取 `ODPS_ACCESS_ID`、`ODPS_ACCESS_SECRET`、`ODPS_PROJECT` 和 `ODPS_ENDPOINT`。不得把凭证复制到 Skill、查询输出或回复中。
+
+## 必要输入
+
+- 用户标识:视频/播放日志使用 `mid`,广告/简单事件/用户活跃日志使用 `machinecode`,值相同。
+- 日期:`yyyyMMdd`。
+- 可选 `apptype`;未提供时查询全部产品,不添加 `apptype` 条件。只有用户明确给出时才按该值过滤。
+- 输出目录,默认当前目录。
+
+## 离线流程
+
+1. 阅读 [logs-and-definitions.md](references/logs-and-definitions.md)。
+2. 运行:
+
+   `python3 "$SKILL_DIR/scripts/user_timeline.py" <mid_or_machinecode> <yyyyMMdd> [apptype] --output-dir <目录>`
+
+3. 输出固定工作表 `行为路径`:`产品apptype`、`用户ID`、`北京时间`、`来源`、`机型信息`、`事件类型`、`行为`、`事件ID`、`endRoutePath`、`objecttype`、`pagesource`、`topic`、`shareid`、`isAdPlaying`、`creativeCode`、`视频id`、`视频标题`、`hotsencetype`、`path`、`subsessionid`、`sessionid`。北京时间必须为第 3 列,使用秒级格式 `yyyy/mm/dd hh:mm:ss`。
+4. 报告输出路径、总行数、各来源行数以及行数为零的来源。
+5. 校验时间顺序、Excel 时间格式、技术事件排除和中文行为定义。
+6. 仅当用户明确要求上传飞书时,使用 `$feishu-data-publisher` 发布最终 Excel;未明确要求时只保留本地结果。
+
+## 实时流程
+
+只有用户明确要求实时数据时才运行:
+
+`python3 "$SKILL_DIR/scripts/user_timeline_realtime.py" <mid_or_machinecode> [yyyyMMdd] [apptype] --output-dir <目录>`
+
+必须说明结果只是当前可用实时数据。`simpleevent_log_flow` 保留周期短,不能代替完整离线日回灌。
+
+## 批量实时流程
+
+输入 CSV 必须包含 `用户标识`,可以包含 `命中策略`。运行:
+
+`python3 "$SKILL_DIR/scripts/user_timeline_realtime_batch.py" <用户清单.csv> [yyyyMMdd] [apptype] --output-dir <目录>`
+
+输出包含可筛选的 `实时行为路径` 工作表和 `用户摘要` 工作表;摘要必须保留当前事件数为零的输入用户。
+
+## 安全与校验
+
+- 除非用户明确要求实时数据,否则只使用离线表。
+- 离线查询必须合并 video、ad、play、simpleevent、useractive、share 六张表。每张表都必须按日期、用户标识和非空 `clienttimestamp` 过滤;仅当用户明确给出 `apptype` 时,各表才添加相同的 `apptype` 条件。
+- 各表事件使用 `UNION ALL` 保留,不得跨表去重。
+- 先按原始时间戳稳定排序,再转换为北京时间。
+- 不向终端打印完整用户事件表。
+- 排除 `deviceId`、`openGIdSuccess`、`buttonView`、`systemInfo`、`videoPlayCancel`、`windowView`;仅 simpleevent 额外排除 `openGIdError`。
+- 只有参考资料或已确认源码支持时才补充 `行为` 定义;不得使用旧列名“中文行为定义”,也不得输出 `auto_enter`、`newPage`、`pageStatus`。
+- 执行前运行 `python3 -m py_compile "$SKILL_DIR"/scripts/*.py`。

+ 4 - 0
.agents/skills/query-user-behavior-path/agents/openai.yaml

@@ -0,0 +1,4 @@
+interface:
+  display_name: "查询用户完整行为路径"
+  short_description: "查询并解释跨视频广告播放与活跃日志的完整用户行为时间线"
+  default_prompt: "使用 $query-user-behavior-path 查询并解释指定用户的完整行为时间线。"

+ 84 - 0
.agents/skills/query-user-behavior-path/references/logs-and-definitions.md

@@ -0,0 +1,84 @@
+# Logs and definitions
+
+## Offline logs
+
+| Source label | Table | Identifier | Time field | Role |
+|---|---|---|---|---|
+| `video` | `loghubods.video_action_log_applet` | `mid` | `clienttimestamp` | Video exposure and playback actions |
+| `ad` | `loghubods.ad_action_log_own` | `machinecode` | `clienttimestamp` | Ad lifecycle actions |
+| `play` | `loghubods.video_play_log` | `mid` | `clienttimestamp` | Playback logs |
+| `simpleevent` | `loghubods.simpleevent_log` | `machinecode` | `clienttimestamp` | Independent user and app events |
+| `useractive` | `loghubods.useractive_log` | `machinecode` | `clienttimestamp` | User-active path records |
+| `share` | `loghubods.user_share_log` | `machinecode` | `clienttimestamp` | Share records; `shareid` identifies the sharing user |
+
+The same supplied identifier is used as both `mid` and `machinecode`.
+
+## Explicit real-time table map
+
+| Source label | Real-time table | Date filter | Notes |
+|---|---|---|---|
+| `video` | `loghubods.video_action_log_per5min` | `dt` from `YYYYMMDD000000` to `YYYYMMDD235959` | There is no `video_action_log_applet_per5min`. |
+| `ad` | `loghubods.ad_action_log_own_per5min` | Same 5-minute `dt` range | |
+| `play` | `loghubods.video_play_log_per5min` | Same 5-minute `dt` range | |
+| `simpleevent` | `loghubods.simpleevent_log_flow` | `year/month/day` (optionally hour) | No `_per5min` table; short real-time retention. |
+| `useractive` | `loghubods.useractive_log_per5min` | Same 5-minute `dt` range | |
+| `share` | `loghubods.user_share_log_per5min` | Same 5-minute `dt` range | `type` is the event type; output `topic`, `shareid` |
+| `operation` | `loghubods.operation_log_per5min` | Same 5-minute `dt` range | No `businesstype`; use `eventid` and retain operation parameters |
+
+Use this map only when the user explicitly asks for real-time data. The result is the currently available real-time window; it is not a substitute for a complete offline daily path.
+
+## Output fields
+
+Offline single-user files begin with `产品apptype`, `用户ID`, `北京时间` (in that order). `产品apptype` is the actual value on each event and is not restricted unless the query explicitly requests one. `北京时间` uses `yyyy/mm/dd hh:mm:ss`.
+
+The remaining offline columns are: `来源`, `机型信息`, `事件类型`, `行为`, `事件ID`, `endRoutePath`, `objecttype`, `pagesource`, `topic`, `shareid`, `isAdPlaying`, `creativeCode`, `视频id`, `视频标题`, `hotsencetype`, `path`, `subsessionid`, `sessionid`.
+
+`endRoutePath` is the page present at a foreground/background transition. `机型信息` normalizes available values to `iOS` or `Android`; sources without a system field are filled only when that user has one unique identified system on the same day. `pagesource` is the behavior scene. `shareid` is the sharing user's ID.
+
+For offline `simpleevent_log`, output `endRoutePath` and parse `isAdPlaying` and `creativeCode` from `extparams`; force these exact camel-case column names before writing Excel because ODPS aliases can be returned in lowercase.
+
+## Exclude
+
+Do not output rows where `businesstype` is one of:
+
+- `deviceId`
+- `openGIdSuccess`
+- `buttonView`
+- `systemInfo`
+- `videoPlayCancel`
+- `windowView`
+
+For source `simpleevent` only, also exclude `openGIdError`.
+
+## Confirmed Chinese definitions
+
+| Source | businesstype | pagesource condition | 行为 | Evidence |
+|---|---|---|---|---|
+| `video` | `videoView` | ends with `user-videos-share` | 头部视频曝光 | Player marks this path as `headVideo` |
+| `video` | `videoPlay` | ends with `user-videos-share` | 头部视频播放 | Same scene rule |
+| `ad` | `adRequest` | any | 广告请求 | User-confirmed terminology |
+| `ad` | `adLoaded` | any | 广告加载 | User-confirmed terminology |
+| `ad` | `adView` | any | 广告曝光 | User-confirmed terminology |
+| `ad` | `adPlay` | any | 广告播放 | User-confirmed terminology |
+| `ad` | `adCloseBtnTap` | any | 广告关闭 | User-confirmed terminology |
+| `play` | `videoPlaySuccess` | any | 播放成功 | User-confirmed terminology |
+| `play` | `videoPlaySlow` | any | 播放卡顿 | User-confirmed terminology |
+| `play` | `videoRealPlay` | any | 有效播放 | User-confirmed terminology |
+| `share` | `topic=click` | any | 点击卡片 | User-confirmed terminology |
+| `useractive` | `path` | any | 打开应用 | User-confirmed terminology |
+| `simpleevent` | `pageView` | ends with `category_55` | 首页分类页曝光(分类55) | `pages/category.js` |
+| `simpleevent` | `pageView` | ends with `user-videos-share` | 视频分享页曝光 | `PageSource.videoShare` |
+| `simpleevent` | `detailRequest` | ends with `user-videos-share` | 视频分享页详情接口请求成功 | Event ID `22022221` |
+| `simpleevent` | `userCaptureScreen` | ends with `user-videos-share` | 用户在视频分享页截图 | Event ID `550001` |
+| `simpleevent` | `userPause` | any | 视频暂停 | Player `reportPaused` |
+| `simpleevent` | `userActiveEnd` | any | 小程序进入后台 | App `onHide` |
+| `simpleevent` | `userActiveStart` | any | 小程序进入前台 | User-confirmed terminology |
+| `simpleevent` | `decideSharePageJump` | any | 准备跳转 | User-confirmed terminology |
+| `simpleevent` | `jumpSwiperPage` | any | 跳转到沉浸式 | User-confirmed terminology |
+| `simpleevent` | `buttonClick` + `objecttype=videoBackIcon` | any | 点击视频页返回图标 | `return-back-icon.tap` |
+| `simpleevent` | `buttonClick` + `objecttype=weapp_quitbutton` | any | 从分享视频页返回首页 | `page-swiper.onback` |
+| `simpleevent` | eventid `107001` | any | 视频封面加载完成 | `logEnum.videoCoverLoad` |
+| `simpleevent` | eventid `107002` | any | 视频封面加载失败 | `logEnum.videoCoverLoadError` |
+| any | eventid `130010` | any | 广告组件加入页面 | `logEnum.adAttached` |
+
+Leave any other definition blank unless the user confirms it or source code supplies evidence.

+ 61 - 0
.agents/skills/query-user-behavior-path/scripts/odps_module.py

@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+"""Minimal PyODPS client configured only through environment variables."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+from odps import ODPS, options
+
+
+ENV_ACCESS_ID = "ODPS_ACCESS_ID"
+ENV_ACCESS_SECRET = "ODPS_ACCESS_SECRET"
+ENV_PROJECT = "ODPS_PROJECT"
+ENV_ENDPOINT = "ODPS_ENDPOINT"
+
+
+def require_env(name: str) -> str:
+    value = os.getenv(name)
+    if not value:
+        raise RuntimeError(f"Missing required environment variable: {name}")
+    return value
+
+
+class ODPSClient:
+    def __init__(self, project: str | None = None, endpoint: str | None = None):
+        access_id = require_env(ENV_ACCESS_ID)
+        access_secret = require_env(ENV_ACCESS_SECRET)
+        project_name = project or require_env(ENV_PROJECT)
+        endpoint_url = endpoint or require_env(ENV_ENDPOINT)
+
+        options.connect_timeout = 60
+        options.read_timeout = 1200
+        options.retry_times = 3
+
+        self.odps = ODPS(
+            access_id,
+            access_secret,
+            project=project_name,
+            endpoint=endpoint_url,
+        )
+
+    def execute_sql(self, sql: str):
+        if not sql.strip():
+            raise ValueError("SQL must not be empty")
+
+        instance = self.odps.run_sql(
+            sql,
+            hints={"odps.sql.submit.mode": "script"},
+        )
+        print(f"[ODPS] InstanceId: {instance.id}", flush=True)
+        instance.wait_for_success()
+        with instance.open_reader(tunnel=True) as reader:
+            return reader.to_pandas()
+
+    def execute_sql_result_save_file(self, sql: str, output_file: str | Path):
+        output_path = Path(output_file).expanduser()
+        output_path.parent.mkdir(parents=True, exist_ok=True)
+        data = self.execute_sql(sql)
+        data.to_csv(output_path, index=False, encoding="utf-8-sig")
+        return output_path

+ 28 - 0
.agents/skills/query-user-behavior-path/scripts/run_sql.py

@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+"""Execute an explicit SQL file with the bundled environment-based ODPS client."""
+
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+from odps_module import ODPSClient
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Run an ODPS SQL file and save UTF-8 CSV")
+    parser.add_argument("sql_file", type=Path)
+    parser.add_argument("output_file", type=Path)
+    parser.add_argument("--project", help="Override ODPS_PROJECT for this query")
+    args = parser.parse_args()
+
+    sql = args.sql_file.expanduser().read_text(encoding="utf-8")
+    output_path = ODPSClient(project=args.project).execute_sql_result_save_file(
+        sql,
+        args.output_file,
+    )
+    print(f"[CSV] {output_path.resolve()}", flush=True)
+
+
+if __name__ == "__main__":
+    main()

+ 207 - 0
.agents/skills/query-user-behavior-path/scripts/user_timeline.py

@@ -0,0 +1,207 @@
+#!/usr/bin/env python3
+# coding=utf-8
+"""单用户完整行为时间线:六张离线日志合并为 Excel。"""
+import argparse
+from datetime import datetime
+from pathlib import Path
+import re
+
+import pandas as pd
+
+from odps_module import ODPSClient
+
+
+EXCLUDED_BUSINESSTYPES = {
+    "deviceId", "openGIdSuccess", "buttonView", "systemInfo", "videoPlayCancel", "windowView"
+}
+
+
+def sql_text(value):
+    return str(value).replace("'", "''")
+
+
+def build_apptype_filter(value):
+    return "" if value is None else f" AND apptype='{sql_text(value)}'"
+
+
+def safe_name(value):
+    return re.sub(r"[^A-Za-z0-9_-]", "_", str(value))
+
+
+SQL_ALL = """
+WITH v AS (
+    SELECT CAST(clienttimestamp AS BIGINT) AS ts, apptype AS 产品apptype, 'video' AS 来源,
+           businesstype, CAST(NULL AS STRING) AS eventid, machineinfo_system AS 系统, pagesource, CAST(NULL AS STRING) AS endRoutePath, CAST(NULL AS STRING) AS objecttype,
+           CAST(NULL AS STRING) AS topic, CAST(NULL AS STRING) AS shareid,
+           CAST(NULL AS STRING) AS isAdPlaying, CAST(NULL AS STRING) AS creativeCode, videoid AS 视频id,
+           CAST(NULL AS STRING) AS hotsencetype, CAST(NULL AS STRING) AS path, subsessionid, sessionid
+    FROM loghubods.video_action_log_applet
+    WHERE dt='{day}'{apptype_filter} AND mid='{mc}'
+      AND businesstype <> 'videoPreView'
+      AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),
+a AS (
+    SELECT CAST(clienttimestamp AS BIGINT) AS ts, apptype AS 产品apptype, 'ad' AS 来源,
+           businesstype, CAST(eventid AS STRING) AS eventid, GET_JSON_OBJECT(machineinfo, '$.system') AS 系统, pagesource, CAST(NULL AS STRING) AS endRoutePath, CAST(NULL AS STRING) AS objecttype,
+           CAST(NULL AS STRING) AS topic, CAST(NULL AS STRING) AS shareid,
+           CAST(NULL AS STRING) AS isAdPlaying, creativecode AS creativeCode, headvideoid AS 视频id,
+           hotsencetype, CAST(NULL AS STRING) AS path, subsessionid, sessionid
+    FROM loghubods.ad_action_log_own
+    WHERE dt='{day}'{apptype_filter} AND machinecode='{mc}'
+      AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),
+p AS (
+    SELECT CAST(clienttimestamp AS BIGINT) AS ts, apptype AS 产品apptype, 'play' AS 来源,
+           businesstype, CAST(eventid AS STRING) AS eventid, machineinfo_system AS 系统, pagesource, CAST(NULL AS STRING) AS endRoutePath, CAST(NULL AS STRING) AS objecttype,
+           CAST(NULL AS STRING) AS topic, CAST(NULL AS STRING) AS shareid,
+           CAST(NULL AS STRING) AS isAdPlaying, CAST(NULL AS STRING) AS creativeCode, videoid AS 视频id,
+           CAST(NULL AS STRING) AS hotsencetype, CAST(NULL AS STRING) AS path, subsessionid, sessionid
+    FROM loghubods.video_play_log
+    WHERE dt='{day}'{apptype_filter} AND mid='{mc}'
+      AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),
+s AS (
+    SELECT CAST(clienttimestamp AS BIGINT) AS ts, apptype AS 产品apptype, 'simpleevent' AS 来源,
+           businesstype, CAST(eventid AS STRING) AS eventid, system AS 系统, pagesource, endroutepath AS endRoutePath, objecttype,
+           CAST(NULL AS STRING) AS topic, CAST(NULL AS STRING) AS shareid,
+           GET_JSON_OBJECT(extparams, '$.isAdPlaying') AS isAdPlaying,
+           GET_JSON_OBJECT(extparams, '$.creativeCode') AS creativeCode, videoid AS 视频id,
+           CAST(NULL AS STRING) AS hotsencetype, CAST(NULL AS STRING) AS path, subsessionid, sessionid
+    FROM loghubods.simpleevent_log
+    WHERE dt='{day}'{apptype_filter} AND machinecode='{mc}'
+      AND (businesstype IS NULL OR businesstype <> 'openGIdError')
+      AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),
+u AS (
+    SELECT CAST(clienttimestamp AS BIGINT) AS ts, apptype AS 产品apptype, 'useractive' AS 来源,
+           businesstype, CAST(eventid AS STRING) AS eventid, system AS 系统, pagesource, CAST(NULL AS STRING) AS endRoutePath, CAST(NULL AS STRING) AS objecttype,
+           CAST(NULL AS STRING) AS topic, CAST(NULL AS STRING) AS shareid,
+           CAST(NULL AS STRING) AS isAdPlaying, CAST(NULL AS STRING) AS creativeCode, CAST(NULL AS STRING) AS 视频id,
+           CAST(NULL AS STRING) AS hotsencetype, path, subsessionid, sessionid
+    FROM loghubods.useractive_log
+    WHERE dt='{day}'{apptype_filter} AND machinecode='{mc}'
+      AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),
+r AS (
+    SELECT CAST(clienttimestamp AS BIGINT) AS ts, apptype AS 产品apptype, 'share' AS 来源,
+           type AS businesstype, CAST(eventid AS STRING) AS eventid, CAST(NULL AS STRING) AS 系统, pagesource, CAST(NULL AS STRING) AS endRoutePath, CAST(NULL AS STRING) AS objecttype,
+           topic, shareid,
+           CAST(NULL AS STRING) AS isAdPlaying, CAST(NULL AS STRING) AS creativeCode, CAST(NULL AS STRING) AS 视频id,
+           CAST(NULL AS STRING) AS hotsencetype, CAST(NULL AS STRING) AS path, subsessionid, sessionid
+    FROM loghubods.user_share_log
+    WHERE dt='{day}'{apptype_filter} AND machinecode='{mc}'
+      AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),
+t AS (
+    SELECT * FROM v UNION ALL SELECT * FROM a UNION ALL SELECT * FROM p
+    UNION ALL SELECT * FROM s UNION ALL SELECT * FROM u UNION ALL SELECT * FROM r
+)
+SELECT t.ts, t.产品apptype, t.来源, t.businesstype, t.eventid, t.系统, t.endRoutePath, t.objecttype, t.pagesource, t.topic, t.shareid,
+       t.isAdPlaying, t.creativeCode, t.视频id, b.title AS 视频标题,
+       t.hotsencetype, t.path, t.subsessionid, t.sessionid
+FROM t
+LEFT JOIN videoods.dim_video b ON t.视频id = b.videoid
+ORDER BY t.ts
+"""
+
+
+def text(value):
+    return "" if pd.isna(value) else str(value)
+
+
+def normalize_device_system(value):
+    value = text(value).lower()
+    if "ios" in value or "iphone" in value or "ipad" in value:
+        return "iOS"
+    if "android" in value:
+        return "Android"
+    return ""
+
+
+def add_device_info(df, group_column=None):
+    df["机型信息"] = df["系统"].map(normalize_device_system)
+    if group_column is None:
+        known = df.loc[df["机型信息"] != "", "机型信息"].drop_duplicates()
+        if len(known) == 1:
+            df.loc[df["机型信息"] == "", "机型信息"] = known.iloc[0]
+    else:
+        known = df.loc[df["机型信息"] != "", [group_column, "机型信息"]].drop_duplicates()
+        systems = known.groupby(group_column)["机型信息"].agg(lambda values: values.iloc[0] if len(values) == 1 else "")
+        df.loc[df["机型信息"] == "", "机型信息"] = df.loc[df["机型信息"] == "", group_column].map(systems).fillna("")
+    df = df.drop(columns="系统")
+    df.insert(df.columns.get_loc("来源") + 1, "机型信息", df.pop("机型信息"))
+    return df
+
+
+EVENTID_BEHAVIOR = {
+    "107001": "视频封面加载完成", "107002": "视频封面加载失败", "130010": "广告组件加入页面",
+    "22022221": "详情接口请求成功", "22022222": "详情接口请求失败", "550001": "用户截图",
+}
+
+
+def behavior_definition(row):
+    source = text(row["来源"])
+    businesstype = text(row["businesstype"])
+    pagesource = text(row["pagesource"])
+    eventid = text(row.get("eventid", ""))
+    objecttype = text(row.get("objecttype", ""))
+    topic = text(row.get("topic", ""))
+    is_head_video_page = pagesource.endswith("user-videos-share")
+    if source == "video" and is_head_video_page:
+        return {"videoView": "头部视频曝光", "videoPlay": "头部视频播放"}.get(businesstype, "")
+    if source == "ad":
+        return {"adRequest": "广告请求", "adLoaded": "广告加载", "adView": "广告曝光", "adPlay": "广告播放", "adCloseBtnTap": "广告关闭"}.get(businesstype, "")
+    if source == "play":
+        return {"videoPlaySuccess": "播放成功", "videoPlaySlow": "播放卡顿", "videoRealPlay": "有效播放"}.get(businesstype, "")
+    if source == "share" and topic == "click":
+        return "点击卡片"
+    if source == "useractive" and businesstype == "path":
+        return "打开应用"
+    if source == "simpleevent":
+        if businesstype == "buttonClick":
+            definition = {"videoBackIcon": "点击视频页返回图标", "weapp_quitbutton": "从分享视频页返回首页"}.get(objecttype, "")
+            if definition:
+                return definition
+        if businesstype == "pageView" and pagesource.endswith("category_55"):
+            return "首页分类页曝光(分类55)"
+        if is_head_video_page:
+            definition = {"pageView": "视频分享页曝光", "detailRequest": "视频分享页详情接口请求成功", "userCaptureScreen": "用户在视频分享页截图"}.get(businesstype, "")
+            if definition:
+                return definition
+        definition = {"userPause": "视频暂停", "userActiveEnd": "小程序进入后台", "userActiveStart": "小程序进入前台", "decideSharePageJump": "准备跳转", "jumpSwiperPage": "跳转到沉浸式"}.get(businesstype, "")
+        if definition:
+            return definition
+    return EVENTID_BEHAVIOR.get(eventid, "")
+
+
+def main():
+    parser = argparse.ArgumentParser(description="查询单用户离线行为时间线")
+    parser.add_argument("user_id", help="machinecode/mid")
+    parser.add_argument("date", help="yyyyMMdd")
+    parser.add_argument("apptype", nargs="?", default=None, help="可选;不传则查询全部产品")
+    parser.add_argument("--output-dir", type=Path, default=Path("."))
+    args = parser.parse_args()
+    datetime.strptime(args.date, "%Y%m%d")
+    df = ODPSClient().execute_sql(SQL_ALL.format(mc=sql_text(args.user_id), day=args.date, apptype_filter=build_apptype_filter(args.apptype)))
+    df = df[~df["businesstype"].isin(EXCLUDED_BUSINESSTYPES)].sort_values("ts", kind="stable").reset_index(drop=True)
+    df = df.rename(columns={"endroutepath": "endRoutePath", "isadplaying": "isAdPlaying", "creativecode": "creativeCode"})
+    df.insert(0, "北京时间", pd.to_datetime(df["ts"], unit="ms", utc=True).dt.tz_convert("Asia/Shanghai").dt.tz_localize(None))
+    df = add_device_info(df.drop(columns="ts"))
+    df.insert(df.columns.get_loc("businesstype") + 1, "行为", df.apply(behavior_definition, axis=1))
+    df = df.rename(columns={"businesstype": "事件类型", "eventid": "事件ID"})
+    df.insert(0, "用户ID", args.user_id)
+    df.insert(0, "产品apptype", df.pop("产品apptype"))
+    args.output_dir.mkdir(parents=True, exist_ok=True)
+    out = args.output_dir / f"timeline_{safe_name(args.user_id[-12:])}_{args.date}.xlsx"
+    with pd.ExcelWriter(out, engine="openpyxl", datetime_format="yyyy/mm/dd hh:mm:ss") as writer:
+        df.to_excel(writer, sheet_name="行为路径", index=False)
+        for cell in writer.sheets["行为路径"]["C"][1:]:
+            cell.number_format = "yyyy/mm/dd hh:mm:ss"
+    counts = df["来源"].value_counts().to_dict()
+    for source in ["video", "ad", "play", "simpleevent", "useractive", "share"]:
+        print(f"[ROWS] {source}={counts.get(source, 0)}", flush=True)
+    print(f"[XLSX] 日期={args.date} 事件数={len(df)} -> {out.resolve()}", flush=True)
+
+
+if __name__ == "__main__":
+    main()

+ 133 - 0
.agents/skills/query-user-behavior-path/scripts/user_timeline_realtime.py

@@ -0,0 +1,133 @@
+#!/usr/bin/env python3
+# coding=utf-8
+"""单用户实时行为时间线:当前可用实时/5分钟日志。"""
+import argparse
+from datetime import datetime
+from pathlib import Path
+
+import pandas as pd
+
+from odps_module import ODPSClient
+from user_timeline import EXCLUDED_BUSINESSTYPES, add_device_info, behavior_definition, build_apptype_filter, safe_name, sql_text
+
+
+SQL_REALTIME = """
+WITH v AS (
+    SELECT CAST(clienttimestamp AS BIGINT) AS ts, apptype AS 产品apptype, 'video' AS 来源,
+           businesstype, CAST(NULL AS STRING) AS eventid, machineinfo_system AS 系统, pagesource, CAST(NULL AS STRING) AS endRoutePath, CAST(NULL AS STRING) AS objecttype, videoid AS 视频id,
+           CAST(NULL AS STRING) AS topic, CAST(NULL AS STRING) AS shareid, CAST(NULL AS STRING) AS creativeCode,
+           CAST(NULL AS STRING) AS rootPageSource, CAST(NULL AS STRING) AS objectId, CAST(NULL AS STRING) AS targetUid,
+           CAST(NULL AS STRING) AS networkType, CAST(NULL AS STRING) AS operationOptions, CAST(NULL AS STRING) AS operationExtParams,
+           CAST(NULL AS STRING) AS hotsencetype, CAST(NULL AS STRING) AS path, subsessionid, sessionid
+    FROM loghubods.video_action_log_per5min
+    WHERE dt >= '{dt_start}' AND dt <= '{dt_end}'{apptype_filter} AND mid='{mc}'
+      AND businesstype <> 'videoPreView' AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),
+a AS (
+    SELECT CAST(clienttimestamp AS BIGINT) AS ts, apptype AS 产品apptype, 'ad' AS 来源,
+           businesstype, eventid, GET_JSON_OBJECT(machineinfo, '$.system') AS 系统, pagesource, CAST(NULL AS STRING) AS endRoutePath, CAST(NULL AS STRING) AS objecttype, headvideoid AS 视频id,
+           CAST(NULL AS STRING) AS topic, CAST(NULL AS STRING) AS shareid, creativecode AS creativeCode,
+           CAST(NULL AS STRING) AS rootPageSource, CAST(NULL AS STRING) AS objectId, CAST(NULL AS STRING) AS targetUid,
+           CAST(NULL AS STRING) AS networkType, CAST(NULL AS STRING) AS operationOptions, CAST(NULL AS STRING) AS operationExtParams,
+           hotsencetype, CAST(NULL AS STRING) AS path, subsessionid, sessionid
+    FROM loghubods.ad_action_log_own_per5min
+    WHERE dt >= '{dt_start}' AND dt <= '{dt_end}'{apptype_filter} AND machinecode='{mc}'
+      AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),
+p AS (
+    SELECT CAST(clienttimestamp AS BIGINT) AS ts, apptype AS 产品apptype, 'play' AS 来源,
+           businesstype, eventid, machineinfo_system AS 系统, pagesource, CAST(NULL AS STRING) AS endRoutePath, CAST(NULL AS STRING) AS objecttype, videoid AS 视频id,
+           CAST(NULL AS STRING) AS topic, CAST(NULL AS STRING) AS shareid, CAST(NULL AS STRING) AS creativeCode,
+           CAST(NULL AS STRING) AS rootPageSource, CAST(NULL AS STRING) AS objectId, CAST(NULL AS STRING) AS targetUid,
+           CAST(NULL AS STRING) AS networkType, CAST(NULL AS STRING) AS operationOptions, CAST(NULL AS STRING) AS operationExtParams,
+           CAST(NULL AS STRING) AS hotsencetype, CAST(NULL AS STRING) AS path, subsessionid, sessionid
+    FROM loghubods.video_play_log_per5min
+    WHERE dt >= '{dt_start}' AND dt <= '{dt_end}'{apptype_filter} AND mid='{mc}'
+      AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),
+s AS (
+    SELECT CAST(clienttimestamp AS BIGINT) AS ts, apptype AS 产品apptype, 'simpleevent' AS 来源,
+           businesstype, eventid, system AS 系统, pagesource, CAST(NULL AS STRING) AS endRoutePath, objecttype, videoid AS 视频id,
+           CAST(NULL AS STRING) AS topic, CAST(NULL AS STRING) AS shareid, CAST(NULL AS STRING) AS creativeCode,
+           CAST(NULL AS STRING) AS rootPageSource, CAST(NULL AS STRING) AS objectId, CAST(NULL AS STRING) AS targetUid,
+           CAST(NULL AS STRING) AS networkType, CAST(NULL AS STRING) AS operationOptions, CAST(NULL AS STRING) AS operationExtParams,
+           CAST(NULL AS STRING) AS hotsencetype, CAST(NULL AS STRING) AS path, subsessionid, sessionid
+    FROM loghubods.simpleevent_log_flow
+    WHERE year='{year}' AND month='{month}' AND day='{day_of_month}'{apptype_filter} AND machinecode='{mc}'
+      AND (businesstype IS NULL OR businesstype <> 'openGIdError') AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),
+u AS (
+    SELECT CAST(clienttimestamp AS BIGINT) AS ts, apptype AS 产品apptype, 'useractive' AS 来源,
+           businesstype, eventid, system AS 系统, pagesource, CAST(NULL AS STRING) AS endRoutePath, CAST(NULL AS STRING) AS objecttype, CAST(NULL AS STRING) AS 视频id,
+           CAST(NULL AS STRING) AS topic, CAST(NULL AS STRING) AS shareid, CAST(NULL AS STRING) AS creativeCode,
+           CAST(NULL AS STRING) AS rootPageSource, CAST(NULL AS STRING) AS objectId, CAST(NULL AS STRING) AS targetUid,
+           CAST(NULL AS STRING) AS networkType, CAST(NULL AS STRING) AS operationOptions, CAST(NULL AS STRING) AS operationExtParams,
+           CAST(NULL AS STRING) AS hotsencetype, path, subsessionid, sessionid
+    FROM loghubods.useractive_log_per5min
+    WHERE dt >= '{dt_start}' AND dt <= '{dt_end}'{apptype_filter} AND machinecode='{mc}'
+      AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),
+r AS (
+    SELECT CAST(clienttimestamp AS BIGINT) AS ts, apptype AS 产品apptype, 'share' AS 来源,
+           type AS businesstype, eventid, CAST(NULL AS STRING) AS 系统, pagesource, CAST(NULL AS STRING) AS endRoutePath, CAST(NULL AS STRING) AS objecttype, CAST(NULL AS STRING) AS 视频id,
+           topic, shareid, CAST(NULL AS STRING) AS creativeCode,
+           rootpagesource AS rootPageSource, shareobjectid AS objectId, CAST(NULL AS STRING) AS targetUid,
+           CAST(NULL AS STRING) AS networkType, CAST(NULL AS STRING) AS operationOptions, CAST(NULL AS STRING) AS operationExtParams,
+           CAST(NULL AS STRING) AS hotsencetype, CAST(NULL AS STRING) AS path, subsessionid, sessionid
+    FROM loghubods.user_share_log_per5min
+    WHERE dt >= '{dt_start}' AND dt <= '{dt_end}'{apptype_filter} AND machinecode='{mc}'
+      AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),
+o AS (
+    SELECT CAST(clienttimestamp AS BIGINT) AS ts, apptype AS 产品apptype, 'operation' AS 来源,
+           CAST(NULL AS STRING) AS businesstype, eventid, machineinfo_system AS 系统, pagesource, CAST(NULL AS STRING) AS endRoutePath, CAST(NULL AS STRING) AS objecttype, CAST(NULL AS STRING) AS 视频id,
+           CAST(NULL AS STRING) AS topic, CAST(NULL AS STRING) AS shareid, CAST(NULL AS STRING) AS creativeCode,
+           rootpagesource AS rootPageSource, objectid AS objectId, targetuid AS targetUid,
+           networktype AS networkType, options AS operationOptions, extparams AS operationExtParams,
+           CAST(NULL AS STRING) AS hotsencetype, CAST(NULL AS STRING) AS path, subsessionid, sessionid
+    FROM loghubods.operation_log_per5min
+    WHERE dt >= '{dt_start}' AND dt <= '{dt_end}'{apptype_filter} AND machinecode='{mc}'
+      AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),
+t AS (
+    SELECT * FROM v UNION ALL SELECT * FROM a UNION ALL SELECT * FROM p UNION ALL SELECT * FROM s
+    UNION ALL SELECT * FROM u UNION ALL SELECT * FROM r UNION ALL SELECT * FROM o
+)
+SELECT t.ts, t.产品apptype, t.来源, t.businesstype, t.eventid, t.系统, t.endRoutePath, t.objecttype, t.pagesource, t.topic, t.shareid, t.creativeCode, t.视频id, b.title AS 视频标题,
+       t.rootPageSource, t.objectId, t.targetUid, t.networkType, t.operationOptions, t.operationExtParams,
+       t.hotsencetype, t.path, t.subsessionid, t.sessionid
+FROM t LEFT JOIN videoods.dim_video b ON t.视频id=b.videoid
+ORDER BY t.ts
+"""
+
+
+def main():
+    parser = argparse.ArgumentParser(description="查询单用户实时行为时间线")
+    parser.add_argument("user_id", help="machinecode/mid")
+    parser.add_argument("date", nargs="?", default=datetime.now().strftime("%Y%m%d"), help="yyyyMMdd")
+    parser.add_argument("apptype", nargs="?", default=None, help="可选;不传则查询全部产品")
+    parser.add_argument("--output-dir", type=Path, default=Path("."))
+    args = parser.parse_args()
+    query_date = datetime.strptime(args.date, "%Y%m%d")
+    sql_args = {"mc": sql_text(args.user_id), "apptype_filter": build_apptype_filter(args.apptype), "dt_start": f"{args.date}000000", "dt_end": f"{args.date}235959", "year": query_date.strftime("%Y"), "month": query_date.strftime("%m"), "day_of_month": query_date.strftime("%d")}
+    df = ODPSClient().execute_sql(SQL_REALTIME.format(**sql_args))
+    df = df[~df["businesstype"].isin(EXCLUDED_BUSINESSTYPES)].sort_values("ts", kind="stable").reset_index(drop=True)
+    df = df.rename(columns={"creativecode": "creativeCode", "endroutepath": "endRoutePath", "rootpagesource": "rootPageSource", "objectid": "objectId", "targetuid": "targetUid", "networktype": "networkType", "operationoptions": "operationOptions", "operationextparams": "operationExtParams"})
+    df.insert(0, "北京时间", pd.to_datetime(df["ts"], unit="ms", utc=True).dt.tz_convert("Asia/Shanghai").dt.tz_localize(None))
+    df = add_device_info(df.drop(columns="ts"))
+    df.insert(df.columns.get_loc("businesstype") + 1, "行为", df.apply(behavior_definition, axis=1))
+    df = df.rename(columns={"businesstype": "事件类型", "eventid": "事件ID"})
+    df.insert(0, "用户ID", args.user_id)
+    df.insert(0, "产品apptype", df.pop("产品apptype"))
+    args.output_dir.mkdir(parents=True, exist_ok=True)
+    out = args.output_dir / f"timeline_realtime_{safe_name(args.user_id[-12:])}_{args.date}.xlsx"
+    with pd.ExcelWriter(out, engine="openpyxl", datetime_format="yyyy/mm/dd hh:mm:ss") as writer:
+        df.to_excel(writer, sheet_name="实时行为路径", index=False)
+        for cell in writer.sheets["实时行为路径"]["C"][1:]:
+            cell.number_format = "yyyy/mm/dd hh:mm:ss"
+    print("注意:simpleevent_log_flow 仅保留当前短实时窗口。")
+    print(f"[XLSX] 日期={args.date} 事件数={len(df)} -> {out.resolve()}", flush=True)
+
+
+if __name__ == "__main__":
+    main()

+ 104 - 0
.agents/skills/query-user-behavior-path/scripts/user_timeline_realtime_batch.py

@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+# coding=utf-8
+"""多个实时用户的行为时间线,输出可筛选的单一 Excel。"""
+import argparse
+from datetime import datetime
+from pathlib import Path
+
+import pandas as pd
+
+from odps_module import ODPSClient
+from user_timeline import EXCLUDED_BUSINESSTYPES, add_device_info, behavior_definition, build_apptype_filter, safe_name, sql_text
+
+
+SQL_REALTIME_BATCH = """
+WITH v AS (
+ SELECT CAST(clienttimestamp AS BIGINT) ts,apptype 产品apptype,mid 用户标识,'video' 来源,businesstype,CAST(NULL AS STRING) eventid,machineinfo_system 系统,pagesource,CAST(NULL AS STRING) endRoutePath,CAST(NULL AS STRING) objecttype,CAST(NULL AS STRING) topic,CAST(NULL AS STRING) shareid,CAST(NULL AS STRING) creativeCode,videoid 视频id,CAST(NULL AS STRING) rootPageSource,CAST(NULL AS STRING) objectId,CAST(NULL AS STRING) targetUid,CAST(NULL AS STRING) networkType,CAST(NULL AS STRING) operationOptions,CAST(NULL AS STRING) operationExtParams,CAST(NULL AS STRING) hotsencetype,CAST(NULL AS STRING) path,subsessionid,sessionid
+ FROM loghubods.video_action_log_per5min WHERE dt>='{dt_start}' AND dt<='{dt_end}'{apptype_filter} AND mid IN ({mc_list}) AND businesstype<>'videoPreView' AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),a AS (
+ SELECT CAST(clienttimestamp AS BIGINT) ts,apptype 产品apptype,machinecode 用户标识,'ad' 来源,businesstype,eventid,GET_JSON_OBJECT(machineinfo,'$.system') 系统,pagesource,CAST(NULL AS STRING) endRoutePath,CAST(NULL AS STRING) objecttype,CAST(NULL AS STRING) topic,CAST(NULL AS STRING) shareid,creativecode creativeCode,headvideoid 视频id,CAST(NULL AS STRING) rootPageSource,CAST(NULL AS STRING) objectId,CAST(NULL AS STRING) targetUid,CAST(NULL AS STRING) networkType,CAST(NULL AS STRING) operationOptions,CAST(NULL AS STRING) operationExtParams,hotsencetype,CAST(NULL AS STRING) path,subsessionid,sessionid
+ FROM loghubods.ad_action_log_own_per5min WHERE dt>='{dt_start}' AND dt<='{dt_end}'{apptype_filter} AND machinecode IN ({mc_list}) AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),p AS (
+ SELECT CAST(clienttimestamp AS BIGINT) ts,apptype 产品apptype,mid 用户标识,'play' 来源,businesstype,eventid,machineinfo_system 系统,pagesource,CAST(NULL AS STRING) endRoutePath,CAST(NULL AS STRING) objecttype,CAST(NULL AS STRING) topic,CAST(NULL AS STRING) shareid,CAST(NULL AS STRING) creativeCode,videoid 视频id,CAST(NULL AS STRING) rootPageSource,CAST(NULL AS STRING) objectId,CAST(NULL AS STRING) targetUid,CAST(NULL AS STRING) networkType,CAST(NULL AS STRING) operationOptions,CAST(NULL AS STRING) operationExtParams,CAST(NULL AS STRING) hotsencetype,CAST(NULL AS STRING) path,subsessionid,sessionid
+ FROM loghubods.video_play_log_per5min WHERE dt>='{dt_start}' AND dt<='{dt_end}'{apptype_filter} AND mid IN ({mc_list}) AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),s AS (
+ SELECT CAST(clienttimestamp AS BIGINT) ts,apptype 产品apptype,machinecode 用户标识,'simpleevent' 来源,businesstype,eventid,system 系统,pagesource,CAST(NULL AS STRING) endRoutePath,objecttype,CAST(NULL AS STRING) topic,CAST(NULL AS STRING) shareid,CAST(NULL AS STRING) creativeCode,videoid 视频id,CAST(NULL AS STRING) rootPageSource,CAST(NULL AS STRING) objectId,CAST(NULL AS STRING) targetUid,CAST(NULL AS STRING) networkType,CAST(NULL AS STRING) operationOptions,CAST(NULL AS STRING) operationExtParams,CAST(NULL AS STRING) hotsencetype,CAST(NULL AS STRING) path,subsessionid,sessionid
+ FROM loghubods.simpleevent_log_flow WHERE year='{year}' AND month='{month}' AND day='{day_of_month}'{apptype_filter} AND machinecode IN ({mc_list}) AND (businesstype IS NULL OR businesstype<>'openGIdError') AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),u AS (
+ SELECT CAST(clienttimestamp AS BIGINT) ts,apptype 产品apptype,machinecode 用户标识,'useractive' 来源,businesstype,eventid,system 系统,pagesource,CAST(NULL AS STRING) endRoutePath,CAST(NULL AS STRING) objecttype,CAST(NULL AS STRING) topic,CAST(NULL AS STRING) shareid,CAST(NULL AS STRING) creativeCode,CAST(NULL AS STRING) 视频id,CAST(NULL AS STRING) rootPageSource,CAST(NULL AS STRING) objectId,CAST(NULL AS STRING) targetUid,CAST(NULL AS STRING) networkType,CAST(NULL AS STRING) operationOptions,CAST(NULL AS STRING) operationExtParams,CAST(NULL AS STRING) hotsencetype,path,subsessionid,sessionid
+ FROM loghubods.useractive_log_per5min WHERE dt>='{dt_start}' AND dt<='{dt_end}'{apptype_filter} AND machinecode IN ({mc_list}) AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),r AS (
+ SELECT CAST(clienttimestamp AS BIGINT) ts,apptype 产品apptype,machinecode 用户标识,'share' 来源,type businesstype,eventid,CAST(NULL AS STRING) 系统,pagesource,CAST(NULL AS STRING) endRoutePath,CAST(NULL AS STRING) objecttype,topic,shareid,CAST(NULL AS STRING) creativeCode,CAST(NULL AS STRING) 视频id,rootpagesource rootPageSource,shareobjectid objectId,CAST(NULL AS STRING) targetUid,CAST(NULL AS STRING) networkType,CAST(NULL AS STRING) operationOptions,CAST(NULL AS STRING) operationExtParams,CAST(NULL AS STRING) hotsencetype,CAST(NULL AS STRING) path,subsessionid,sessionid
+ FROM loghubods.user_share_log_per5min WHERE dt>='{dt_start}' AND dt<='{dt_end}'{apptype_filter} AND machinecode IN ({mc_list}) AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),o AS (
+ SELECT CAST(clienttimestamp AS BIGINT) ts,apptype 产品apptype,machinecode 用户标识,'operation' 来源,CAST(NULL AS STRING) businesstype,eventid,machineinfo_system 系统,pagesource,CAST(NULL AS STRING) endRoutePath,CAST(NULL AS STRING) objecttype,CAST(NULL AS STRING) topic,CAST(NULL AS STRING) shareid,CAST(NULL AS STRING) creativeCode,CAST(NULL AS STRING) 视频id,rootpagesource rootPageSource,objectid objectId,targetuid targetUid,networktype networkType,options operationOptions,extparams operationExtParams,CAST(NULL AS STRING) hotsencetype,CAST(NULL AS STRING) path,subsessionid,sessionid
+ FROM loghubods.operation_log_per5min WHERE dt>='{dt_start}' AND dt<='{dt_end}'{apptype_filter} AND machinecode IN ({mc_list}) AND clienttimestamp IS NOT NULL AND clienttimestamp<>''
+),t AS (SELECT * FROM v UNION ALL SELECT * FROM a UNION ALL SELECT * FROM p UNION ALL SELECT * FROM s UNION ALL SELECT * FROM u UNION ALL SELECT * FROM r UNION ALL SELECT * FROM o)
+SELECT t.ts,t.产品apptype,t.用户标识,t.来源,t.businesstype,t.eventid,t.系统,t.endRoutePath,t.objecttype,t.pagesource,t.topic,t.shareid,t.creativeCode,t.视频id,b.title 视频标题,t.rootPageSource,t.objectId,t.targetUid,t.networkType,t.operationOptions,t.operationExtParams,t.hotsencetype,t.path,t.subsessionid,t.sessionid
+FROM t LEFT JOIN videoods.dim_video b ON t.视频id=b.videoid ORDER BY t.ts
+"""
+
+
+def load_user_strategies(path):
+    users = pd.read_csv(path, dtype=str).fillna("")
+    if "用户标识" not in users.columns:
+        raise ValueError("用户清单必须包含“用户标识”列")
+    strategy_column = "命中策略" if "命中策略" in users.columns else None
+    strategies = {}
+    for _, row in users.iterrows():
+        user = row["用户标识"].strip()
+        if user:
+            strategy = row[strategy_column].strip() if strategy_column else ""
+            strategies.setdefault(user, [])
+            if strategy and strategy not in strategies[user]:
+                strategies[user].append(strategy)
+    if not strategies:
+        raise ValueError("用户清单中没有有效的用户标识")
+    return {user: ";".join(tags) for user, tags in strategies.items()}
+
+
+def sql_literals(values):
+    return ",".join("'" + sql_text(value) + "'" for value in values)
+
+
+def main():
+    parser = argparse.ArgumentParser(description="批量查询实时用户行为时间线")
+    parser.add_argument("users_file", type=Path)
+    parser.add_argument("date", nargs="?", default=datetime.now().strftime("%Y%m%d"), help="yyyyMMdd")
+    parser.add_argument("apptype", nargs="?", default=None, help="可选;不传则查询全部产品")
+    parser.add_argument("--output-dir", type=Path, default=Path("."))
+    args = parser.parse_args()
+    users = load_user_strategies(args.users_file)
+    date = datetime.strptime(args.date, "%Y%m%d")
+    sql_args = {"mc_list": sql_literals(users), "apptype_filter": build_apptype_filter(args.apptype), "dt_start": f"{args.date}000000", "dt_end": f"{args.date}235959", "year": date.strftime("%Y"), "month": date.strftime("%m"), "day_of_month": date.strftime("%d")}
+    df = ODPSClient().execute_sql(SQL_REALTIME_BATCH.format(**sql_args))
+    df = df[~df["businesstype"].isin(EXCLUDED_BUSINESSTYPES)].sort_values("ts", kind="stable").reset_index(drop=True)
+    df = df.rename(columns={"creativecode": "creativeCode", "endroutepath": "endRoutePath", "rootpagesource": "rootPageSource", "objectid": "objectId", "targetuid": "targetUid", "networktype": "networkType", "operationoptions": "operationOptions", "operationextparams": "operationExtParams"})
+    df.insert(0, "北京时间", pd.to_datetime(df["ts"], unit="ms", utc=True).dt.tz_convert("Asia/Shanghai").dt.tz_localize(None))
+    df = add_device_info(df.drop(columns="ts"), group_column="用户标识")
+    df.insert(2, "命中策略", df["用户标识"].map(users).fillna(""))
+    df.insert(df.columns.get_loc("businesstype") + 1, "行为", df.apply(behavior_definition, axis=1))
+    df = df.rename(columns={"businesstype": "事件类型", "eventid": "事件ID"})
+    df.insert(0, "产品apptype", df.pop("产品apptype"))
+    df.insert(1, "用户标识", df.pop("用户标识"))
+    df.insert(2, "命中策略", df.pop("命中策略"))
+    counts = df["用户标识"].value_counts()
+    summary = pd.DataFrame({"用户标识": list(users), "命中策略": list(users.values()), "实时事件数": [counts.get(user, 0) for user in users]})
+    args.output_dir.mkdir(parents=True, exist_ok=True)
+    out = args.output_dir / f"timeline_realtime_{safe_name(args.users_file.stem)}_{args.date}.xlsx"
+    with pd.ExcelWriter(out, engine="openpyxl", datetime_format="yyyy/mm/dd hh:mm:ss") as writer:
+        df.to_excel(writer, sheet_name="实时行为路径", index=False)
+        worksheet = writer.sheets["实时行为路径"]
+        worksheet.freeze_panes = "A2"
+        worksheet.auto_filter.ref = worksheet.dimensions
+        for cell in worksheet["D"][1:]:
+            cell.number_format = "yyyy/mm/dd hh:mm:ss"
+        summary.to_excel(writer, sheet_name="用户摘要", index=False)
+        writer.sheets["用户摘要"].freeze_panes = "A2"
+        writer.sheets["用户摘要"].auto_filter.ref = writer.sheets["用户摘要"].dimensions
+    print("注意:simpleevent_log_flow 仅保留当前短实时窗口。")
+    print(f"[XLSX] 用户数={len(users)} 日期={args.date} 事件数={len(df)} -> {out.resolve()}", flush=True)
+
+
+if __name__ == "__main__":
+    main()

+ 34 - 0
.env.example

@@ -0,0 +1,34 @@
+# LLM: OpenRouter is the default provider.
+LLM_PROVIDER=openrouter
+OPENROUTER_API_KEY=
+OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
+CODEX_MODEL=openai/gpt-5.6-terra
+CODEX_REASONING_EFFORT=medium
+CODEX_TIMEOUT_SECONDS=300
+# For direct OpenAI instead: LLM_PROVIDER=openai, OPENAI_API_KEY=..., CODEX_MODEL=gpt-5.6-terra
+OPENAI_API_KEY=
+
+# MaxCompute / ODPS (use a read-only account).
+ODPS_ACCESS_ID=
+ODPS_ACCESS_KEY=
+# ODPS_ACCESS_SECRET is accepted as a compatibility alias for existing Skills.
+ODPS_ACCESS_SECRET=
+ODPS_PROJECT=
+ODPS_ENDPOINT=
+ODPS_TUNNEL_ENDPOINT=
+ODPS_ALLOWED_PROJECTS=
+
+# Feishu self-built app with im.message.receive_v1 subscribed.
+FEISHU_APP_ID=
+FEISHU_APP_SECRET=
+FEISHU_DOMAIN=https://open.feishu.cn
+FEISHU_ALLOWED_CHAT_IDS=
+
+# Runtime.
+DATA_QUERY_RUNTIME_DIR=runtime
+CONVERSATION_IDLE_HOURS=24
+QUERY_TIMEOUT_SECONDS=1200
+QUERY_MAX_ROWS=10000
+QUERY_MAX_REPAIRS=2
+QUERY_CONCURRENCY=2
+LOG_LEVEL=INFO

+ 10 - 0
.gitignore

@@ -0,0 +1,10 @@
+.env
+.venv/
+__pycache__/
+*.py[cod]
+*.egg-info/
+.pytest_cache/
+runtime/
+logs/
+*.pid
+.DS_Store

+ 13 - 0
AGENTS.md

@@ -0,0 +1,13 @@
+# Data Query Agent instructions
+
+This repository is a read-only data-query service. Never modify ODPS data or external resources other than publishing the successful query result to Feishu.
+
+When turning a user request into SQL:
+
+1. Inspect the relevant Skill under `.agents/skills/`; use `query-odps-data` for generic questions.
+2. Ask for clarification when the metric definition, time range, population, or grouping would materially change the answer.
+3. Return exactly one MaxCompute `SELECT` or `WITH ... SELECT` statement. Never emit DDL, DML, scripting, multiple statements, cross-project access, credentials, or LogView URLs.
+4. Include explicit partition predicates for every partitioned table and select only needed columns.
+5. Prefer a bounded date range and aggregate before joining large tables.
+6. The host application validates and executes SQL. Do not execute commands, write files, call Feishu, or claim a query succeeded.
+

+ 37 - 0
README.md

@@ -0,0 +1,37 @@
+# Data Query Agent
+
+一个通过飞书长连接接收自然语言、使用 Codex SDK 编写 MaxCompute SQL、执行只读查询并自动发布飞书在线表格的 Python 服务。
+
+## 功能
+
+- 飞书 WebSocket 长连接,无需公网回调地址。
+- 每个“聊天类型 + chat_id + 用户 open_id”独立保留 Codex 多轮线程。
+- `new`/`clear`/`help`/`skills` 命令及对应 `/`、中文别名。
+- Codex 自动选择并完整加载仓库内 Skill;优先复用 Skill SQL、模板和确定性脚本,通用探索再生成 SQL。
+- SQL AST 只读校验、项目白名单、分区条件检查、超时和最多行数限制。
+- 查询成功后生成 CSV/XLSX,并导入为企业内链接可读的飞书电子表格。
+- SQLite 只保存会话、消息去重和运行元数据;查询结果保存在 `runtime/runs/`。
+
+## 快速开始
+
+```bash
+cd /Users/liulidong/project/data_query_agent
+cp .env.example .env            # 本工程首次创建时已按授权尝试迁移参考工程凭证
+vim .env                        # 补齐缺失项,尤其是白名单
+./scripts/setup.sh
+./scripts/check.sh              # 只读检查飞书、ODPS 和 Codex/OpenRouter
+./scripts/start.sh
+./scripts/status.sh
+```
+
+日志在 `logs/service.log`。停止或重启:`./scripts/stop.sh`、`./scripts/restart.sh`。
+
+如果 `./scripts/check.sh` 在 OpenRouter 步骤返回 `401 User not found`,请在 `.env` 中替换 `OPENROUTER_API_KEY`;再运行检查,三项均为 `OK` 后启动服务。
+
+## 飞书后台配置
+
+应用必须是企业自建应用,启用机器人能力,并在“事件与回调”中选择长连接、订阅 `im.message.receive_v1`。给应用开通收发消息、获取机器人信息、上传文件、云文档导入与权限管理所需权限。只处理白名单群中真正 @ 当前机器人的消息;群内用户不另设白名单,私聊不处理。
+
+## 运行边界
+
+默认 OpenRouter provider 使用 Responses API,模型为 `openai/gpt-5.6-terra`。Codex 仅使用只读文件工具加载 Skill、SQL 模板和业务参考,禁止读取 `.env`、runtime 和日志;它只继承模型 provider 密钥,不继承 ODPS 和飞书凭证。ODPS 查询、Skill 脚本和飞书发布均由 Python 主服务执行,ODPS 账户仍应配置为只读权限。

+ 49 - 0
findings.md

@@ -0,0 +1,49 @@
+# Findings
+
+- The reference `exp_add_feed` project uses PyODPS and contains legacy hardcoded credentials; no credential literal may enter tracked files.
+- The portable `mini_new/data_query_skill` repository already contains four self-contained ODPS query Skills and a Feishu spreadsheet publisher Skill.
+- Codex Python SDK 0.144.4 supports `AsyncCodex`, `thread_start`, `thread_resume`, per-turn JSON Schema output, read-only sandboxing, and explicit approval modes.
+- OpenRouter can be configured as a Codex custom provider using the Responses wire API and `OPENROUTER_API_KEY`.
+- The existing Feishu implementation uses `lark-oapi` WebSocket events and the Drive import APIs. The requested permission is tenant-readable, not the reference publisher's public-editable policy.
+- External content and reference repositories are treated as untrusted inputs; only verified interfaces and business definitions are reused.
+- The `Agent` reference's strongest reusable pattern is a lightweight WebSocket callback plus per-conversation serialization and exact bot-open-id mention checks.
+- `lark-oapi` INFO logging includes ephemeral WebSocket URL credentials; production uses ERROR for the SDK logger.
+- This host uses a SOCKS proxy, so the isolated environment must include both HTTPX and Requests SOCKS extras.
+- The migrated Feishu and ODPS credentials pass read-only identity/project checks.
+- The project-local OpenRouter key is valid. Initial 401 responses came from a different stale `OPENROUTER_API_KEY` inherited from the interactive shell because dotenv used `override=False`; project configuration now explicitly prefers its mode-600 `.env`.
+- The reference Google route proved the OpenRouter key and dotenv fix. The requested production model is `openai/gpt-5.6-terra` over OpenRouter Responses.
+- Codex scans all six repository Skills and injects their metadata, but the current worker disables shell/unified tools and constrains output to one SQL, so script-based Skills cannot load or execute their complete workflow.
+- The desired routing policy is: prefer validated SQL/scripts packaged by a matching Skill; use model-generated SQL for generic exploration or uncovered parameters; keep ODPS and Feishu credentials solely in the Python host.
+- `query-user-behavior-path` is the only fully deterministic end-to-end query Skill currently packaged: `user_timeline.py` accepts `user_id`, `yyyyMMdd`, optional `apptype`, builds its validated multi-source SQL, executes ODPS, and writes an XLSX.
+- Product-efficiency and growth-fission Skills package request normalization and deterministic report formatting, but their raw-facts SQL is intentionally generated from the Skill contracts; their host execution path must therefore remain `Codex generates one validated SQL -> host executes -> Skill formatter`.
+- Advertising-risk packages reusable SQL templates plus a standard SQL runner; the Codex planner should select and adapt the matching template, then the existing host SQL guard/ODPS path can execute it.
+- Generic ODPS exploration has no script and should continue through generated, guarded single-SQL execution.
+- Strict structured output requires `additionalProperties: false` at the root and nested parameter object; Pydantic `ConfigDict(extra="forbid")` produces the accepted schema for the OpenRouter/OpenAI Responses route.
+- Real SDK routing now selects `query-odps-data` for an ambiguous DAU request and asks for the missing DAU/table definition rather than fabricating SQL.
+- A fully specified timeline request now returns `selected_skill=query-user-behavior-path`, `execution_mode=skill_script`, normalized date/apptype parameters, and `sql=null`.
+- The resulting Codex session contains `user_timeline.py` and `logs-and-definitions.md`, proving the complete Skill workflow and required reference were loaded rather than only its metadata description.
+- Real routing also selects `odps-ad-risk-analysis` and generates a single SQL from its event-pair template, while an under-specified product-efficiency request selects its dedicated Skill and asks for the required experiment parameters in one message.
+- The Skill-reader permission profile successfully preserves full Skill activation while denying `.env`, runtime, logs, Git, non-workspace filesystem reads, and tool network access.
+- Behavior-path `apptype` was previously an explicit default of `0` in the Skill and all three scripts; this caused every source query to filter product 0 even when the user omitted the product.
+- The revised behavior uses a shared conditional SQL fragment: omitted `apptype` renders no product predicate, while an explicit value is applied identically to all five sources.
+- A real Codex routing check for a timeline question without a product now returns `apptype=null`, confirming it no longer invents product 0.
+- Product-efficiency run `20260812_145417_f87d8204` repaired two SQL failures and ultimately completed ODPS instance `20260812065533286girvdjg2vxg` with the required 16 raw bucket rows; the user-visible failure occurred afterward in deterministic formatting.
+- The raw result has all 26 required columns and no CSV nulls, but `dau=0` for all 16 buckets while video/share/return facts are nonzero.
+- `add_rates()` converts zero DAU denominators to `pd.NA`; the resulting per-DAU Series has object dtype, and `Series.round(4)` at formatter line 126 calls Python `round(pd.NA)`, causing the exact `NAType` TypeError.
+- The final repaired SQL incorrectly buckets offline `useractive_log` from its physical `rootsessionid` column. The established offline product-efficiency SQL uses `GET_JSON_OBJECT(extparams, '$.rootSessionId')`; the Skill reference documented the realtime mapping explicitly but left the offline mapping ambiguous.
+- A one-row read-only diagnostic for `20260808`, `apptype=4`, third-from-end bucket found total DAU 1,610,304; physical-column bucketed DAU 0; extparams bucketed DAU 1,610,300. This proves the all-zero DAU is a SQL field-selection defect, not absent activity.
+- The formatter has a second independent robustness defect: even a legitimately zero DAU baseline should render per-DAU rates as blank/unavailable and continue, per the Skill contract, rather than crash on nullable values.
+- The established offline and realtime product-efficiency SQLs use `GET_JSON_OBJECT(extparams, '$.rootSessionId')` for both useractive and video-action logs, while source-share logs use their physical `rootsessionid` column. The Skill must state this exact per-table mapping instead of allowing the planner to choose either source.
+- Existing query Skills reference 14 distinct `loghubods` source tables across active, video-action, playback, sharing, simple-event, advertising, and operation logs. The behavior-path workflow also joins `videoods.dim_video`, so the generic catalog needs 15 fully qualified confirmed tables.
+- The catalog must distinguish confirmed business meanings from schema-only fields. Unqualified logical/target names such as `ad_own_open_conv` are not safe to publish as confirmed source tables without a project-qualified definition.
+- Read-only ODPS metadata confirms all 15 fully qualified tables exist. Most fields are `STRING`; `videoods.dim_video.videoid` is `BIGINT`, and many event-table comments are sparse or null, so business meanings must come from the existing Skill contracts rather than metadata alone.
+- Experiment fields such as `rootSessionId`, `rootSourceId`, `userShareDepth`, and `isSpecialLayer` may be JSON members of `extparams` on active/video sources, while share sources expose physical lowercase columns. The generic catalog must preserve these per-table distinctions, especially the prohibition on physical offline `useractive_log.rootsessionid` for experiment DAU.
+- A live “重新查询” reused an older Codex thread and regenerated the pre-fix physical `useractive_log.rootsessionid` expression despite the revised Skill on disk. Prompt/Skill constraints alone are insufficient across persisted threads, so the host must validate this critical product-efficiency field contract before ODPS submission and route violations through SQL repair.
+- The 15:39 product-efficiency update changes video-source handling, not report metrics: offline `video_action_log_applet` and realtime `video_action_log_flow` must never filter physical `business`; event selection uses `businesstype IN ('videoView','videoPlay','videoShareFriend')` with the normal date, app, version, and channel conditions.
+- The same update adjusts host preflight to ignore `business` when checking `video_action_log_applet` partition coverage and adds contract checks/tests rejecting `business` references in both offline and realtime product-efficiency SQL. Removing `v.business IS NOT NULL` from the latest successful SQL still passes metadata/partition preflight.
+- Product-efficiency run `20260812_154740_92c9afcc` produced zero returns because its source-share CTE used `user_share_log.type='share'`. The established return contract uses source `topic='share'`, click `topic='click'`, same-day/app `shareid` join, source `rootsessionid` bucket, and distinct click-side `machinecode` UV.
+- A bounded diagnostic for 20260811/app 4 found 1,265,139 `topic='share'` rows versus zero `type='share'` rows; the correct topic-based join matched 311,039 share IDs and 605,411 globally distinct return users. The zero report is therefore a generated-SQL field error, not absent returns or formatter loss.
+- After the contract update, a fresh Codex thread generated product-efficiency SQL with video `businesstype='videoShareFriend'`, source `topic='share'`, click `topic='click'`, source `rootsessionid` bucketing, and no `type='share'` predicate.
+- Product-efficiency run `20260812_152831_290915c8` has a separate all-zero-video defect. Its three rootSessionId mappings are correct: offline active/video use `extparams.$.rootSessionId`, while share uses physical `rootsessionid`. The zero facts come from `video_action_log_applet.business='applet'` added during repair after the partition guard reported missing `(dt,business)` predicates.
+- Domain correction: the validated product-efficiency query must not filter `video_action_log_applet.business`, including neither `business='applet'` nor a three-event `business IN (...)`. It uses exact `dt`, `apptype`, and `businesstype IN ('videoView','videoPlay','videoShareFriend')`. The metadata-driven guard's requirement that every reported partition column appear in predicates is incompatible with this verified table contract and caused the repair failure.
+- The same no-`business` rule applies to realtime `video_action_log_flow`: use `year/month/dt`, `apptype`, and `businesstype`, with `rootSessionId` from `extparams`. The repository-local Skill and host contract now enforce this symmetrically for offline and realtime product-efficiency queries.

+ 62 - 0
progress.md

@@ -0,0 +1,62 @@
+# Progress
+
+## 2026-08-11
+
+- Read the required planning, OpenAI docs, Skill creator, Feishu publisher, and coding-guideline Skills.
+- Inspected the reference ODPS, Feishu Sheets, Feishu WebSocket, and portable Skill implementations.
+- Confirmed the target project did not already exist.
+- Created the project directory and initialized implementation planning files.
+
+## 2026-08-12
+
+- Built the Python package, environment loader, SQLite state store, exact Feishu commands, SQL AST guard, PyODPS executor, report writer, and structured Codex runtime.
+- Isolated Codex in a sanitized child environment and disabled shell, unified execution, Web search, Apps, and multi-agent tools.
+- Implemented the Feishu WebSocket listener, precise bot mention detection, dual allowlists, fast callback queueing, threaded conversation isolation, replies, spreadsheet import, and tenant-readable link permissions.
+- Initialized the generic ODPS Skill, vendored five reference Skills, adapted Feishu permissions, and validated all six Skills.
+- Migrated authorized credentials into ignored mode-600 `.env`; no credential literal was added to tracked source.
+- Installed the self-contained virtual environment, including the pinned Codex Python SDK and bundled CLI.
+- Passed Python compilation, dependency checks, 15 unit tests, configuration validation, Feishu bot identity lookup, ODPS project metadata lookup, and two WebSocket/background-process smoke tests.
+- Lowered Feishu SDK logging after observing an ephemeral WebSocket query credential at INFO; removed that log and verified the new log is clean.
+- Diagnosed the apparent OpenRouter 401 by reproducing `examples/auto_put_ad_mini`: a stale shell variable overrode the valid project `.env`. Fixed dotenv precedence, added a regression test, selected the reference Google model, and passed `/models/user`, Chat Completions, Responses, and Codex structured-output checks.
+- Switched to the new Feishu application, changed authorization to allow any mentioned user inside the configured group, and established working long-connection message intake.
+- Confirmed repository Skill metadata is present in SDK sessions, while full script workflows are blocked by the current planner-only worker configuration.
+- Started Phase 6 to add structured Skill routing with host-side execution and Skill SQL/script reuse.
+- Classified Skill execution contracts: deterministic user timeline script; generated raw SQL plus deterministic formatters for experiment reports; reusable SQL templates for ad risk; generated SQL for generic exploration.
+- Added strict structured Skill routing fields to the Codex decision, enabled read-only Skill file inspection, and kept ODPS/Feishu credentials excluded from the Codex child environment.
+- Added an allowlisted host Skill executor for deterministic user timelines and report post-formatting.
+- Passed real SDK checks for generic clarification and complete timeline Skill activation; session evidence confirms full `SKILL.md` reference loading.
+- Passed real SDK routing checks for advertising-risk SQL generation and product-efficiency parameter clarification.
+- Removed the obsolete user allowlist configuration; authorization is now group allowlist + actual bot mention only.
+- Expanded the suite to 22 passing tests and passed configuration plus Feishu/ODPS/Codex doctor checks.
+- Completed secret and whitespace checks, restarted the Feishu long connection with the Skill-aware implementation, and confirmed the new service process is running.
+- Started Phase 9 to make behavior-path `apptype` optional without silently defaulting to product 0.
+- Updated the timeline Skill contract plus offline, realtime, and batch scripts so omitted `apptype` queries all products; explicit values remain exact filters.
+- Passed 27 tests, Python compilation, Skill quick validation, and a real Codex check returning `apptype=null` when omitted.
+- Restarted the Feishu long-connection service with optional apptype behavior; the new process is stable.
+- Began diagnosing the product-efficiency failure and reproduced its full formatter traceback against the saved raw facts without rerunning ODPS.
+- Completed diagnosis: parameter routing was correct, ODPS eventually succeeded, offline DAU used the wrong rootSessionId source, and the formatter then crashed while rounding nullable per-DAU rates.
+- Started Phase 11 to make the offline bucket mapping deterministic and harden zero-denominator handling.
+- Documented exact per-table rootSessionId mappings, added semantic raw-fact validation and NaN-safe rate formatting, and added three formatter regressions; the focused tests pass.
+- Passed the full 30-test suite and Skill quick validation; the saved broken raw result now fails with an explicit rootSessionId mapping diagnosis.
+- Passed a real Codex routing/SQL-generation probe for the original parameters: the generated offline DAU CTE reads `GET_JSON_OBJECT(extparams, '$.rootSessionId')` from `loghubods.useractive_log`.
+- Completed Python compilation, restarted the Feishu long connection, and verified PID 51256 is running with the WebSocket started and no active query runs.
+- Started Phase 12 to consolidate the 14 verified source-table definitions into the generic query Skill using Chinese documentation and instructions.
+- Rewrote the generic ODPS Skill in Chinese and added a Chinese catalog covering 14 log tables plus `videoods.dim_video`, including common field meanings, per-table fields, partitions, JSON-source constraints, and confirmed event values.
+- Added catalog coverage tests; the focused tests and Skill quick validation pass.
+- Passed a real generic-query planning check using `simpleevent_log`, `dt`, `apptype`, and `businesstype` from the new Chinese catalog.
+- Found a persisted old Codex thread could revive the pre-fix product-efficiency field expression; added a host-side SQL contract check that blocks it before ODPS and routes it through repair.
+- Passed all 34 tests and restarted the Feishu WebSocket service with the Chinese catalog and host-side contract guard.
+- Started Phase 13 to review the newly modified product-efficiency Skill, validate its behavior, and reload the service safely.
+- Reviewed the 15:39 update across Skill docs, SQL guard, and tests: it removes `business` from product-efficiency video filtering, relies on `businesstype`, and teaches partition preflight to require `dt` without forcing the `business` subpartition.
+- Confirmed a successful saved SQL still passes ODPS metadata preflight after removing `v.business IS NOT NULL`; focused Skill/guard validation passed.
+- Passed all 38 tests, Python compilation, and Skill validation; restarted the Feishu service and verified PID 57203 with the WebSocket active and zero running queries.
+- Diagnosed the latest report's zero-return result: raw ODPS facts were already zero because the source-share filter used `type='share'` instead of `topic='share'`; a read-only diagnostic confirmed 605,411 matched return users under the correct contract. No production code or report was changed.
+- Started Phase 15 to make source `topic='share'` and click `topic='click'` explicit in the product-efficiency Skill, align the generic catalog, validate fresh SQL, and restart the service.
+- Updated the product-efficiency workflow, metric contract, raw SQL contract, and generic table catalog with explicit source/click topic fields and a prohibition on using `type='share'` for return attribution; added two static contract regressions.
+- Focused regressions and quick validation for both affected Skills pass.
+- A fresh real Codex planning check passed the exact return-field assertions; all 40 tests and compilation passed, then the Feishu service was restarted with the updated Skills.
+- Diagnosed run `20260812_152831_290915c8`: parameters, DAU rootSessionId, video rootSessionId, and share physical rootSessionId mappings were correct, but the first partition-validation repair inserted `video_action_log_applet.business='applet'`, producing zero exposure/play/share facts while DAU and return remained nonzero.
+- Corrected the diagnosis after domain confirmation: product-efficiency queries must not add any `business` condition to `video_action_log_applet`; they filter exact `dt`, `apptype`, and `businesstype`. The current metadata-driven guard incorrectly required every reported partition column and triggered the bad repair.
+- Updated the repository-local product-efficiency Skill so both offline `video_action_log_applet` and realtime `video_action_log_flow` explicitly forbid physical `business` predicates and use only their date partitions, `apptype`, and `businesstype` for video facts.
+- Hardened the host contract to reject `business='applet'` and `business IN (...)` for either video source; offline partition validation now requires `dt` but ignores metadata-reported `business` for `video_action_log_applet`.
+- Verified the saved failed SQL is rejected and the same SQL with the `business` line removed is accepted. All 38 tests, Python compilation, and Skill quick validation passed; service PID 56492 is running with the Feishu WebSocket started.

+ 39 - 0
pyproject.toml

@@ -0,0 +1,39 @@
+[build-system]
+requires = ["setuptools>=75", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "data-query-agent"
+version = "0.1.0"
+description = "Feishu long-connection data query agent powered by Codex SDK and ODPS"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+  "httpx[socks]>=0.27",
+  "lark-oapi==1.5.3",
+  "openai-codex==0.144.4",
+  "openpyxl>=3.1",
+  "pandas>=2.2",
+  "pydantic>=2.8",
+  "pyodps>=0.12.3",
+  "python-dotenv>=1.0",
+  "requests[socks]>=2.31",
+  "sqlglot>=25",
+]
+
+[project.optional-dependencies]
+dev = ["pytest>=8", "pytest-asyncio>=0.24", "pyyaml>=6"]
+
+[project.scripts]
+data-query-agent = "data_query_agent.__main__:main"
+
+[tool.setuptools]
+package-dir = {"" = "src"}
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.pytest.ini_options]
+pythonpath = ["src"]
+testpaths = ["tests"]
+asyncio_mode = "auto"

+ 11 - 0
scripts/check.sh

@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+VENV_PYTHON="$PROJECT_DIR/.venv/bin/python"
+if [[ ! -x "$VENV_PYTHON" ]]; then
+  echo "Python environment missing. Run ./scripts/setup.sh first." >&2
+  exit 1
+fi
+cd "$PROJECT_DIR"
+"$VENV_PYTHON" -m data_query_agent --doctor

+ 7 - 0
scripts/restart.sh

@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+"$SCRIPT_DIR/stop.sh"
+"$SCRIPT_DIR/start.sh"
+

+ 12 - 0
scripts/setup.sh

@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+VENV_DIR="$PROJECT_DIR/.venv"
+
+if [[ ! -d "$VENV_DIR" ]]; then
+  python3 -m venv "$VENV_DIR"
+fi
+"$VENV_DIR/bin/python" -m pip install --upgrade pip
+"$VENV_DIR/bin/python" -m pip install -e "$PROJECT_DIR[dev]"
+echo "Environment ready: $VENV_DIR"

+ 33 - 0
scripts/start.sh

@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+VENV_PYTHON="$PROJECT_DIR/.venv/bin/python"
+PID_FILE="$PROJECT_DIR/runtime/service.pid"
+LOG_FILE="$PROJECT_DIR/logs/service.log"
+
+if [[ ! -x "$VENV_PYTHON" ]]; then
+  echo "Python environment missing. Run ./scripts/setup.sh first." >&2
+  exit 1
+fi
+if [[ -f "$PID_FILE" ]]; then
+  EXISTING_PID="$(tr -dc '0-9' < "$PID_FILE")"
+  if [[ -n "$EXISTING_PID" ]] && kill -0 "$EXISTING_PID" 2>/dev/null; then
+    echo "Service already running (PID $EXISTING_PID)."
+    exit 0
+  fi
+fi
+
+mkdir -p "$PROJECT_DIR/runtime" "$PROJECT_DIR/logs"
+cd "$PROJECT_DIR"
+"$VENV_PYTHON" -m data_query_agent --check-config
+nohup "$VENV_PYTHON" -m data_query_agent >>"$LOG_FILE" 2>&1 &
+SERVICE_PID=$!
+printf '%s\n' "$SERVICE_PID" > "$PID_FILE"
+sleep 1
+if ! kill -0 "$SERVICE_PID" 2>/dev/null; then
+  echo "Service failed to start. Check $LOG_FILE" >&2
+  exit 1
+fi
+echo "Service started (PID $SERVICE_PID). Log: $LOG_FILE"
+

+ 17 - 0
scripts/status.sh

@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PID_FILE="$PROJECT_DIR/runtime/service.pid"
+LOG_FILE="$PROJECT_DIR/logs/service.log"
+
+if [[ -f "$PID_FILE" ]]; then
+  SERVICE_PID="$(tr -dc '0-9' < "$PID_FILE")"
+  if [[ -n "$SERVICE_PID" ]] && kill -0 "$SERVICE_PID" 2>/dev/null; then
+    echo "Service is running (PID $SERVICE_PID)."
+    echo "Log: $LOG_FILE"
+    exit 0
+  fi
+fi
+echo "Service is not running."
+exit 1

+ 29 - 0
scripts/stop.sh

@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PID_FILE="$PROJECT_DIR/runtime/service.pid"
+
+if [[ ! -f "$PID_FILE" ]]; then
+  echo "Service is not running (PID file not found)."
+  exit 0
+fi
+SERVICE_PID="$(tr -dc '0-9' < "$PID_FILE")"
+if [[ -z "$SERVICE_PID" ]] || ! kill -0 "$SERVICE_PID" 2>/dev/null; then
+  rm -f "$PID_FILE"
+  echo "Service is not running; stale PID file removed."
+  exit 0
+fi
+
+kill "$SERVICE_PID"
+for _ in {1..20}; do
+  if ! kill -0 "$SERVICE_PID" 2>/dev/null; then
+    rm -f "$PID_FILE"
+    echo "Service stopped."
+    exit 0
+  fi
+  sleep 0.5
+done
+echo "Service did not stop within 10 seconds (PID $SERVICE_PID)." >&2
+exit 1
+

+ 55 - 0
scripts/test_openrouter_key.py

@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+"""Validate OpenRouter auth through the user-filtered models endpoint."""
+
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+
+import httpx
+from dotenv import load_dotenv
+
+
+def main() -> int:
+    project_dir = Path(__file__).resolve().parents[1]
+    load_dotenv(project_dir / ".env", override=True)
+    api_key = os.getenv("OPENROUTER_API_KEY", "").strip()
+    base_url = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1").rstrip("/")
+    if not api_key:
+        print("OpenRouter key: MISSING", file=sys.stderr)
+        return 2
+
+    try:
+        response = httpx.get(
+            f"{base_url}/models/user",
+            headers={"Authorization": f"Bearer {api_key}"},
+            timeout=30,
+        )
+    except httpx.HTTPError as exc:
+        print(f"OpenRouter key check: NETWORK ERROR ({type(exc).__name__})", file=sys.stderr)
+        return 3
+
+    if response.status_code == 200:
+        data = response.json().get("data") or []
+        model_ids = {str(item.get("id") or "") for item in data}
+        configured_model = os.getenv("CODEX_MODEL", "").strip()
+        print("OpenRouter key: VALID")
+        print(f"User-visible models: {len(model_ids)}")
+        if configured_model:
+            print(f"Configured model available: {configured_model in model_ids} ({configured_model})")
+        return 0
+
+    message = "unknown error"
+    try:
+        payload = response.json()
+        error = payload.get("error") or payload.get("message") or {}
+        message = error.get("message", "unknown error") if isinstance(error, dict) else str(error)
+    except ValueError:
+        pass
+    print(f"OpenRouter key: INVALID (HTTP {response.status_code}: {message[:200]})", file=sys.stderr)
+    return 1
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 4 - 0
src/data_query_agent/__init__.py

@@ -0,0 +1,4 @@
+"""Data query agent package."""
+
+__version__ = "0.1.0"
+

+ 59 - 0
src/data_query_agent/__main__.py

@@ -0,0 +1,59 @@
+from __future__ import annotations
+
+import argparse
+import asyncio
+import logging
+import signal
+from pathlib import Path
+
+from .config import Settings
+from .doctor import run_doctor
+from .service import DataQueryService
+
+
+async def run_service(settings: Settings) -> None:
+    service = DataQueryService(settings)
+    stop_event = asyncio.Event()
+    loop = asyncio.get_running_loop()
+    for name in ("SIGINT", "SIGTERM"):
+        if hasattr(signal, name):
+            try:
+                loop.add_signal_handler(getattr(signal, name), stop_event.set)
+            except NotImplementedError:
+                pass
+    await service.start()
+    try:
+        await stop_event.wait()
+    finally:
+        await service.stop()
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Feishu long-connection data query agent")
+    parser.add_argument("--check-config", action="store_true", help="Validate configuration and exit")
+    parser.add_argument("--doctor", action="store_true", help="Run read-only Feishu, ODPS, and Codex connectivity checks")
+    args = parser.parse_args()
+    project_dir = Path(__file__).resolve().parents[2]
+    settings = Settings.load(project_dir)
+    logging.basicConfig(
+        level=getattr(logging, settings.log_level, logging.INFO),
+        format="%(asctime)s %(levelname)s %(name)s %(message)s",
+    )
+    settings.validate()
+    settings.runtime_dir.mkdir(parents=True, exist_ok=True)
+    if args.check_config:
+        print("Configuration OK")
+        return
+    if args.doctor:
+        try:
+            asyncio.run(run_doctor(settings))
+        except Exception as exc:
+            logging.error("Read-only connectivity check failed: %s", exc)
+            raise SystemExit(1) from None
+        return
+    asyncio.run(run_service(settings))
+
+
+
+if __name__ == "__main__":
+    main()

+ 103 - 0
src/data_query_agent/codex_runtime.py

@@ -0,0 +1,103 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import re
+import sys
+from pathlib import Path
+from typing import Any, TypeVar
+
+from pydantic import BaseModel
+
+from .config import Settings
+from .models import QueryAnalysis, QueryDecision
+
+ModelT = TypeVar("ModelT", bound=BaseModel)
+
+
+class CodexRuntime:
+    def __init__(self, settings: Settings) -> None:
+        self.settings = settings
+        self.cwd = settings.project_dir
+
+    def _child_env(self) -> dict[str, str]:
+        allowed = ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "SSL_CERT_FILE", "SSL_CERT_DIR")
+        env = {key: os.environ[key] for key in allowed if key in os.environ}
+        if self.settings.llm_provider == "openrouter":
+            env["OPENROUTER_API_KEY"] = self.settings.openrouter_api_key
+        else:
+            env["OPENAI_API_KEY"] = self.settings.openai_api_key
+        return env
+
+    async def _call(self, thread_id: str | None, prompt: str, response_model: type[ModelT]) -> tuple[str, ModelT]:
+        request: dict[str, Any] = {
+            "thread_id": thread_id,
+            "provider": self.settings.llm_provider,
+            "base_url": self.settings.openrouter_base_url,
+            "model": self.settings.codex_model,
+            "effort": self.settings.codex_reasoning_effort,
+            "cwd": str(self.cwd),
+            "prompt": prompt,
+            "schema": response_model.model_json_schema(),
+        }
+        process = await asyncio.create_subprocess_exec(
+            sys.executable,
+            "-m",
+            "data_query_agent.codex_worker",
+            stdin=asyncio.subprocess.PIPE,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+            env=self._child_env(),
+            cwd=self.cwd,
+        )
+        try:
+            stdout, stderr = await asyncio.wait_for(
+                process.communicate(json.dumps(request, ensure_ascii=False).encode()),
+                timeout=self.settings.codex_timeout_seconds,
+            )
+        except TimeoutError:
+            process.terminate()
+            try:
+                await asyncio.wait_for(process.wait(), timeout=5)
+            except TimeoutError:
+                process.kill()
+                await process.wait()
+            raise TimeoutError(f"Codex request exceeded {self.settings.codex_timeout_seconds} seconds")
+        try:
+            payload = json.loads(stdout.decode())
+        except Exception as exc:
+            detail = stderr.decode(errors="replace")[-1000:]
+            raise RuntimeError(f"Codex worker returned invalid output: {detail}") from exc
+        if not payload.get("ok"):
+            raise RuntimeError(str(payload.get("error") or "Codex worker failed"))
+        return str(payload["thread_id"]), response_model.model_validate(payload["response"])
+
+    async def plan(self, thread_id: str | None, question: str) -> tuple[str, QueryDecision]:
+        prompt = f"""用户本轮问题如下:
+<user_question>
+{question}
+</user_question>
+
+结合本线程历史自动选择最匹配的仓库 Skill,并读取其完整说明。
+- 信息不足时返回 needs_clarification,用一个问题集中询问缺失口径。
+- query-user-behavior-path 参数完整时返回 skill_script,提取 user_id、date、realtime;只有用户明确给出产品时才设置 apptype,否则返回 null,表示不添加产品条件;sql=null。
+- 其他 Skill 返回 sql;优先复用 Skill 的 SQL 模板或严格按其事实契约生成一条完整的 MaxCompute SQL,不要用 Markdown 代码块。
+- 所有不适用参数均明确返回 null。"""
+        return await self._call(thread_id, prompt, QueryDecision)
+
+    async def repair(self, thread_id: str, sql: str, error: str, attempt: int) -> tuple[str, QueryDecision]:
+        safe_error = re.sub(r"(?i)(access[_ -]?key|secret|token)\s*[:=]\s*\S+", r"\1=<redacted>", error)[:3000]
+        prompt = f"""第 {attempt} 次 SQL 校验或执行失败。修复 SQL,仍只返回一条只读 MaxCompute 查询。
+失败 SQL:
+{sql}
+
+错误:
+{safe_error}"""
+        return await self._call(thread_id, prompt, QueryDecision)
+
+    async def analyze(self, thread_id: str, question: str, profile: dict[str, Any]) -> tuple[str, QueryAnalysis]:
+        prompt = f"""基于下面有限的查询结果画像给出中文结论。只使用画像中的事实;若结果截断或为空要明确说明。
+原问题:{question}
+结果画像:{json.dumps(profile, ensure_ascii=False, default=str)}"""
+        return await self._call(thread_id, prompt, QueryAnalysis)

+ 101 - 0
src/data_query_agent/codex_worker.py

@@ -0,0 +1,101 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import sys
+from typing import Any
+
+from openai_codex import ApprovalMode, AsyncCodex, CodexConfig
+from openai_codex.generated.v2_all import ReasoningEffort
+
+
+BASE_INSTRUCTIONS = """你是企业内部的只读数据查询规划器。必须从当前工作区 .agents/skills 中选择最匹配的查询 Skill;通用问题使用 query-odps-data。
+匹配后必须读取该 Skill 的完整 SKILL.md 及它直接要求的业务参考资料,再按其口径提取参数、集中追问缺失参数或生成执行计划。优先复用 Skill 中已验证的 SQL 模板和确定性脚本,不要重新发明已有口径。
+query-user-behavior-path 信息齐全时使用 skill_script,sql 必须为 null;其他查询使用 sql,并只生成一条 MaxCompute SELECT/WITH SQL。
+你可以使用只读文件查看工具读取 Skill,但严禁运行 Skill 脚本、访问凭证、读取 .env、写文件、直接访问 ODPS、直接调用飞书、生成 DDL/DML、多语句或跨项目 SQL。不要声称查询或发布已完成。输出必须符合主程序提供的 JSON Schema。"""
+
+
+def _provider_config(request: dict[str, Any]) -> tuple[str, dict[str, Any] | None]:
+    locked_down: dict[str, Any] = {
+        "default_permissions": "skill-reader",
+        "permissions": {
+            "skill-reader": {
+                "filesystem": {
+                    ":root": "deny",
+                    ":minimal": "read",
+                    ":tmpdir": "deny",
+                    ":slash_tmp": "deny",
+                    ":workspace_roots": {
+                        ".": "read",
+                        ".env": "deny",
+                        ".env.*": "deny",
+                        "**/*.env": "deny",
+                        "runtime": "deny",
+                        "logs": "deny",
+                        ".git": "deny",
+                    },
+                },
+                "network": {"enabled": False},
+            }
+        },
+        "features": {
+            "apps": False,
+            "multi_agent": False,
+            "shell_tool": True,
+            "unified_exec": True,
+        },
+        "web_search": "disabled",
+    }
+    if request["provider"] == "openrouter":
+        locked_down["model_providers"] = {
+            "openrouter": {
+                "name": "OpenRouter",
+                "base_url": request["base_url"],
+                "env_key": "OPENROUTER_API_KEY",
+                "wire_api": "responses",
+            }
+        }
+        return "openrouter", locked_down
+    return "openai", locked_down
+
+
+async def run(request: dict[str, Any]) -> dict[str, Any]:
+    provider, provider_config = _provider_config(request)
+    config = CodexConfig(cwd=request["cwd"])
+    async with AsyncCodex(config) as codex:
+        common = {
+            "approval_mode": ApprovalMode.deny_all,
+            "base_instructions": BASE_INSTRUCTIONS,
+            "cwd": request["cwd"],
+            "model": request["model"],
+            "model_provider": provider,
+        }
+        if provider_config:
+            common["config"] = provider_config
+        if request.get("thread_id"):
+            thread = await codex.thread_resume(request["thread_id"], **common)
+        else:
+            thread = await codex.thread_start(**common)
+        result = await thread.run(
+            request["prompt"],
+            effort=ReasoningEffort(request.get("effort", "medium")),
+            output_schema=request["schema"],
+            approval_mode=ApprovalMode.deny_all,
+        )
+        if not result.final_response:
+            raise RuntimeError("Codex returned no final response")
+        return {"thread_id": thread.id, "response": json.loads(result.final_response)}
+
+
+def main() -> None:
+    try:
+        request = json.loads(sys.stdin.read())
+        response = asyncio.run(run(request))
+        sys.stdout.write(json.dumps({"ok": True, **response}, ensure_ascii=False))
+    except Exception as exc:
+        sys.stdout.write(json.dumps({"ok": False, "error": f"{type(exc).__name__}: {exc}"}, ensure_ascii=False))
+        raise SystemExit(1)
+
+
+if __name__ == "__main__":
+    main()

+ 44 - 0
src/data_query_agent/commands.py

@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+NEW_COMMANDS = frozenset({"/new", "new", "新会话", "开启新会话"})
+CLEAR_COMMANDS = frozenset({"/clear", "clear", "清空上下文", "清除上下文"})
+HELP_COMMANDS = frozenset({"/help", "help", "帮助"})
+SKILLS_COMMANDS = frozenset({"/skills", "skills", "skill", "能力", "查询能力"})
+
+
+def parse_command(text: str) -> str | None:
+    normalized = text.strip().lower()
+    if normalized in NEW_COMMANDS:
+        return "new"
+    if normalized in CLEAR_COMMANDS:
+        return "clear"
+    if normalized in HELP_COMMANDS:
+        return "help"
+    if normalized in SKILLS_COMMANDS:
+        return "skills"
+    if any(phrase in normalized for phrase in ("有哪些skill", "有什么skill", "支持查询什么", "能查询什么")):
+        return "skills"
+    return None
+
+
+HELP_TEXT = """我是数据查询 Agent,可以根据自然语言编写并执行只读 MaxCompute SQL,成功后自动返回飞书表格。
+
+命令:
+- new / /new / 新会话:开启新的逻辑会话
+- clear / /clear / 清空上下文:清除当前上下文
+- help / /help / 帮助:显示帮助
+- skills / /skills / 查询能力:显示当前 Skill 和支持的查询
+
+提问时尽量说明指标口径、时间范围、筛选条件和期望维度;不清楚时我会继续追问。"""
+
+
+SKILLS_TEXT = """当前支持以下 Skill:
+
+- 通用 ODPS 查询:把自然语言问题转换为安全的只读 MaxCompute SQL,支持多轮补充口径。
+- 广告风险分析:截图、曝光后切后台、落地页隐藏、动态黑名单、DAU 交集,以及按 mid/uid 诊断。
+- 增长裂变报表:首层 UV、曝光、播放、分享、裂变 UV、STR、T0 裂变率等实验指标。
+- 产品效率报表:DAU、曝光、播放、分享、回流、STR、ROV,以及实验/对照分析。
+- 单用户行为路径:按 mid/machinecode 查询并解释视频、广告、播放、活跃等完整时间线。
+- 飞书数据发布:把 CSV/Excel 导入飞书表格,并发送数据结论和表格卡片。
+
+直接描述查询目标即可;缺少日期、指标口径或筛选条件时,我会继续追问。"""

+ 110 - 0
src/data_query_agent/config.py

@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+
+def _csv_set(value: str) -> frozenset[str]:
+    return frozenset(item.strip() for item in value.replace(",", ",").split(",") if item.strip())
+
+
+def _positive_int(name: str, default: int) -> int:
+    value = int(os.getenv(name, str(default)))
+    if value < 1:
+        raise ValueError(f"{name} must be positive")
+    return value
+
+
+@dataclass(frozen=True)
+class Settings:
+    project_dir: Path
+    runtime_dir: Path
+    llm_provider: str
+    codex_model: str
+    codex_reasoning_effort: str
+    codex_timeout_seconds: int
+    openrouter_api_key: str
+    openrouter_base_url: str
+    openai_api_key: str
+    odps_access_id: str
+    odps_access_key: str
+    odps_project: str
+    odps_endpoint: str
+    odps_tunnel_endpoint: str
+    odps_allowed_projects: frozenset[str]
+    feishu_app_id: str
+    feishu_app_secret: str
+    feishu_domain: str
+    allowed_chat_ids: frozenset[str]
+    conversation_idle_hours: int
+    query_timeout_seconds: int
+    query_max_rows: int
+    query_max_repairs: int
+    query_concurrency: int
+    log_level: str
+
+    @property
+    def db_path(self) -> Path:
+        return self.runtime_dir / "state.sqlite3"
+
+    @classmethod
+    def load(cls, project_dir: Path | None = None) -> "Settings":
+        root = (project_dir or Path(__file__).resolve().parents[2]).resolve()
+        # This service is deployed from its project-local .env. Prefer that
+        # explicit file over stale variables inherited from an interactive shell.
+        load_dotenv(root / ".env", override=True)
+        runtime = Path(os.getenv("DATA_QUERY_RUNTIME_DIR", "runtime"))
+        if not runtime.is_absolute():
+            runtime = root / runtime
+        project = os.getenv("ODPS_PROJECT", "").strip()
+        allowed_projects = _csv_set(os.getenv("ODPS_ALLOWED_PROJECTS", project))
+        return cls(
+            project_dir=root,
+            runtime_dir=runtime,
+            llm_provider=os.getenv("LLM_PROVIDER", "openrouter").strip().lower(),
+            codex_model=os.getenv("CODEX_MODEL", "openai/gpt-5.6-terra").strip(),
+            codex_reasoning_effort=os.getenv("CODEX_REASONING_EFFORT", "medium").strip(),
+            codex_timeout_seconds=_positive_int("CODEX_TIMEOUT_SECONDS", 300),
+            openrouter_api_key=os.getenv("OPENROUTER_API_KEY", "").strip(),
+            openrouter_base_url=os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1").rstrip("/"),
+            openai_api_key=os.getenv("OPENAI_API_KEY", "").strip(),
+            odps_access_id=os.getenv("ODPS_ACCESS_ID", "").strip(),
+            odps_access_key=(os.getenv("ODPS_ACCESS_KEY", "") or os.getenv("ODPS_ACCESS_SECRET", "")).strip(),
+            odps_project=project,
+            odps_endpoint=os.getenv("ODPS_ENDPOINT", "").strip(),
+            odps_tunnel_endpoint=os.getenv("ODPS_TUNNEL_ENDPOINT", "").strip(),
+            odps_allowed_projects=allowed_projects,
+            feishu_app_id=os.getenv("FEISHU_APP_ID", "").strip(),
+            feishu_app_secret=os.getenv("FEISHU_APP_SECRET", "").strip(),
+            feishu_domain=os.getenv("FEISHU_DOMAIN", "https://open.feishu.cn").rstrip("/"),
+            allowed_chat_ids=_csv_set(os.getenv("FEISHU_ALLOWED_CHAT_IDS", "")),
+            conversation_idle_hours=_positive_int("CONVERSATION_IDLE_HOURS", 24),
+            query_timeout_seconds=_positive_int("QUERY_TIMEOUT_SECONDS", 1200),
+            query_max_rows=_positive_int("QUERY_MAX_ROWS", 10000),
+            query_max_repairs=_positive_int("QUERY_MAX_REPAIRS", 2),
+            query_concurrency=_positive_int("QUERY_CONCURRENCY", 2),
+            log_level=os.getenv("LOG_LEVEL", "INFO").upper(),
+        )
+
+    def validate(self) -> None:
+        required = {
+            "FEISHU_APP_ID": self.feishu_app_id,
+            "FEISHU_APP_SECRET": self.feishu_app_secret,
+            "FEISHU_ALLOWED_CHAT_IDS": self.allowed_chat_ids,
+            "ODPS_ACCESS_ID": self.odps_access_id,
+            "ODPS_ACCESS_KEY": self.odps_access_key,
+            "ODPS_PROJECT": self.odps_project,
+            "ODPS_ENDPOINT": self.odps_endpoint,
+        }
+        if self.llm_provider == "openrouter":
+            required["OPENROUTER_API_KEY"] = self.openrouter_api_key
+        elif self.llm_provider == "openai":
+            required["OPENAI_API_KEY"] = self.openai_api_key
+        else:
+            raise ValueError("LLM_PROVIDER must be openrouter or openai")
+        missing = [name for name, value in required.items() if not value]
+        if missing:
+            raise ValueError("Missing required configuration: " + ", ".join(missing))

+ 45 - 0
src/data_query_agent/doctor.py

@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+import asyncio
+
+from odps import ODPS
+
+from .codex_runtime import CodexRuntime
+from .config import Settings
+from .feishu import FeishuApi
+
+
+async def run_doctor(settings: Settings) -> None:
+    settings.validate()
+    print("Configuration: OK", flush=True)
+
+    feishu = FeishuApi(settings)
+    try:
+        bot_id = await feishu.bot_open_id()
+        if not bot_id:
+            raise RuntimeError("Feishu bot identity is empty")
+        print("Feishu bot identity: OK", flush=True)
+    finally:
+        await feishu.close()
+
+    def check_odps() -> None:
+        client = ODPS(
+            settings.odps_access_id,
+            settings.odps_access_key,
+            project=settings.odps_project,
+            endpoint=settings.odps_endpoint,
+        )
+        project = client.get_project()
+        project.reload()
+        if not project.name:
+            raise RuntimeError("ODPS project metadata is empty")
+
+    await asyncio.to_thread(check_odps)
+    print("ODPS project metadata: OK", flush=True)
+
+    thread_id, decision = await CodexRuntime(settings).plan(
+        None, "帮我统计昨天的活跃用户数;如果活跃定义不明确,请先追问,不要猜。"
+    )
+    if not thread_id:
+        raise RuntimeError("Codex did not create a thread")
+    print(f"Codex/{settings.llm_provider} structured response: OK ({decision.status})", flush=True)

+ 242 - 0
src/data_query_agent/feishu.py

@@ -0,0 +1,242 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import threading
+import time
+from pathlib import Path
+from typing import Any, Callable
+
+import httpx
+import lark_oapi as lark
+
+from .config import Settings
+from .models import IncomingMessage
+
+logger = logging.getLogger(__name__)
+
+
+class FeishuApiError(RuntimeError):
+    pass
+
+
+class FeishuApi:
+    def __init__(self, settings: Settings) -> None:
+        self.app_id = settings.feishu_app_id
+        self.app_secret = settings.feishu_app_secret
+        self.base_url = settings.feishu_domain + "/open-apis"
+        self._client = httpx.AsyncClient(timeout=30)
+        self._token = ""
+        self._token_expires_at = 0.0
+        self._token_lock = asyncio.Lock()
+
+    @staticmethod
+    def _payload(response: httpx.Response, action: str) -> dict[str, Any]:
+        try:
+            response.raise_for_status()
+            payload = response.json()
+        except (httpx.HTTPError, ValueError) as exc:
+            raise FeishuApiError(f"{action}失败:HTTP 响应无效") from exc
+        if int(payload.get("code") or 0) != 0:
+            raise FeishuApiError(f"{action}失败:code={payload.get('code')}, msg={payload.get('msg')}")
+        return payload
+
+    async def token(self) -> str:
+        if self._token and time.monotonic() < self._token_expires_at:
+            return self._token
+        async with self._token_lock:
+            if self._token and time.monotonic() < self._token_expires_at:
+                return self._token
+            response = await self._client.post(
+                f"{self.base_url}/auth/v3/tenant_access_token/internal",
+                json={"app_id": self.app_id, "app_secret": self.app_secret},
+            )
+            payload = self._payload(response, "获取 tenant access token")
+            self._token = str(payload["tenant_access_token"])
+            self._token_expires_at = time.monotonic() + max(60, int(payload.get("expire") or 7200) - 300)
+            return self._token
+
+    async def _headers(self) -> dict[str, str]:
+        return {"Authorization": f"Bearer {await self.token()}"}
+
+    async def bot_open_id(self) -> str:
+        response = await self._client.get(f"{self.base_url}/bot/v3/info", headers=await self._headers())
+        payload = self._payload(response, "获取机器人信息")
+        open_id = str((payload.get("bot") or {}).get("open_id") or "")
+        if not open_id:
+            raise FeishuApiError("机器人信息缺少 open_id")
+        return open_id
+
+    async def reply_text(self, message_id: str, text: str) -> None:
+        response = await self._client.post(
+            f"{self.base_url}/im/v1/messages/{message_id}/reply",
+            headers={**await self._headers(), "Content-Type": "application/json"},
+            json={"msg_type": "text", "content": json.dumps({"text": text}, ensure_ascii=False)},
+        )
+        self._payload(response, "回复飞书消息")
+
+    async def reply_card(self, message_id: str, title: str, markdown: str, url: str | None = None) -> None:
+        elements: list[dict[str, Any]] = [{"tag": "markdown", "content": markdown}]
+        if url:
+            elements.extend([
+                {"tag": "hr"},
+                {"tag": "action", "actions": [{
+                    "tag": "button",
+                    "type": "primary",
+                    "text": {"tag": "plain_text", "content": "打开在线表格"},
+                    "url": url,
+                }]},
+            ])
+        card = {
+            "config": {"wide_screen_mode": True},
+            "header": {"template": "blue", "title": {"tag": "plain_text", "content": title[:80]}},
+            "elements": elements,
+        }
+        response = await self._client.post(
+            f"{self.base_url}/im/v1/messages/{message_id}/reply",
+            headers={**await self._headers(), "Content-Type": "application/json"},
+            json={"msg_type": "interactive", "content": json.dumps(card, ensure_ascii=False)},
+        )
+        self._payload(response, "回复飞书卡片")
+
+    async def publish_sheet(self, file_path: Path, title: str) -> str:
+        extension = file_path.suffix.lower().lstrip(".")
+        if extension not in {"csv", "xls", "xlsx"} or not file_path.is_file():
+            raise ValueError("待发布文件必须是存在的 CSV/XLS/XLSX")
+        headers = await self._headers()
+        with file_path.open("rb") as handle:
+            response = await self._client.post(
+                f"{self.base_url}/drive/v1/medias/upload_all",
+                headers=headers,
+                data={
+                    "file_name": file_path.name,
+                    "parent_type": "ccm_import_open",
+                    "size": str(file_path.stat().st_size),
+                    "extra": json.dumps({"obj_type": "sheet", "file_extension": extension}),
+                },
+                files={"file": (file_path.name, handle, "application/octet-stream")},
+            )
+        file_token = str(self._payload(response, "上传待导入文件")["data"]["file_token"])
+        response = await self._client.post(
+            f"{self.base_url}/drive/v1/import_tasks",
+            headers={**headers, "Content-Type": "application/json"},
+            json={
+                "file_extension": extension,
+                "file_token": file_token,
+                "type": "sheet",
+                "file_name": title[:100],
+                "point": {"mount_type": 1, "mount_key": ""},
+            },
+        )
+        ticket = str(self._payload(response, "创建表格导入任务")["data"]["ticket"])
+        result: dict[str, Any] | None = None
+        for _ in range(45):
+            await asyncio.sleep(2)
+            response = await self._client.get(
+                f"{self.base_url}/drive/v1/import_tasks/{ticket}", headers=headers
+            )
+            current = self._payload(response, "查询表格导入结果").get("data", {}).get("result", {})
+            if current.get("job_status") == 0:
+                result = current
+                break
+            if current.get("job_status") == 3:
+                raise FeishuApiError(f"导入在线表格失败:{current.get('job_error_msg', 'unknown error')}")
+        if result is None:
+            raise TimeoutError("导入在线表格超过 90 秒仍未完成")
+        sheet_token = str(result.get("token") or "")
+        url = str(result.get("url") or "")
+        file_type = str(result.get("type") or "sheet")
+        if not sheet_token or not url:
+            raise FeishuApiError("导入响应缺少表格 token 或 URL")
+        response = await self._client.patch(
+            f"{self.base_url}/drive/v2/permissions/{sheet_token}/public",
+            headers={**headers, "Content-Type": "application/json"},
+            params={"type": file_type},
+            json={"external_access": False, "link_share_entity": "tenant_readable"},
+        )
+        self._payload(response, "设置企业内链接可读权限")
+        return url
+
+    async def close(self) -> None:
+        await self._client.aclose()
+
+
+class FeishuLongConnection:
+    def __init__(self, settings: Settings, bot_open_id: str, callback: Callable[[IncomingMessage], None]) -> None:
+        self.settings = settings
+        self.bot_open_id = bot_open_id
+        self.callback = callback
+        self._thread: threading.Thread | None = None
+        self._client: Any = None
+
+    @staticmethod
+    def _parse_content(content: str, message_type: str) -> str:
+        try:
+            payload = json.loads(content)
+        except (TypeError, json.JSONDecodeError):
+            return str(content)
+        if message_type == "text":
+            return str(payload.get("text") or "")
+        if message_type == "post":
+            parts: list[str] = []
+            body = payload.get("zh_cn") or payload
+            if body.get("title"):
+                parts.append(str(body["title"]))
+            for paragraph in body.get("content", []) or []:
+                for item in paragraph if isinstance(paragraph, list) else []:
+                    if item.get("tag") in {"text", "a"}:
+                        parts.append(str(item.get("text") or item.get("href") or ""))
+            return "\n".join(parts).strip()
+        return ""
+
+    def _handle(self, data: Any) -> None:
+        try:
+            event = data.event
+            message = event.message
+            sender_id = event.sender.sender_id if event.sender else None
+            mentions = list(message.mentions or [])
+            mentioned_bot = any(
+                getattr(getattr(mention, "id", None), "open_id", None) == self.bot_open_id
+                for mention in mentions
+            )
+            text = self._parse_content(message.content, message.message_type)
+            for mention in mentions:
+                key = str(getattr(mention, "key", "") or "")
+                name = str(getattr(mention, "name", "") or "")
+                if key:
+                    text = text.replace(key, "")
+                if name:
+                    text = text.replace(f"@{name}", "")
+            incoming = IncomingMessage(
+                message_id=str(message.message_id or ""),
+                chat_id=str(message.chat_id or ""),
+                chat_type=str(message.chat_type or ""),
+                sender_open_id=str(getattr(sender_id, "open_id", "") or ""),
+                text=text.strip(),
+                message_type=str(message.message_type or ""),
+                mentioned_bot=mentioned_bot,
+            )
+            self.callback(incoming)
+        except Exception:
+            logger.exception("Failed to parse Feishu event")
+
+    def start(self) -> threading.Thread:
+        dispatcher = (
+            lark.EventDispatcherHandler.builder("", "")
+            .register_p2_im_message_receive_v1(self._handle)
+            .build()
+        )
+        domain = lark.LARK_DOMAIN if "larksuite" in self.settings.feishu_domain else lark.FEISHU_DOMAIN
+        self._client = lark.ws.Client(
+            self.settings.feishu_app_id,
+            self.settings.feishu_app_secret,
+            event_handler=dispatcher,
+            domain=domain,
+            # INFO includes the full WebSocket URL and its ephemeral access key.
+            log_level=lark.LogLevel.CRITICAL,
+        )
+        self._thread = threading.Thread(target=self._client.start, name="feishu-websocket", daemon=True)
+        self._thread.start()
+        logger.info("Feishu WebSocket thread started")
+        return self._thread

+ 74 - 0
src/data_query_agent/models.py

@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+@dataclass(frozen=True)
+class IncomingMessage:
+    message_id: str
+    chat_id: str
+    chat_type: str
+    sender_open_id: str
+    text: str
+    message_type: str = "text"
+    mentioned_bot: bool = False
+
+    @property
+    def conversation_key(self) -> str:
+        return f"{self.chat_type}:{self.chat_id}:{self.sender_open_id}"
+
+
+class SkillParameters(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+    user_id: str | None = Field(description="mid/machinecode for a single-user timeline, otherwise null")
+    date: str | None = Field(description="Single yyyyMMdd date, otherwise null")
+    apptype: str | None = Field(description="Explicit timeline apptype filter; null means all products with no apptype predicate")
+    realtime: bool | None = Field(description="Whether the user explicitly requested realtime data, otherwise null")
+    app_type: str | None = Field(description="Experiment report app type, otherwise null")
+    date_from: str | None = Field(description="Report start date yyyyMMdd, otherwise null")
+    date_to: str | None = Field(description="Report end date yyyyMMdd, otherwise null")
+    data_mode: str | None = Field(description="offline or realtime, otherwise null")
+    bucket_position_from_end: int | None = Field(description="Experiment bucket position, otherwise null")
+    experiment_buckets: list[str] | None = Field(description="Experiment hex buckets, otherwise null")
+    control_buckets: list[str] | None = Field(description="Control hex buckets, or null to use the complement")
+    version: str | None = Field(description="Version code or all, otherwise null")
+    first_layer_rule: str | None = Field(description="Growth first-layer rule, otherwise null")
+    exclude_qywx: bool | None = Field(description="Whether enterprise-WeChat traffic is excluded, otherwise null")
+
+
+class QueryDecision(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+    status: Literal["needs_clarification", "ready", "rejected"]
+    reply: str = Field(description="Chinese response for clarification/rejection or a short execution acknowledgement")
+    title: str = Field(description="Short spreadsheet title; use 数据查询结果 when no result title applies")
+    selected_skill: Literal[
+        "query-odps-data",
+        "odps-ad-risk-analysis",
+        "odps-growth-fission-report",
+        "odps-product-efficiency-report",
+        "query-user-behavior-path",
+    ] = Field(description="The repository query Skill selected for this request")
+    execution_mode: Literal["sql", "skill_script"] = Field(description="sql for guarded host SQL execution; skill_script only for an allowlisted deterministic Skill script")
+    sql: str | None = Field(description="Exactly one read-only MaxCompute SELECT/WITH query, or null when not ready")
+    parameters: SkillParameters
+    assumptions: list[str]
+
+
+class QueryAnalysis(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+    summary: str = Field(description="Concise Chinese conclusion, no invented facts")
+    highlights: list[str] = Field(max_length=5)
+    caveats: list[str] = Field(max_length=3)
+
+
+@dataclass(frozen=True)
+class QueryResult:
+    dataframe: object
+    instance_id: str
+    truncated: bool

+ 80 - 0
src/data_query_agent/odps_client.py

@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+from dataclasses import dataclass
+from typing import Any
+
+import pandas as pd
+from odps import ODPS
+
+from .config import Settings
+from .models import QueryResult
+from .sql_guard import SQLGuard, TableRef
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class TableMetadata:
+    partitions: dict[str, list[str]]
+
+
+class ODPSClient:
+    def __init__(self, settings: Settings) -> None:
+        kwargs: dict[str, Any] = {
+            "access_id": settings.odps_access_id,
+            "secret_access_key": settings.odps_access_key,
+            "project": settings.odps_project,
+            "endpoint": settings.odps_endpoint,
+        }
+        if settings.odps_tunnel_endpoint:
+            kwargs["tunnel_endpoint"] = settings.odps_tunnel_endpoint
+        self._client = ODPS(**kwargs)
+        self.project = settings.odps_project
+        self.timeout = settings.query_timeout_seconds
+        self.max_rows = settings.query_max_rows
+
+    async def partition_metadata(self, refs: list[TableRef]) -> TableMetadata:
+        return await asyncio.to_thread(self._partition_metadata_sync, refs)
+
+    def _partition_metadata_sync(self, refs: list[TableRef]) -> TableMetadata:
+        result: dict[str, list[str]] = {}
+        for ref in refs:
+            full_name = f"{ref.project}.{ref.name}" if ref.project else ref.name
+            table = self._client.get_table(full_name)
+            result[full_name] = [column.name for column in table.schema.partitions]
+        return TableMetadata(result)
+
+    async def execute(self, sql: str) -> QueryResult:
+        return await asyncio.to_thread(self._execute_sync, sql)
+
+    def _execute_sync(self, sql: str) -> QueryResult:
+        instance = self._client.run_sql(sql)
+        instance_id = str(instance.id)
+        logger.info("ODPS query started instance_id=%s", instance_id)
+        try:
+            instance.wait_for_success(timeout=self.timeout)
+        except Exception:
+            try:
+                instance.stop()
+            except Exception:
+                logger.warning("Failed to stop ODPS instance %s", instance_id, exc_info=True)
+            raise
+        with instance.open_reader() as reader:
+            frame = reader.to_pandas(start=0, count=self.max_rows + 1)
+        truncated = len(frame.index) > self.max_rows
+        if truncated:
+            frame = frame.iloc[: self.max_rows].copy()
+        if not isinstance(frame, pd.DataFrame):
+            frame = pd.DataFrame(frame)
+        logger.info("ODPS query finished instance_id=%s rows=%d truncated=%s", instance_id, len(frame), truncated)
+        return QueryResult(frame, instance_id, truncated)
+
+
+async def validate_for_odps(sql: str, guard: SQLGuard, odps: ODPSClient) -> list[TableRef]:
+    refs = guard.validate(sql)
+    metadata = await odps.partition_metadata(refs)
+    guard.validate_partition_predicates(sql, metadata.partitions)
+    return refs
+

+ 62 - 0
src/data_query_agent/reports.py

@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+import json
+from datetime import date, datetime
+from decimal import Decimal
+from pathlib import Path
+from typing import Any
+
+import pandas as pd
+
+
+def _json_value(value: Any) -> Any:
+    if value is None or isinstance(value, (str, int, float, bool)):
+        return value
+    if isinstance(value, (datetime, date)):
+        return value.isoformat()
+    if isinstance(value, Decimal):
+        return float(value)
+    if pd.isna(value):
+        return None
+    return str(value)
+
+
+def write_json(path: Path, value: Any) -> None:
+    path.write_text(json.dumps(value, ensure_ascii=False, indent=2, default=_json_value), encoding="utf-8")
+
+
+def build_profile(frame: pd.DataFrame, truncated: bool) -> dict[str, Any]:
+    sample = [
+        {str(key): _json_value(value) for key, value in row.items()}
+        for row in frame.head(20).to_dict(orient="records")
+    ]
+    numeric: dict[str, Any] = {}
+    for column in frame.select_dtypes(include="number").columns[:20]:
+        series = frame[column].dropna()
+        if not series.empty:
+            numeric[str(column)] = {
+                "min": _json_value(series.min()),
+                "max": _json_value(series.max()),
+                "mean": _json_value(series.mean()),
+                "sum": _json_value(series.sum()),
+            }
+    return {
+        "returned_rows": len(frame.index),
+        "truncated": truncated,
+        "columns": [str(column) for column in frame.columns],
+        "numeric": numeric,
+        "sample": sample,
+    }
+
+
+def write_result_files(run_dir: Path, frame: pd.DataFrame, sql: str, info: dict[str, Any]) -> tuple[Path, Path]:
+    csv_path = run_dir / "result.csv"
+    xlsx_path = run_dir / "result.xlsx"
+    frame.to_csv(csv_path, index=False, encoding="utf-8-sig")
+    info_frame = pd.DataFrame([{"字段": key, "值": _json_value(value)} for key, value in info.items()])
+    sql_chunks = [sql[index : index + 30000] for index in range(0, len(sql), 30000)] or [""]
+    with pd.ExcelWriter(xlsx_path, engine="openpyxl") as writer:
+        frame.to_excel(writer, sheet_name="查询结果", index=False)
+        info_frame.to_excel(writer, sheet_name="查询信息", index=False)
+        pd.DataFrame({"SQL": sql_chunks}).to_excel(writer, sheet_name="SQL", index=False)
+    return csv_path, xlsx_path

+ 308 - 0
src/data_query_agent/service.py

@@ -0,0 +1,308 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+import re
+import uuid
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+import pandas as pd
+
+from .codex_runtime import CodexRuntime
+from .commands import HELP_TEXT, SKILLS_TEXT, parse_command
+from .config import Settings
+from .feishu import FeishuApi, FeishuLongConnection
+from .models import IncomingMessage, QueryAnalysis, QueryDecision
+from .odps_client import ODPSClient, validate_for_odps
+from .reports import build_profile, write_json
+from .skill_executor import SkillExecutor
+from .sql_guard import SQLGuard
+from .state import StateStore
+
+logger = logging.getLogger(__name__)
+
+
+class DataQueryService:
+    def __init__(self, settings: Settings) -> None:
+        self.settings = settings
+        settings.runtime_dir.mkdir(parents=True, exist_ok=True)
+        (settings.runtime_dir / "runs").mkdir(parents=True, exist_ok=True)
+        self.state = StateStore(settings.db_path, settings.conversation_idle_hours)
+        self.codex = CodexRuntime(settings)
+        self.odps = ODPSClient(settings)
+        self.guard = SQLGuard(settings.odps_allowed_projects)
+        self.skills = SkillExecutor(settings)
+        self.feishu = FeishuApi(settings)
+        self.queue: asyncio.Queue[IncomingMessage] = asyncio.Queue(maxsize=1000)
+        self.query_slots = asyncio.Semaphore(settings.query_concurrency)
+        self._locks: dict[str, asyncio.Lock] = {}
+        self._listener: FeishuLongConnection | None = None
+        self._worker_tasks: list[asyncio.Task[Any]] = []
+        self._loop: asyncio.AbstractEventLoop | None = None
+
+    def _authorized(self, message: IncomingMessage) -> bool:
+        return (
+            message.message_type in {"text", "post"}
+            and bool(message.text)
+            and bool(message.message_id)
+            and bool(message.chat_id)
+            and bool(message.sender_open_id)
+            and message.chat_id in self.settings.allowed_chat_ids
+            and message.chat_type == "group"
+            and message.mentioned_bot
+        )
+
+    def _enqueue_from_websocket(self, message: IncomingMessage) -> None:
+        if not self._authorized(message) or self._loop is None:
+            return
+
+        def enqueue() -> None:
+            try:
+                self.queue.put_nowait(message)
+            except asyncio.QueueFull:
+                logger.error("Inbound queue full; message_id=%s dropped", message.message_id)
+
+        self._loop.call_soon_threadsafe(enqueue)
+
+    async def start(self) -> None:
+        self._loop = asyncio.get_running_loop()
+        bot_open_id = await self.feishu.bot_open_id()
+        self._listener = FeishuLongConnection(self.settings, bot_open_id, self._enqueue_from_websocket)
+        self._listener.start()
+        worker_count = max(2, self.settings.query_concurrency * 2)
+        self._worker_tasks = [asyncio.create_task(self._worker(index)) for index in range(worker_count)]
+        logger.info("Data query agent started workers=%d", worker_count)
+
+    async def stop(self) -> None:
+        for task in self._worker_tasks:
+            task.cancel()
+        await asyncio.gather(*self._worker_tasks, return_exceptions=True)
+        await self.feishu.close()
+        self.state.close()
+
+    async def _worker(self, index: int) -> None:
+        while True:
+            message = await self.queue.get()
+            try:
+                await self.handle(message)
+            except asyncio.CancelledError:
+                raise
+            except Exception:
+                logger.exception("Unhandled message failure worker=%d message_id=%s", index, message.message_id)
+            finally:
+                self.queue.task_done()
+
+    async def handle(self, message: IncomingMessage) -> None:
+        if not self._authorized(message):
+            return
+        if not self.state.claim_message(message.message_id, message.conversation_key):
+            logger.info("Duplicate Feishu message ignored message_id=%s", message.message_id)
+            return
+        lock = self._locks.setdefault(message.conversation_key, asyncio.Lock())
+        async with lock:
+            try:
+                command = parse_command(message.text)
+                if command:
+                    await self._handle_command(message, command)
+                    self.state.finish_message(message.message_id, "command")
+                    return
+                await self.feishu.reply_text(message.message_id, "收到,正在处理你的请求…")
+                async with self.query_slots:
+                    await self._handle_query(message)
+                self.state.finish_message(message.message_id, "completed")
+            except Exception as exc:
+                safe_error = self._safe_error(exc)
+                logger.error("Message handling failed message_id=%s error=%s", message.message_id, safe_error)
+                self.state.finish_message(message.message_id, "failed", safe_error)
+                self.state.fail_active_runs(message.message_id, safe_error)
+                try:
+                    await self.feishu.reply_text(message.message_id, f"查询失败:{safe_error}")
+                except Exception as reply_exc:
+                    logger.error(
+                        "Failed to send error reply message_id=%s error=%s",
+                        message.message_id,
+                        self._safe_error(reply_exc),
+                    )
+
+    async def _handle_command(self, message: IncomingMessage, command: str) -> None:
+        if command == "help":
+            await self.feishu.reply_text(message.message_id, HELP_TEXT)
+            return
+        if command == "skills":
+            await self.feishu.reply_text(message.message_id, SKILLS_TEXT)
+            return
+        conversation = self.state.reset_conversation(message.conversation_key, new_session=command == "new")
+        if command == "new":
+            text = f"已开启新会话({conversation.session_id[:8]}),下一条问题将使用全新上下文。"
+        else:
+            text = "已清空当前上下文,下一条问题将重新开始。"
+        await self.feishu.reply_text(message.message_id, text)
+
+    async def _handle_query(self, message: IncomingMessage) -> None:
+        conversation = self.state.get_conversation(message.conversation_key)
+        thread_id, decision = await self.codex.plan(conversation.thread_id, message.text)
+        self.state.set_thread(message.conversation_key, thread_id)
+        if decision.status != "ready":
+            await self.feishu.reply_text(message.message_id, decision.reply)
+            return
+        if decision.execution_mode == "sql" and not decision.sql:
+            raise RuntimeError("Agent 返回 ready 但没有 SQL")
+        if decision.execution_mode == "skill_script" and decision.selected_skill != "query-user-behavior-path":
+            raise RuntimeError("Agent 请求了未授权的 Skill 脚本")
+
+        run_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:8]
+        run_dir = self.settings.runtime_dir / "runs" / run_id
+        run_dir.mkdir(parents=True, exist_ok=False)
+        self.state.create_run(run_id, message.conversation_key, message.message_id, run_dir)
+        write_json(run_dir / "request.json", {
+            "run_id": run_id,
+            "message_id": message.message_id,
+            "conversation_key": message.conversation_key,
+            "session_id": conversation.session_id,
+            "question": message.text,
+            "selected_skill": decision.selected_skill,
+            "execution_mode": decision.execution_mode,
+            "parameters": decision.parameters.model_dump(),
+        })
+
+        await self.feishu.reply_text(
+            message.message_id,
+            f"已匹配 Skill:{decision.selected_skill},正在执行查询…",
+        )
+
+        if decision.execution_mode == "skill_script":
+            self.state.update_run(run_id, "running", metadata={"skill": decision.selected_skill})
+            artifact = await self.skills.run_user_timeline(decision.parameters, run_dir)
+            await self._publish_result(
+                message,
+                run_id,
+                thread_id,
+                decision.title,
+                decision.selected_skill,
+                artifact.dataframe,
+                artifact.xlsx_path,
+                artifact.instance_id,
+                artifact.truncated,
+            )
+            return
+
+        current = decision
+        result = None
+        repairs: list[dict[str, Any]] = []
+        for attempt in range(self.settings.query_max_repairs + 1):
+            sql = (current.sql or "").strip().rstrip(";")
+            (run_dir / "query.sql").write_text(sql + "\n", encoding="utf-8")
+            write_json(run_dir / "query_plan.json", {
+                "title": current.title,
+                "selected_skill": current.selected_skill,
+                "execution_mode": current.execution_mode,
+                "parameters": current.parameters.model_dump(),
+                "assumptions": current.assumptions,
+                "attempt": attempt,
+                "repairs": repairs,
+            })
+            try:
+                if current.selected_skill == "odps-product-efficiency-report":
+                    self.guard.validate_product_efficiency_contract(sql, current.parameters.data_mode)
+                await validate_for_odps(sql, self.guard, self.odps)
+                self.state.update_run(run_id, "running")
+                result = await self.odps.execute(sql)
+                break
+            except Exception as exc:
+                if attempt >= self.settings.query_max_repairs:
+                    self.state.update_run(run_id, "failed", metadata={"attempts": attempt + 1})
+                    raise
+                repairs.append({"attempt": attempt + 1, "error": self._safe_error(exc)})
+                thread_id, current = await self.codex.repair(thread_id, sql, str(exc), attempt + 1)
+                self.state.set_thread(message.conversation_key, thread_id)
+                if current.status != "ready" or not current.sql:
+                    raise RuntimeError(current.reply or "Agent 无法修复 SQL")
+
+        if result is None:
+            raise RuntimeError("查询未返回结果")
+        frame = result.dataframe
+        if not isinstance(frame, pd.DataFrame):
+            frame = pd.DataFrame(frame)
+        info = {
+            "run_id": run_id,
+            "原始问题": message.text,
+            "匹配 Skill": current.selected_skill,
+            "ODPS instance_id": result.instance_id,
+            "返回行数": len(frame.index),
+            "是否截断": result.truncated,
+            "生成时间": datetime.now().isoformat(timespec="seconds"),
+        }
+        artifact = await self.skills.format_report(
+            current.selected_skill,
+            current.parameters,
+            run_dir,
+            frame,
+            current.sql or "",
+            info,
+        )
+        await self._publish_result(
+            message,
+            run_id,
+            thread_id,
+            current.title,
+            current.selected_skill,
+            artifact.dataframe,
+            artifact.xlsx_path,
+            result.instance_id,
+            result.truncated,
+        )
+
+    async def _publish_result(
+        self,
+        message: IncomingMessage,
+        run_id: str,
+        thread_id: str,
+        title: str,
+        selected_skill: str,
+        frame: pd.DataFrame,
+        xlsx_path: Path,
+        instance_id: str,
+        truncated: bool,
+    ) -> None:
+        profile = build_profile(frame, truncated)
+        try:
+            thread_id, analysis = await self.codex.analyze(thread_id, message.text, profile)
+            self.state.set_thread(message.conversation_key, thread_id)
+        except Exception as exc:
+            logger.error("Codex result analysis failed; using deterministic summary: %s", self._safe_error(exc))
+            analysis = QueryAnalysis(
+                summary=f"查询完成,共返回 {len(frame.index)} 行、{len(frame.columns)} 列。",
+                highlights=[],
+                caveats=["结果达到行数上限,表格仅包含前若干行。"] if truncated else [],
+            )
+        summary_payload = analysis.model_dump()
+        summary_payload["profile"] = profile
+        run_dir = self.settings.runtime_dir / "runs" / run_id
+        write_json(run_dir / "summary.json", summary_payload)
+        self.state.update_run(
+            run_id,
+            "publishing",
+            instance_id=instance_id,
+            metadata={"skill": selected_skill, "rows": len(frame.index), "truncated": truncated},
+        )
+        await self.feishu.reply_text(message.message_id, "查询完成,正在生成并发布飞书表格…")
+        url = await self.feishu.publish_sheet(xlsx_path, title)
+        self.state.update_run(
+            run_id,
+            "completed",
+            feishu_url=url,
+            metadata={"skill": selected_skill, "rows": len(frame.index), "truncated": truncated},
+        )
+        lines = [analysis.summary]
+        lines.extend(f"- {item}" for item in analysis.highlights)
+        lines.extend(f"- 注意:{item}" for item in analysis.caveats)
+        lines.append(f"\n返回 **{len(frame.index)}** 行" + ("(已截断)" if truncated else ""))
+        await self.feishu.reply_card(message.message_id, title, "\n".join(lines)[:5000], url)
+
+    @staticmethod
+    def _safe_error(exc: Exception) -> str:
+        text = str(exc).replace("\n", " ")
+        text = re.sub(r"(?i)(access[_ -]?key|secret|token)\s*[:=]\s*\S+", r"\1=<redacted>", text)
+        return text[:500] or type(exc).__name__

+ 164 - 0
src/data_query_agent/skill_executor.py

@@ -0,0 +1,164 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import re
+import sys
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+
+import pandas as pd
+
+from .config import Settings
+from .models import SkillParameters
+from .reports import write_json, write_result_files
+
+
+REPORT_SKILLS = {
+    "odps-product-efficiency-report",
+    "odps-growth-fission-report",
+}
+
+
+@dataclass(frozen=True)
+class SkillArtifact:
+    dataframe: pd.DataFrame
+    xlsx_path: Path
+    instance_id: str
+    truncated: bool = False
+
+
+class SkillExecutor:
+    def __init__(self, settings: Settings) -> None:
+        self.settings = settings
+        self.skills_dir = settings.project_dir / ".agents" / "skills"
+
+    def _odps_env(self) -> dict[str, str]:
+        inherited = (
+            "PATH",
+            "LANG",
+            "LC_ALL",
+            "TMPDIR",
+            "SSL_CERT_FILE",
+            "SSL_CERT_DIR",
+            "HTTP_PROXY",
+            "HTTPS_PROXY",
+            "ALL_PROXY",
+            "NO_PROXY",
+        )
+        env = {key: os.environ[key] for key in inherited if key in os.environ}
+        env.update(
+            {
+                "ODPS_ACCESS_ID": self.settings.odps_access_id,
+                "ODPS_ACCESS_SECRET": self.settings.odps_access_key,
+                "ODPS_PROJECT": self.settings.odps_project,
+                "ODPS_ENDPOINT": self.settings.odps_endpoint,
+            }
+        )
+        return env
+
+    async def _run(self, args: list[str], *, env: dict[str, str] | None = None) -> str:
+        process = await asyncio.create_subprocess_exec(
+            *args,
+            cwd=self.settings.project_dir,
+            env=env,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+        try:
+            stdout, stderr = await asyncio.wait_for(
+                process.communicate(), timeout=self.settings.query_timeout_seconds
+            )
+        except TimeoutError:
+            process.terminate()
+            try:
+                await asyncio.wait_for(process.wait(), timeout=5)
+            except TimeoutError:
+                process.kill()
+                await process.wait()
+            raise TimeoutError("Skill 查询执行超时") from None
+        if process.returncode != 0:
+            detail = stderr.decode(errors="replace").strip().splitlines()[-1:] or ["unknown error"]
+            raise RuntimeError("Skill 执行失败:" + detail[0][:500])
+        return stdout.decode(errors="replace")
+
+    async def run_user_timeline(self, parameters: SkillParameters, run_dir: Path) -> SkillArtifact:
+        user_id = (parameters.user_id or "").strip()
+        date = (parameters.date or "").strip()
+        apptype = parameters.apptype.strip() if parameters.apptype is not None else None
+        if not re.fullmatch(r"[A-Za-z0-9_.:-]{1,256}", user_id):
+            raise ValueError("用户标识必须是有效的 mid/machinecode")
+        try:
+            datetime.strptime(date, "%Y%m%d")
+        except ValueError:
+            raise ValueError("行为路径日期必须是 yyyyMMdd") from None
+        if apptype is not None and not re.fullmatch(r"\d{1,8}", apptype):
+            raise ValueError("apptype 必须是数字")
+
+        script_name = "user_timeline_realtime.py" if parameters.realtime else "user_timeline.py"
+        script = self.skills_dir / "query-user-behavior-path" / "scripts" / script_name
+        args = [sys.executable, str(script), user_id, date]
+        if apptype is not None:
+            args.append(apptype)
+        args.extend(["--output-dir", str(run_dir)])
+        output = await self._run(
+            args,
+            env=self._odps_env(),
+        )
+        candidates = list(run_dir.glob("timeline*.xlsx"))
+        if len(candidates) != 1:
+            raise RuntimeError("行为路径 Skill 未生成唯一的 Excel 文件")
+        instance_match = re.search(r"InstanceId:\s*([^\s]+)", output)
+        frame = pd.read_excel(candidates[0], sheet_name="行为路径")
+        return SkillArtifact(
+            dataframe=frame,
+            xlsx_path=candidates[0],
+            instance_id=instance_match.group(1) if instance_match else "skill-script",
+        )
+
+    async def format_report(
+        self,
+        skill_name: str,
+        parameters: SkillParameters,
+        run_dir: Path,
+        raw_frame: pd.DataFrame,
+        sql: str,
+        info: dict[str, object],
+    ) -> SkillArtifact:
+        if skill_name not in REPORT_SKILLS:
+            _, xlsx_path = write_result_files(run_dir, raw_frame, sql, info)
+            return SkillArtifact(raw_frame, xlsx_path, str(info.get("ODPS instance_id") or ""))
+
+        request_data = {
+            key: value
+            for key, value in parameters.model_dump().items()
+            if value is not None and key not in {"user_id", "date", "apptype", "realtime"}
+        }
+        request_data["output_dir"] = str(run_dir)
+        request_path = run_dir / "skill_request.json"
+        normalized_path = run_dir / "normalized_request.json"
+        raw_path = run_dir / "raw_facts.csv"
+        full_path = run_dir / "full_report.csv"
+        aggregate_path = run_dir / "aggregate_report.csv"
+        write_json(request_path, request_data)
+        raw_frame.to_csv(raw_path, index=False, encoding="utf-8-sig")
+
+        scripts = self.skills_dir / skill_name / "scripts"
+        normalized_text = await self._run([sys.executable, str(scripts / "normalize_request.py"), str(request_path)])
+        write_json(normalized_path, json.loads(normalized_text))
+        await self._run(
+            [
+                sys.executable,
+                str(scripts / "format_report.py"),
+                str(normalized_path),
+                str(raw_path),
+                str(full_path),
+                "--aggregate-output",
+                str(aggregate_path),
+            ]
+        )
+        report = pd.read_csv(full_path)
+        _, xlsx_path = write_result_files(run_dir, report, sql, info)
+        return SkillArtifact(report, xlsx_path, str(info.get("ODPS instance_id") or ""))

+ 144 - 0
src/data_query_agent/sql_guard.py

@@ -0,0 +1,144 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from sqlglot import exp, parse, parse_one
+from sqlglot.errors import ParseError
+
+
+class SQLValidationError(ValueError):
+    pass
+
+
+@dataclass(frozen=True)
+class TableRef:
+    project: str | None
+    name: str
+
+
+class SQLGuard:
+    _forbidden = (
+        exp.Insert,
+        exp.Update,
+        exp.Delete,
+        exp.Create,
+        exp.Drop,
+        exp.Alter,
+        exp.Command,
+        exp.Merge,
+        exp.TruncateTable,
+    )
+
+    def __init__(self, allowed_projects: frozenset[str]) -> None:
+        self.allowed_projects = {project.lower() for project in allowed_projects}
+
+    def validate(self, sql: str) -> list[TableRef]:
+        if not sql.strip():
+            raise SQLValidationError("SQL 为空")
+        try:
+            statements = [statement for statement in parse(sql, read="hive") if statement is not None]
+        except ParseError as exc:
+            raise SQLValidationError(f"SQL 无法解析:{exc}") from exc
+        if len(statements) != 1:
+            raise SQLValidationError("只允许一条 SQL")
+        statement = statements[0]
+        if not isinstance(statement, exp.Query):
+            raise SQLValidationError("只允许 SELECT 或 WITH ... SELECT")
+        for kind in self._forbidden:
+            if statement.find(kind):
+                raise SQLValidationError(f"禁止使用 {kind.__name__}")
+        refs: list[TableRef] = []
+        ctes = {cte.alias_or_name.lower() for cte in statement.find_all(exp.CTE)}
+        for table in statement.find_all(exp.Table):
+            name = table.name
+            if not name or name.lower() in ctes:
+                continue
+            project = table.catalog or table.db or None
+            if project and self.allowed_projects and project.lower() not in self.allowed_projects:
+                raise SQLValidationError(f"禁止跨项目访问:{project}.{name}")
+            refs.append(TableRef(project, name))
+        if not refs:
+            raise SQLValidationError("SQL 未引用数据表")
+        return refs
+
+    @staticmethod
+    def validate_product_efficiency_contract(sql: str, data_mode: str | None) -> None:
+        if data_mode not in {"offline", "realtime"}:
+            return
+        statement = parse_one(sql, read="hive")
+        if data_mode == "offline":
+            active_selects = [
+                table.find_ancestor(exp.Select)
+                for table in statement.find_all(exp.Table)
+                if table.name.lower() == "useractive_log"
+            ]
+            if not active_selects:
+                raise SQLValidationError("离线产品效率 SQL 必须从 loghubods.useractive_log 统计 DAU")
+            valid_active_mapping = False
+            for select in active_selects:
+                if select is None:
+                    continue
+                fragment = select.sql(dialect="hive").lower()
+                if "get_json_object" in fragment and "extparams" in fragment and "rootsessionid" in fragment:
+                    valid_active_mapping = True
+                    break
+            if not valid_active_mapping:
+                raise SQLValidationError(
+                    "离线产品效率 DAU 必须使用 GET_JSON_OBJECT(extparams, '$.rootSessionId') 分桶,"
+                    "禁止使用 useractive_log.rootsessionid"
+                )
+
+        for table in statement.find_all(exp.Table):
+            if table.name.lower() not in {"video_action_log_applet", "video_action_log_flow"}:
+                continue
+            select = table.find_ancestor(exp.Select)
+            if select is None:
+                continue
+            alias = table.alias_or_name.lower()
+            for column in select.find_all(exp.Column):
+                if column.name.lower() != "business":
+                    continue
+                qualifier = column.table.lower() if column.table else ""
+                if not qualifier or qualifier == alias:
+                    raise SQLValidationError(
+                        "产品效率视频日志禁止使用 business 条件,"
+                        "请仅用日期分区、apptype 和 businesstype 过滤视频行为"
+                    )
+
+    @staticmethod
+    def validate_partition_predicates(sql: str, partitions: dict[str, list[str]]) -> None:
+        statement = parse_one(sql, read="hive")
+        predicate_roots: list[exp.Expression] = []
+        predicate_roots.extend(where.this for where in statement.find_all(exp.Where))
+        predicate_roots.extend(
+            on for join in statement.find_all(exp.Join) if (on := join.args.get("on")) is not None
+        )
+        partitioned_count = sum(bool(columns) for columns in partitions.values())
+        missing: list[str] = []
+        for table, columns in partitions.items():
+            if not columns:
+                continue
+            table_name = table.rsplit(".", 1)[-1].lower()
+            qualifiers = {
+                candidate.alias_or_name.lower()
+                for candidate in statement.find_all(exp.Table)
+                if candidate.name.lower() == table_name
+            }
+            partition_names = {column.lower() for column in columns}
+            if table_name == "video_action_log_applet":
+                partition_names.discard("business")
+            found = False
+            for predicate in predicate_roots:
+                for candidate in predicate.find_all(exp.Column):
+                    qualifier = candidate.table.lower() if candidate.table else ""
+                    if candidate.name.lower() not in partition_names:
+                        continue
+                    if qualifier in qualifiers or (not qualifier and partitioned_count == 1):
+                        found = True
+                        break
+                if found:
+                    break
+            if not found:
+                missing.append(f"{table}({', '.join(sorted(partition_names))})")
+        if missing:
+            raise SQLValidationError("分区表缺少明确分区条件:" + ";".join(missing))

+ 142 - 0
src/data_query_agent/state.py

@@ -0,0 +1,142 @@
+from __future__ import annotations
+
+import json
+import sqlite3
+import threading
+import time
+import uuid
+from dataclasses import dataclass
+from pathlib import Path
+
+
+@dataclass(frozen=True)
+class Conversation:
+    key: str
+    session_id: str
+    thread_id: str | None
+    last_active_at: float
+
+
+class StateStore:
+    def __init__(self, path: Path, idle_hours: int = 24) -> None:
+        path.parent.mkdir(parents=True, exist_ok=True)
+        self.path = path
+        self.idle_seconds = idle_hours * 3600
+        self._lock = threading.RLock()
+        self._conn = sqlite3.connect(path, check_same_thread=False)
+        self._conn.row_factory = sqlite3.Row
+        self._conn.execute("PRAGMA journal_mode=WAL")
+        self._conn.execute("PRAGMA foreign_keys=ON")
+        self._create_schema()
+
+    def _create_schema(self) -> None:
+        with self._lock, self._conn:
+            self._conn.executescript(
+                """
+                CREATE TABLE IF NOT EXISTS conversations (
+                    conversation_key TEXT PRIMARY KEY,
+                    session_id TEXT NOT NULL,
+                    thread_id TEXT,
+                    last_active_at REAL NOT NULL
+                );
+                CREATE TABLE IF NOT EXISTS messages (
+                    message_id TEXT PRIMARY KEY,
+                    conversation_key TEXT NOT NULL,
+                    received_at REAL NOT NULL,
+                    status TEXT NOT NULL,
+                    error TEXT
+                );
+                CREATE TABLE IF NOT EXISTS runs (
+                    run_id TEXT PRIMARY KEY,
+                    conversation_key TEXT NOT NULL,
+                    message_id TEXT NOT NULL,
+                    status TEXT NOT NULL,
+                    artifact_dir TEXT NOT NULL,
+                    instance_id TEXT,
+                    feishu_url TEXT,
+                    metadata_json TEXT NOT NULL DEFAULT '{}',
+                    created_at REAL NOT NULL,
+                    updated_at REAL NOT NULL
+                );
+                """
+            )
+
+    def claim_message(self, message_id: str, conversation_key: str) -> bool:
+        now = time.time()
+        with self._lock, self._conn:
+            cursor = self._conn.execute(
+                "INSERT OR IGNORE INTO messages(message_id, conversation_key, received_at, status) VALUES (?, ?, ?, 'received')",
+                (message_id, conversation_key, now),
+            )
+            return cursor.rowcount == 1
+
+    def finish_message(self, message_id: str, status: str, error: str | None = None) -> None:
+        with self._lock, self._conn:
+            self._conn.execute(
+                "UPDATE messages SET status = ?, error = ? WHERE message_id = ?",
+                (status, error[:1000] if error else None, message_id),
+            )
+
+    def get_conversation(self, key: str) -> Conversation:
+        now = time.time()
+        with self._lock, self._conn:
+            row = self._conn.execute(
+                "SELECT * FROM conversations WHERE conversation_key = ?", (key,)
+            ).fetchone()
+            if row is None or now - row["last_active_at"] > self.idle_seconds:
+                session_id = uuid.uuid4().hex
+                self._conn.execute(
+                    "INSERT INTO conversations(conversation_key, session_id, thread_id, last_active_at) VALUES (?, ?, NULL, ?) "
+                    "ON CONFLICT(conversation_key) DO UPDATE SET session_id=excluded.session_id, thread_id=NULL, last_active_at=excluded.last_active_at",
+                    (key, session_id, now),
+                )
+                return Conversation(key, session_id, None, now)
+            self._conn.execute(
+                "UPDATE conversations SET last_active_at = ? WHERE conversation_key = ?", (now, key)
+            )
+            return Conversation(key, row["session_id"], row["thread_id"], now)
+
+    def reset_conversation(self, key: str, new_session: bool) -> Conversation:
+        current = self.get_conversation(key)
+        now = time.time()
+        session_id = uuid.uuid4().hex if new_session else current.session_id
+        with self._lock, self._conn:
+            self._conn.execute(
+                "UPDATE conversations SET session_id = ?, thread_id = NULL, last_active_at = ? WHERE conversation_key = ?",
+                (session_id, now, key),
+            )
+        return Conversation(key, session_id, None, now)
+
+    def set_thread(self, key: str, thread_id: str) -> None:
+        with self._lock, self._conn:
+            self._conn.execute(
+                "UPDATE conversations SET thread_id = ?, last_active_at = ? WHERE conversation_key = ?",
+                (thread_id, time.time(), key),
+            )
+
+    def create_run(self, run_id: str, key: str, message_id: str, artifact_dir: Path) -> None:
+        now = time.time()
+        with self._lock, self._conn:
+            self._conn.execute(
+                "INSERT INTO runs(run_id, conversation_key, message_id, status, artifact_dir, created_at, updated_at) VALUES (?, ?, ?, 'planning', ?, ?, ?)",
+                (run_id, key, message_id, str(artifact_dir), now, now),
+            )
+
+    def update_run(self, run_id: str, status: str, *, instance_id: str | None = None, feishu_url: str | None = None, metadata: dict | None = None) -> None:
+        with self._lock, self._conn:
+            self._conn.execute(
+                "UPDATE runs SET status=?, instance_id=COALESCE(?, instance_id), feishu_url=COALESCE(?, feishu_url), metadata_json=?, updated_at=? WHERE run_id=?",
+                (status, instance_id, feishu_url, json.dumps(metadata or {}, ensure_ascii=False), time.time(), run_id),
+            )
+
+    def fail_active_runs(self, message_id: str, error: str) -> None:
+        with self._lock, self._conn:
+            self._conn.execute(
+                "UPDATE runs SET status='failed', metadata_json=?, updated_at=? "
+                "WHERE message_id=? AND status NOT IN ('completed', 'failed')",
+                (json.dumps({"error": error[:500]}, ensure_ascii=False), time.time(), message_id),
+            )
+
+    def close(self) -> None:
+        with self._lock:
+            self._conn.close()

+ 138 - 0
task_plan.md

@@ -0,0 +1,138 @@
+# Task Plan: Data Query Agent
+
+## Goal
+
+Build a Python Codex SDK service that receives allowlisted Feishu messages, keeps per-user-per-chat multi-turn context, generates and safely executes read-only ODPS SQL, and automatically publishes results as tenant-readable Feishu spreadsheets.
+
+## Phases
+
+### Phase 1: Scaffold and configuration
+- [x] Create package, configuration, runtime directories, and launch scripts
+- [x] Migrate local credentials into an ignored `.env` without exposing them
+- **Status:** completed
+
+### Phase 2: Core runtime
+- [x] Implement SQLite state, command parsing, SQL validation, ODPS execution, and report generation
+- [x] Implement Codex SDK structured planning and multi-turn thread persistence
+- **Status:** completed
+
+### Phase 3: Feishu integration
+- [x] Implement WebSocket message intake, allowlists, replies, spreadsheet import, and tenant-readable permissions
+- [x] Connect the end-to-end asynchronous workflow
+- **Status:** completed
+
+### Phase 4: Skills
+- [x] Create the generic ODPS query Skill
+- [x] Vendor and adapt the four existing query Skills and Feishu publisher Skill
+- [x] Validate every Skill
+- **Status:** completed
+
+### Phase 5: Verification
+- [x] Add focused unit tests and read-only external connectivity checks
+- [x] Run compile, tests, dependency check, secret scan, and launch-script checks
+- **Status:** completed (OpenRouter credential health check is externally blocked by a stale key)
+
+### Phase 6: Skill-aware execution routing
+- [x] Define a structured Codex decision that identifies the selected Skill and execution mode
+- [x] Let Codex read matched Skill instructions while keeping credentials out of its environment
+- [x] Reuse validated Skill SQL/scripts when available; generate SQL only for generic or uncovered queries
+- **Status:** completed
+
+### Phase 7: Host-side Skill execution and Feishu delivery
+- [x] Add an allowlisted Skill executor for supported script-based workflows
+- [x] Execute generated or reused read-only queries and publish the resulting spreadsheet to Feishu
+- [x] Add progress replies that accurately reflect routing, querying, and publishing
+- **Status:** completed
+
+### Phase 8: Skill-routing verification
+- [x] Add tests for implicit Skill selection, clarification, generic SQL, and script execution plans
+- [x] Run the full test suite and restart the Feishu long-connection service
+- **Status:** completed
+
+### Phase 9: Optional apptype behavior-path filtering
+- [x] Change timeline Skill contracts so omitted apptype means all products
+- [x] Update offline, realtime, and batch SQL scripts to add the predicate only when apptype is explicit
+- [x] Add regression tests, validate the Skill, and restart the Feishu service
+- **Status:** completed
+
+### Phase 10: Diagnose product-efficiency formatting failure
+- [x] Identify the failed run and distinguish ODPS execution from formatting
+- [x] Trace the invalid raw fact and exact formatter failure path
+- [x] Report root cause, impact, and recommended fix without changing production code
+- **Status:** completed
+
+### Phase 11: Harden product-efficiency Skill
+- [x] Make offline rootSessionId source mappings explicit in the Skill contract
+- [x] Reject semantically inconsistent raw facts and tolerate legitimate zero denominators
+- [x] Add formatter regressions, validate the Skill, and restart the Feishu service
+- **Status:** completed
+
+### Phase 12: Add a Chinese generic-query data catalog
+- [x] Inventory the 14 verified source tables and fields used by existing query Skills
+- [x] Add a Chinese table/field catalog and convert the generic query Skill instructions to Chinese
+- [x] Validate catalog coverage, forward-check generic SQL generation, and restart the Feishu service
+- **Status:** completed
+
+### Phase 13: Review updated product-efficiency Skill and reload service
+- [x] Identify and analyze the externally modified Skill files and behavioral changes
+- [x] Validate the updated Skill and run relevant regressions
+- [x] Wait for active queries to finish, then restart and verify the Feishu service
+- **Status:** completed
+
+### Phase 14: Diagnose zero product-efficiency returns
+- [x] Trace the latest report from generated SQL through raw and formatted return fields
+- [x] Compare source-share filtering with the established offline return contract
+- [x] Confirm the cause using a bounded read-only ODPS diagnostic
+- **Status:** completed
+
+### Phase 15: Fix product-efficiency return attribution contract
+- [x] Specify exact source-share and click topic filters in the product-efficiency Skill
+- [x] Align the generic table catalog and add regression coverage
+- [x] Forward-check fresh SQL generation, then restart and verify the Feishu service
+- **Status:** completed
+
+### Phase 13: Diagnose all-zero product-efficiency video facts
+- [x] Inspect the latest SDK request, repaired SQL, raw facts, formatter output, and host validation path
+- [x] Compare rootSessionId mappings and video partitions with the known-good offline query
+- [x] Report the root cause and guard gap without changing production code
+- **Status:** completed
+
+### Phase 14: Enforce no-business product-efficiency video queries
+- [x] Update the repository-local product-efficiency Skill for offline and realtime video sources
+- [x] Reject any product-efficiency video `business` predicate and accept offline `dt`-only partition filtering
+- [x] Add regressions, run the full suite, validate the Skill, and restart the Feishu service
+- **Status:** completed
+
+## Decisions
+
+- Python 3.12, `openai-codex==0.144.4`, OpenRouter, GPT-5.6 Terra.
+- PyODPS direct read-only query path.
+- Feishu WebSocket long connection and automatic spreadsheet publication.
+- Context key is chat type + chat ID + sender open ID.
+- Commands: `/new`, `/clear`, `/help` plus exact English and Chinese aliases.
+- SQLite is a local state file; query artifacts remain ordinary files.
+- Deployment uses `nohup` management scripts, not Docker or systemd.
+- Skill SQL and scripts are preferred over newly generated SQL when a matching Skill already defines a validated workflow.
+- Codex selects the Skill and extracts parameters; the Python host owns credentials, executes allowlisted workflows, and publishes results.
+- For behavior-path queries, omitted `apptype` means no product filter; explicit `apptype` remains an exact filter.
+
+## Errors Encountered
+
+| Error | Attempt | Resolution |
+|---|---:|---|
+| SOCKS proxy extras missing in the isolated venv | 1 | Added `httpx[socks]` and `requests[socks]` dependencies. |
+| Feishu SDK INFO log included ephemeral WebSocket query credentials | 1 | Switched SDK logging to ERROR, deleted the affected log, and verified the replacement log contains no access key or ticket. |
+| OpenRouter returned `401 User not found` | 1 | Found a stale shell `OPENROUTER_API_KEY` overriding the valid project `.env`; changed project config to prefer `.env` and added a regression test. |
+| Initial result-publishing refactor referenced pre-refactor local variables | 1 | Replaced them with the method arguments and derived run directory before running tests. |
+| OpenRouter rejected the nested Skill routing schema because `additionalProperties: false` was absent | 1 | Set all structured-output Pydantic models to forbid extra properties, producing a strict compatible schema. |
+| Enabling read-only file tools could expose project credential files | 1 | Replaced the broad read-only sandbox preset with a least-privilege permission profile that denies root, `.env*`, runtime, logs, and Git while allowing read-only Skill workspace access. |
+| Repeated standalone multi-case SDK probes completed without forwarding Python stdout | 3 | Stopped repeating the wrapper probe; inspect the generated Codex session record and rely on the service/doctor path for integration validation. |
+| Git diff/status checks failed because the project is not a Git repository | 1 | Switched final validation to compilation, tests, configuration/doctor checks, and a secret scan excluding the expected local `.env`. |
+| Diagnostic shell used reserved zsh variable `status` after reproducing the formatter traceback | 1 | The traceback was captured successfully; avoid that variable name in subsequent commands. |
+| First real-plan probe had invalid nested shell quoting | 1 | Removed the embedded single-quoted JSON-path literal and changed the assertion to safe SQL substrings. |
+| `nohup` service process was reaped when the tool command ended | 1 | Removed the stale PID and launched the service in a persistent foreground exec session; PID and WebSocket startup were verified. |
+| Conversation diagnostic queried a nonexistent `updated_at` column | 1 | Inspect the SQLite schema first and query only the actual conversation columns. |
+| Planned cleanup patch no longer matched `sql_guard.py` | 1 | Detected a concurrent 15:39 Skill/guard update, re-read the changed files, and preserved the newer business-field validation instead of overwriting it. |
+| A diagnostic `rg` pattern used backticks inside double quotes, causing zsh command substitution | 1 | The file output was still obtained; use single-quoted search patterns or plain terms for future shell searches. |
+| First fresh-plan assertion command had unmatched nested shell quotes | 1 | Switch to a quoted Python heredoc so SQL literals are not interpreted by zsh. |
+| Product-efficiency repair added `video_action_log_applet.business='applet'` | 1 | Diagnosed that product-efficiency SQL must not filter `business` at all; the host metadata guard incorrectly required every reported partition column instead of accepting the Skill's verified `dt`-only offline predicate. |

+ 24 - 0
tests/test_authorization.py

@@ -0,0 +1,24 @@
+from data_query_agent.models import IncomingMessage
+from data_query_agent.service import DataQueryService
+
+
+def message(*, chat_id: str = "allowed", chat_type: str = "group", mentioned: bool = True) -> IncomingMessage:
+    return IncomingMessage(
+        message_id="m1",
+        chat_id=chat_id,
+        chat_type=chat_type,
+        sender_open_id="any-group-member",
+        text="查询昨天 DAU",
+        message_type="text",
+        mentioned_bot=mentioned,
+    )
+
+
+def test_authorizes_any_member_who_mentions_bot_in_allowed_group() -> None:
+    service = object.__new__(DataQueryService)
+    service.settings = type("Settings", (), {"allowed_chat_ids": frozenset({"allowed"})})()
+
+    assert service._authorized(message()) is True
+    assert service._authorized(message(chat_id="other")) is False
+    assert service._authorized(message(mentioned=False)) is False
+    assert service._authorized(message(chat_type="p2p")) is False

+ 16 - 0
tests/test_codex_worker.py

@@ -0,0 +1,16 @@
+from data_query_agent.codex_worker import _provider_config
+
+
+def test_codex_can_read_skills_but_not_project_credentials() -> None:
+    provider, config = _provider_config(
+        {"provider": "openrouter", "base_url": "https://openrouter.ai/api/v1"}
+    )
+
+    assert provider == "openrouter"
+    assert config is not None
+    assert config["features"]["shell_tool"] is True
+    assert config["features"]["unified_exec"] is True
+    filesystem = config["permissions"]["skill-reader"]["filesystem"]
+    assert filesystem[":workspace_roots"]["."] == "read"
+    assert filesystem[":workspace_roots"][".env"] == "deny"
+    assert config["permissions"]["skill-reader"]["network"]["enabled"] is False

+ 12 - 0
tests/test_commands.py

@@ -0,0 +1,12 @@
+from data_query_agent.commands import parse_command
+
+
+def test_commands_are_exact_matches() -> None:
+    assert parse_command(" /new ") == "new"
+    assert parse_command("开启新会话") == "new"
+    assert parse_command("CLEAR") == "clear"
+    assert parse_command("帮助") == "help"
+    assert parse_command("/skills") == "skills"
+    assert parse_command("目前你有哪些skill 都支持查询什么") == "skills"
+    assert parse_command("你能查询什么") == "skills"
+    assert parse_command("new 查询昨天 DAU") is None

+ 14 - 0
tests/test_config.py

@@ -0,0 +1,14 @@
+from data_query_agent.config import Settings
+
+
+def test_project_env_overrides_stale_shell_value(tmp_path, monkeypatch) -> None:
+    monkeypatch.setenv("OPENROUTER_API_KEY", "stale-shell-key")
+    (tmp_path / ".env").write_text(
+        "OPENROUTER_API_KEY=project-key\nCODEX_MODEL=google/gemini-3-flash-preview\n",
+        encoding="utf-8",
+    )
+
+    settings = Settings.load(tmp_path)
+
+    assert settings.openrouter_api_key == "project-key"
+    assert settings.codex_model == "google/gemini-3-flash-preview"

+ 9 - 0
tests/test_feishu.py

@@ -0,0 +1,9 @@
+import json
+
+from data_query_agent.feishu import FeishuLongConnection
+
+
+def test_parse_text_and_post() -> None:
+    assert FeishuLongConnection._parse_content(json.dumps({"text": "hello"}), "text") == "hello"
+    post = {"zh_cn": {"title": "标题", "content": [[{"tag": "text", "text": "内容"}]]}}
+    assert FeishuLongConnection._parse_content(json.dumps(post, ensure_ascii=False), "post") == "标题\n内容"

+ 37 - 0
tests/test_generic_skill_catalog.py

@@ -0,0 +1,37 @@
+from pathlib import Path
+
+
+SKILL_DIR = Path(__file__).parents[1] / ".agents" / "skills" / "query-odps-data"
+
+
+def test_generic_skill_is_chinese_and_requires_catalog() -> None:
+    skill = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
+
+    assert "# 通用 ODPS 数据查询" in skill
+    assert "references/data-catalog.md" in skill
+    assert "目录未收录的表或字段" in skill
+
+
+def test_catalog_covers_all_confirmed_tables() -> None:
+    catalog = (SKILL_DIR / "references" / "data-catalog.md").read_text(encoding="utf-8")
+    tables = {
+        "loghubods.useractive_log",
+        "loghubods.useractive_log_per5min",
+        "loghubods.video_action_log_applet",
+        "loghubods.video_action_log_flow",
+        "loghubods.video_action_log_per5min",
+        "loghubods.video_play_log",
+        "loghubods.video_play_log_per5min",
+        "loghubods.user_share_log",
+        "loghubods.user_share_log_per5min",
+        "loghubods.simpleevent_log",
+        "loghubods.simpleevent_log_flow",
+        "loghubods.ad_action_log_own",
+        "loghubods.ad_action_log_own_per5min",
+        "loghubods.operation_log_per5min",
+        "videoods.dim_video",
+    }
+
+    assert all(f"`{table}`" in catalog for table in tables)
+    assert "禁止改用物理列 `rootsessionid`" in catalog
+    assert "未收录的字段不得按名称猜测含义" in catalog

+ 8 - 0
tests/test_models.py

@@ -0,0 +1,8 @@
+from data_query_agent.models import QueryAnalysis, QueryDecision, SkillParameters
+
+
+def test_structured_output_schemas_require_every_property() -> None:
+    for model in (SkillParameters, QueryDecision, QueryAnalysis):
+        schema = model.model_json_schema()
+        assert set(schema["required"]) == set(schema["properties"])
+        assert schema["additionalProperties"] is False

+ 62 - 0
tests/test_product_efficiency_formatter.py

@@ -0,0 +1,62 @@
+import importlib.util
+from pathlib import Path
+
+import pandas as pd
+import pytest
+
+
+SCRIPT = (
+    Path(__file__).parents[1]
+    / ".agents"
+    / "skills"
+    / "odps-product-efficiency-report"
+    / "scripts"
+    / "format_report.py"
+)
+spec = importlib.util.spec_from_file_location("product_efficiency_formatter", SCRIPT)
+assert spec and spec.loader
+formatter = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(formatter)
+
+
+CONFIG = {
+    "experiment_buckets": list("012345678"),
+    "control_buckets": list("9abcdef"),
+}
+
+
+def raw_facts(*, dau: int = 0, all_exposure_pv: int = 0) -> pd.DataFrame:
+    rows = []
+    for bucket in formatter.HEX:
+        row = {
+            "stat_date": "20260808",
+            "app_type": "4",
+            "version_code": "all",
+            "bucket": bucket,
+            **{column: 0 for column in formatter.RAW_FACT_COLUMNS},
+        }
+        row["dau"] = dau
+        row["all_exposure_pv"] = all_exposure_pv
+        rows.append(row)
+    return pd.DataFrame(rows)
+
+
+def test_rejects_zero_dau_with_nonzero_behavior_facts() -> None:
+    with pytest.raises(ValueError, match="zero DAU.*nonzero behavior.*rootSessionId"):
+        formatter.build_report(raw_facts(all_exposure_pv=1), CONFIG)
+
+
+def test_legitimate_all_zero_facts_keep_blank_rates() -> None:
+    report = formatter.build_report(raw_facts(), CONFIG)
+
+    assert len(report) == 20
+    assert len(report.columns) == 65
+    assert pd.isna(report.loc[0, "全部曝光PV/DAU"])
+    assert report.loc[0, "全部STR(分享PV/曝光PV)"] == ""
+
+
+def test_positive_dau_rates_are_unchanged() -> None:
+    report = formatter.build_report(raw_facts(dau=10, all_exposure_pv=20), CONFIG)
+
+    assert report.loc[0, "全部曝光PV/DAU"] == 2.0
+    assert report.loc[0, "全部STR(分享PV/曝光PV)"] == "0.0000%"

+ 30 - 0
tests/test_product_efficiency_skill_contract.py

@@ -0,0 +1,30 @@
+from pathlib import Path
+
+
+PROJECT = Path(__file__).parents[1]
+SKILL_DIR = PROJECT / ".agents" / "skills" / "odps-product-efficiency-report"
+
+
+def test_return_attribution_fields_are_explicit() -> None:
+    contract = "\n".join(
+        [
+            (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8"),
+            (SKILL_DIR / "references" / "metrics.md").read_text(encoding="utf-8"),
+            (SKILL_DIR / "references" / "raw-output-contract.md").read_text(encoding="utf-8"),
+        ]
+    )
+
+    assert "topic='share'" in contract
+    assert "topic='click'" in contract
+    assert "type='share'" in contract
+    assert "Never use `type='share'`" in contract
+
+
+def test_generic_catalog_uses_topic_for_return_chain() -> None:
+    catalog = (
+        PROJECT / ".agents" / "skills" / "query-odps-data" / "references" / "data-catalog.md"
+    ).read_text(encoding="utf-8")
+
+    assert "源分享使用 `topic='share'`" in catalog
+    assert "点击使用 `topic='click'`" in catalog
+    assert "禁止用 `type='share'` 判断源分享" in catalog

+ 12 - 0
tests/test_reports.py

@@ -0,0 +1,12 @@
+import pandas as pd
+
+from data_query_agent.reports import build_profile, write_result_files
+
+
+def test_empty_result_still_creates_publishable_workbook(tmp_path) -> None:
+    frame = pd.DataFrame(columns=["date", "dau"])
+    csv_path, xlsx_path = write_result_files(tmp_path, frame, "SELECT 1", {"run_id": "r1"})
+    assert csv_path.stat().st_size > 0
+    assert xlsx_path.stat().st_size > 0
+    assert build_profile(frame, False)["returned_rows"] == 0
+

+ 14 - 0
tests/test_security.py

@@ -0,0 +1,14 @@
+from dataclasses import replace
+
+from data_query_agent.codex_runtime import CodexRuntime
+from data_query_agent.config import Settings
+
+
+def test_codex_child_environment_excludes_data_credentials(tmp_path) -> None:
+    settings = replace(Settings.load(), runtime_dir=tmp_path)
+    env = CodexRuntime(settings)._child_env()
+    assert "ODPS_ACCESS_ID" not in env
+    assert "ODPS_ACCESS_KEY" not in env
+    assert "FEISHU_APP_SECRET" not in env
+    assert ("OPENROUTER_API_KEY" in env) != ("OPENAI_API_KEY" in env)
+

+ 100 - 0
tests/test_skill_executor.py

@@ -0,0 +1,100 @@
+from dataclasses import replace
+
+import pandas as pd
+import pytest
+
+from data_query_agent.config import Settings
+from data_query_agent.models import SkillParameters
+from data_query_agent.skill_executor import SkillExecutor
+
+
+def parameters(**overrides) -> SkillParameters:
+    values = {
+        "user_id": None,
+        "date": None,
+        "apptype": None,
+        "realtime": None,
+        "app_type": None,
+        "date_from": None,
+        "date_to": None,
+        "data_mode": None,
+        "bucket_position_from_end": None,
+        "experiment_buckets": None,
+        "control_buckets": None,
+        "version": None,
+        "first_layer_rule": None,
+        "exclude_qywx": None,
+    }
+    values.update(overrides)
+    return SkillParameters(**values)
+
+
+@pytest.mark.asyncio
+async def test_user_timeline_without_apptype_omits_product_argument(tmp_path, monkeypatch) -> None:
+    executor = SkillExecutor(replace(Settings.load(), runtime_dir=tmp_path))
+
+    async def fake_run(args, *, env=None):
+        assert args[1].endswith("query-user-behavior-path/scripts/user_timeline.py")
+        assert args[2:4] == ["mid_123", "20260806"]
+        assert args[4:] == ["--output-dir", str(tmp_path)]
+        assert env and "ODPS_ACCESS_ID" in env and "FEISHU_APP_SECRET" not in env
+        pd.DataFrame({"北京时间": ["2026-08-06 10:00:00"], "来源": ["video"]}).to_excel(
+            tmp_path / "timeline_test.xlsx", sheet_name="行为路径", index=False
+        )
+        return "[ODPS] InstanceId: i-test\n[XLSX] done"
+
+    monkeypatch.setattr(executor, "_run", fake_run)
+    artifact = await executor.run_user_timeline(
+        parameters(user_id="mid_123", date="20260806", apptype=None, realtime=False),
+        tmp_path,
+    )
+
+    assert artifact.instance_id == "i-test"
+    assert len(artifact.dataframe) == 1
+    assert artifact.xlsx_path.name == "timeline_test.xlsx"
+
+
+@pytest.mark.asyncio
+async def test_user_timeline_with_explicit_apptype_passes_exact_filter(tmp_path, monkeypatch) -> None:
+    executor = SkillExecutor(replace(Settings.load(), runtime_dir=tmp_path))
+
+    async def fake_run(args, *, env=None):
+        assert args[2:5] == ["mid_123", "20260806", "4"]
+        pd.DataFrame({"来源": ["video"]}).to_excel(
+            tmp_path / "timeline_filtered.xlsx", sheet_name="行为路径", index=False
+        )
+        return "[ODPS] InstanceId: i-filtered"
+
+    monkeypatch.setattr(executor, "_run", fake_run)
+    artifact = await executor.run_user_timeline(
+        parameters(user_id="mid_123", date="20260806", apptype="4", realtime=False),
+        tmp_path,
+    )
+
+    assert artifact.instance_id == "i-filtered"
+
+
+@pytest.mark.asyncio
+async def test_user_timeline_rejects_invalid_host_parameters(tmp_path) -> None:
+    executor = SkillExecutor(replace(Settings.load(), runtime_dir=tmp_path))
+    with pytest.raises(ValueError, match="mid/machinecode"):
+        await executor.run_user_timeline(
+            parameters(user_id="bad value", date="20260806", apptype="0", realtime=False),
+            tmp_path,
+        )
+
+
+@pytest.mark.asyncio
+async def test_generic_sql_result_uses_existing_workbook_path(tmp_path) -> None:
+    executor = SkillExecutor(replace(Settings.load(), runtime_dir=tmp_path))
+    artifact = await executor.format_report(
+        "query-odps-data",
+        parameters(),
+        tmp_path,
+        pd.DataFrame({"dau": [10]}),
+        "SELECT 10 AS dau",
+        {"ODPS instance_id": "i-generic"},
+    )
+
+    assert artifact.xlsx_path.is_file()
+    assert artifact.instance_id == "i-generic"

+ 129 - 0
tests/test_sql_guard.py

@@ -0,0 +1,129 @@
+import pytest
+
+from data_query_agent.sql_guard import SQLGuard, SQLValidationError
+
+
+def test_allows_one_select_and_tracks_cte_sources() -> None:
+    guard = SQLGuard(frozenset({"loghubods"}))
+    refs = guard.validate(
+        "WITH daily AS (SELECT uid FROM loghubods.events WHERE ds='20260810') SELECT count(*) FROM daily"
+    )
+    assert [(ref.project, ref.name) for ref in refs] == [("loghubods", "events")]
+
+
+@pytest.mark.parametrize(
+    "sql",
+    [
+        "DELETE FROM loghubods.events WHERE ds='20260810'",
+        "SELECT * FROM loghubods.events; SELECT * FROM loghubods.users",
+        "CREATE TABLE x AS SELECT 1",
+    ],
+)
+def test_rejects_non_read_only_or_multiple_statements(sql: str) -> None:
+    with pytest.raises(SQLValidationError):
+        SQLGuard(frozenset({"loghubods"})).validate(sql)
+
+
+def test_rejects_cross_project() -> None:
+    with pytest.raises(SQLValidationError, match="跨项目"):
+        SQLGuard(frozenset({"loghubods"})).validate("SELECT * FROM other_project.events")
+
+
+def test_partition_predicate_is_required() -> None:
+    with pytest.raises(SQLValidationError, match="分区"):
+        SQLGuard.validate_partition_predicates(
+            "SELECT uid FROM events WHERE uid > 0", {"events": ["ds"]}
+        )
+    SQLGuard.validate_partition_predicates(
+        "SELECT uid FROM events WHERE ds BETWEEN '20260801' AND '20260810'", {"events": ["ds"]}
+    )
+
+
+def test_each_partitioned_join_source_must_be_filtered() -> None:
+    partitions = {"events": ["ds"], "users": ["ds"]}
+    with pytest.raises(SQLValidationError, match="users"):
+        SQLGuard.validate_partition_predicates(
+            "SELECT a.uid FROM events a JOIN users b ON a.uid=b.uid WHERE a.ds='20260810'",
+            partitions,
+        )
+    SQLGuard.validate_partition_predicates(
+        "SELECT a.uid FROM events a JOIN users b ON a.uid=b.uid AND b.ds='20260810' WHERE a.ds='20260810'",
+        partitions,
+    )
+
+
+def test_video_action_applet_partition_requires_dt_but_not_business() -> None:
+    partitions = {"loghubods.video_action_log_applet": ["dt", "business"]}
+    SQLGuard.validate_partition_predicates(
+        "SELECT mid FROM loghubods.video_action_log_applet v "
+        "WHERE v.dt='20260810' AND v.businesstype='videoView'",
+        partitions,
+    )
+    with pytest.raises(SQLValidationError, match=r"video_action_log_applet\(dt\)"):
+        SQLGuard.validate_partition_predicates(
+            "SELECT mid FROM loghubods.video_action_log_applet v WHERE v.businesstype='videoView'",
+            partitions,
+        )
+
+
+def test_offline_product_efficiency_requires_extparams_root_session() -> None:
+    bad = """
+    SELECT SUBSTR(u.rootsessionid, LENGTH(u.rootsessionid) - 2, 1) bucket,
+           COUNT(DISTINCT u.machinecode) dau
+    FROM loghubods.useractive_log u
+    WHERE u.dt='20260808'
+    GROUP BY SUBSTR(u.rootsessionid, LENGTH(u.rootsessionid) - 2, 1)
+    """
+    with pytest.raises(SQLValidationError, match="extparams.*rootSessionId"):
+        SQLGuard.validate_product_efficiency_contract(bad, "offline")
+
+    good = """
+    SELECT SUBSTR(root_session_id, LENGTH(root_session_id) - 2, 1) bucket,
+           COUNT(DISTINCT machinecode) dau
+    FROM (
+      SELECT machinecode, GET_JSON_OBJECT(extparams, '$.rootSessionId') root_session_id
+      FROM loghubods.useractive_log
+      WHERE dt='20260808'
+    ) u
+    GROUP BY SUBSTR(root_session_id, LENGTH(root_session_id) - 2, 1)
+    """
+    SQLGuard.validate_product_efficiency_contract(good, "offline")
+
+
+def test_realtime_product_efficiency_does_not_use_offline_contract() -> None:
+    SQLGuard.validate_product_efficiency_contract("SELECT 1", "realtime")
+
+
+def test_realtime_product_efficiency_rejects_video_business_filter() -> None:
+    sql = """
+    SELECT mid, GET_JSON_OBJECT(extparams, '$.rootSessionId') root_session_id
+    FROM loghubods.video_action_log_flow v
+    WHERE v.year='2026' AND v.month='08' AND v.dt='12'
+      AND v.business='applet'
+      AND v.apptype='4' AND v.businesstype='videoView'
+    """
+    with pytest.raises(SQLValidationError, match="禁止使用 business"):
+        SQLGuard.validate_product_efficiency_contract(sql, "realtime")
+
+
+@pytest.mark.parametrize(
+    "business_filter",
+    ["v.business='applet'", "v.business IN ('videoView', 'videoPlay', 'videoShareFriend')"],
+)
+def test_offline_product_efficiency_rejects_video_business_filter(business_filter: str) -> None:
+    sql = f"""
+    WITH dau AS (
+      SELECT machinecode, GET_JSON_OBJECT(extparams, '$.rootSessionId') root_session_id
+      FROM loghubods.useractive_log
+      WHERE dt='20260810'
+    ), video AS (
+      SELECT mid, GET_JSON_OBJECT(extparams, '$.rootSessionId') root_session_id
+      FROM loghubods.video_action_log_applet v
+      WHERE v.dt='20260810'
+        AND {business_filter}
+        AND v.businesstype IN ('videoView', 'videoPlay', 'videoShareFriend')
+    )
+    SELECT COUNT(*) FROM dau JOIN video ON dau.root_session_id=video.root_session_id
+    """
+    with pytest.raises(SQLValidationError, match="禁止使用 business"):
+        SQLGuard.validate_product_efficiency_contract(sql, "offline")

+ 53 - 0
tests/test_state.py

@@ -0,0 +1,53 @@
+import time
+
+from data_query_agent.state import StateStore
+
+
+def test_conversations_are_isolated_and_resettable(tmp_path) -> None:
+    store = StateStore(tmp_path / "state.sqlite3", idle_hours=24)
+    first = store.get_conversation("group:c1:u1")
+    second = store.get_conversation("group:c2:u1")
+    assert first.session_id != second.session_id
+
+    store.set_thread(first.key, "thread-1")
+    assert store.get_conversation(first.key).thread_id == "thread-1"
+    cleared = store.reset_conversation(first.key, new_session=False)
+    assert cleared.session_id == first.session_id
+    assert cleared.thread_id is None
+    renewed = store.reset_conversation(first.key, new_session=True)
+    assert renewed.session_id != first.session_id
+
+
+def test_message_claim_is_idempotent(tmp_path) -> None:
+    store = StateStore(tmp_path / "state.sqlite3")
+    assert store.claim_message("m1", "p2p:c1:u1") is True
+    assert store.claim_message("m1", "p2p:c1:u1") is False
+
+
+def test_active_run_is_failed_without_overwriting_completed_run(tmp_path) -> None:
+    store = StateStore(tmp_path / "state.sqlite3")
+    active_dir = tmp_path / "active"
+    done_dir = tmp_path / "done"
+    store.create_run("r1", "p2p:c1:u1", "m1", active_dir)
+    store.create_run("r2", "p2p:c1:u1", "m1", done_dir)
+    store.update_run("r2", "completed")
+    store.fail_active_runs("m1", "safe error")
+    rows = store._conn.execute("SELECT run_id, status FROM runs ORDER BY run_id").fetchall()
+    assert [(row["run_id"], row["status"]) for row in rows] == [
+        ("r1", "failed"),
+        ("r2", "completed"),
+    ]
+
+
+def test_idle_conversation_starts_new_thread(tmp_path) -> None:
+    store = StateStore(tmp_path / "state.sqlite3", idle_hours=1)
+    conversation = store.get_conversation("p2p:c1:u1")
+    store.set_thread(conversation.key, "old-thread")
+    with store._conn:
+        store._conn.execute(
+            "UPDATE conversations SET last_active_at=? WHERE conversation_key=?",
+            (time.time() - 7200, conversation.key),
+        )
+    expired = store.get_conversation(conversation.key)
+    assert expired.thread_id is None
+    assert expired.session_id != conversation.session_id

+ 72 - 0
tests/test_timeline_sql.py

@@ -0,0 +1,72 @@
+import importlib.util
+import sys
+from pathlib import Path
+
+
+SCRIPTS = Path(__file__).parents[1] / ".agents" / "skills" / "query-user-behavior-path" / "scripts"
+sys.path.insert(0, str(SCRIPTS))
+spec = importlib.util.spec_from_file_location("timeline_skill_script", SCRIPTS / "user_timeline.py")
+assert spec and spec.loader
+timeline = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(timeline)
+
+
+def load_script(name: str):
+    module_spec = importlib.util.spec_from_file_location(f"timeline_{name}", SCRIPTS / f"{name}.py")
+    assert module_spec and module_spec.loader
+    module = importlib.util.module_from_spec(module_spec)
+    module_spec.loader.exec_module(module)
+    return module
+
+
+realtime = load_script("user_timeline_realtime")
+realtime_batch = load_script("user_timeline_realtime_batch")
+
+
+def realtime_args(apptype):
+    return {
+        "mc": "mid_123",
+        "mc_list": "'mid_123'",
+        "apptype_filter": timeline.build_apptype_filter(apptype),
+        "dt_start": "20260806000000",
+        "dt_end": "20260806235959",
+        "year": "2026",
+        "month": "08",
+        "day_of_month": "06",
+    }
+
+
+def test_omitted_apptype_adds_no_product_predicate() -> None:
+    sql = timeline.SQL_ALL.format(
+        day="20260806",
+        mc="mid_123",
+        apptype_filter=timeline.build_apptype_filter(None),
+    )
+
+    assert "AND apptype=" not in sql
+
+
+def test_explicit_apptype_filters_every_timeline_source() -> None:
+    sql = timeline.SQL_ALL.format(
+        day="20260806",
+        mc="mid_123",
+        apptype_filter=timeline.build_apptype_filter("4"),
+    )
+
+    assert sql.count("apptype='4'") == 6
+
+
+def test_realtime_timeline_apptype_is_optional_across_all_sources() -> None:
+    unfiltered = realtime.SQL_REALTIME.format(**realtime_args(None))
+    filtered = realtime.SQL_REALTIME.format(**realtime_args("4"))
+
+    assert "AND apptype=" not in unfiltered
+    assert filtered.count("apptype='4'") == 7
+
+
+def test_realtime_batch_apptype_is_optional_across_all_sources() -> None:
+    unfiltered = realtime_batch.SQL_REALTIME_BATCH.format(**realtime_args(None))
+    filtered = realtime_batch.SQL_REALTIME_BATCH.format(**realtime_args("4"))
+
+    assert "AND apptype=" not in unfiltered
+    assert filtered.count("apptype='4'") == 7